diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c42782257..7732d2476 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,12 +34,16 @@ jobs: os: ubuntu-latest - rid: win-x64 name: officecli-win-x64.exe - os: ubuntu-latest + os: windows-latest - rid: win-arm64 name: officecli-win-arm64.exe - os: ubuntu-latest + os: windows-latest runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + steps: - uses: actions/checkout@v4 @@ -81,14 +85,41 @@ jobs: if: >- (matrix.rid == 'osx-arm64' && runner.arch == 'ARM64') || (matrix.rid == 'osx-x64' && runner.arch == 'X64') || - (matrix.rid == 'linux-x64' && runner.os == 'Linux') + (matrix.rid == 'linux-x64' && runner.os == 'Linux') || + (matrix.rid == 'win-x64' && runner.os == 'Windows') + env: + # Disable Git Bash (MSYS) POSIX-to-Windows path conversion on + # windows-latest, which otherwise mangles `/body` into + # `C:/Program Files/Git/body` before it reaches the CLI. + MSYS_NO_PATHCONV: '1' + MSYS2_ARG_CONV_EXCL: '*' run: | chmod +x publish/${{ matrix.name }} publish/${{ matrix.name }} create test_smoke.docx publish/${{ matrix.name }} add test_smoke.docx /body --type paragraph --prop text="Hello from CI" - publish/${{ matrix.name }} get test_smoke.docx /body/p[1] + publish/${{ matrix.name }} get test_smoke.docx '/body/p[1]' + publish/${{ matrix.name }} close test_smoke.docx rm -f test_smoke.docx + - name: Smoke test - install + if: >- + (matrix.rid == 'osx-arm64' && runner.arch == 'ARM64') || + (matrix.rid == 'osx-x64' && runner.arch == 'X64') || + (matrix.rid == 'linux-x64' && runner.os == 'Linux') || + (matrix.rid == 'win-x64' && runner.os == 'Windows') + env: + MSYS_NO_PATHCONV: '1' + MSYS2_ARG_CONV_EXCL: '*' + run: | + publish/${{ matrix.name }} install + if [ "$RUNNER_OS" == "Windows" ]; then + test -f "$LOCALAPPDATA/OfficeCli/officecli.exe" || { echo "FAIL: officecli.exe not found in %LOCALAPPDATA%\\OfficeCli"; exit 1; } + "$LOCALAPPDATA/OfficeCli/officecli.exe" --version + else + test -f "$HOME/.local/bin/officecli" || { echo "FAIL: officecli not found in ~/.local/bin"; exit 1; } + "$HOME/.local/bin/officecli" --version + fi + - name: Upload artifact uses: actions/upload-artifact@v4 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..0b52afd76 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,126 @@ +# Contributing to OfficeCLI + +> 中文版 / Chinese version: [CONTRIBUTING.zh.md](./CONTRIBUTING.zh.md) + +> You must follow the two rules below. Code style, dependencies, tests, and +> docs are handled by the maintainer in post-merge cleanup — do not worry +> about them. + +## Rule 1: One PR = one atomic change + +A PR must contain exactly one feature or one bug fix that cannot be further +decomposed. If your change can be split into multiple pieces that each have +standalone value, submit each piece as a separate PR. + +### Self-check + +Before opening the PR, ask your AI tool: + +> "Analyze this diff. Can it be decomposed into multiple PRs where each +> could be merged or reverted independently? If yes, list them." + +If the answer is "yes, N PRs", split into N PRs before submitting. + +### Examples + +**✅ Single-PR bugs** — one root cause, one fix +- `Picture added with only 'width' specified gets wrong default height` +- `Body-level find: anchor throws ArgumentException` +- `AddParagraph --index N is off-by-one when the body contains a table` + +**✅ Single-PR features** — one coherent capability +- `query ole: list embedded OLE objects with ProgID and dimensions` +- `set wrap/hposition/vposition on floating pictures` + +**❌ Must split** — multiple independent changes bundled together +- `Fix picture index bug + add OLE detection + add HTML heading numbering` + → 3 PRs, zero shared code +- `Add OLE object detection + add EMF→PNG conversion` + → 2 PRs, two independent layers +- `Add auto aspect ratio + fix index off-by-one + fix line spacing clipping` + → 3 PRs, three unrelated root causes + +**🤔 Judgment calls** — default to splitting +- `Add helper function + its first consumer` + → 1 or 2 PRs; split if the helper has standalone reuse potential +- `Add read support + add write support for the same property` + → 1 or 2 PRs; split if you want read to land before write is vetted + +## Rule 2: Every PR must include a verifiable validation method + +State in the PR description (or a linked issue) how a reviewer can confirm +your change actually works. + +### For bug-fix PRs — pick one (in order of preference) + +1. **officecli command sequence** showing broken output before and fixed + output after +2. **Shell or Python script** that reproduces the bug and runs clean after + the fix +3. **Authoritative reference** showing what the correct behavior should be + (OOXML spec, Microsoft / ECMA docs, etc.) +4. **Screenshot** — only when the bug is purely visual + +### For feature PRs — include at minimum + +- **A screenshot** of the feature in action (Word / Excel / PowerPoint + window, HTML preview, or terminal output) +- Optionally a command sequence showing how to trigger it + +### Examples + +**Bug fix — command sequence (ideal):** + +```bash +# Before my fix: +officecli blank test.docx +officecli add test.docx picture --prop "path=photo-2x1.png" --prop "width=10cm" +officecli query test.docx picture +# → height: "10.2cm" ❌ WRONG (hardcoded 4-inch default) + +# After my fix: +officecli blank test.docx +officecli add test.docx picture --prop "path=photo-2x1.png" --prop "width=10cm" +officecli query test.docx picture +# → height: "5.0cm" ✓ CORRECT (auto-computed from 2:1 pixel ratio) +``` + +**Feature — screenshot (ideal):** + +> **Heading auto-numbering from style chain** +> +> Before: ![heading-before.png] (plain "Chapter One" with no number) +> After: ![heading-after.png] ("1. Chapter One" with auto-numbering span) +> +> How to trigger: +> ```bash +> officecli blank demo.docx +> officecli add demo.docx paragraph --prop "style=Heading1" --prop "text=Chapter One" +> officecli watch demo.docx +> ``` + +## If you don't follow these rules + +The maintainer reserves two options. + +### Option A — Reject and ask for resubmission (preferred) + +The maintainer closes the PR with a link to this guide and asks you to +resubmit as properly decomposed PRs with validation methods. + +**Your credit:** the PR is entirely yours, including the **"Merged"** badge +after resubmission. + +### Option B — Cherry-pick the valuable parts (last resort) + +If part of your PR is clearly valuable and worth saving, the maintainer runs +`git cherry-pick` on those commits into `main` directly and closes the +original PR. + +**Your credit:** +- `git cherry-pick` preserves the original author, so `git log` and + `git blame` still show you as author of those lines. +- The maintainer's reconcile commit message carries a + `Co-authored-by: ` trailer, which counts toward your + GitHub contribution graph. +- **However, the original PR shows as "Closed" instead of "Merged"**. diff --git a/CONTRIBUTING.zh.md b/CONTRIBUTING.zh.md new file mode 100644 index 000000000..4560ba6f9 --- /dev/null +++ b/CONTRIBUTING.zh.md @@ -0,0 +1,118 @@ +# 为 OfficeCLI 贡献代码 + +> English / 英文主文件: [CONTRIBUTING.md](./CONTRIBUTING.md) + +> 你必须遵守下面两条规则。代码风格、依赖、测试、文档由维护者在 merge 之后通过 +> follow-up commit 处理 —— 不用操心。 + +## Rule 1: 一个 PR 只做一件不可再拆的事 + +一个 PR 必须包含且仅包含一个 feature 或一个 bug 修复,而且这个单元不能再被拆分。 +如果你的改动可以被拆成多个每个都有独立价值的部分,就拆成多个 PR 分别提交。 + +### 自检 + +提交前,先让你的 AI 做一次拆分分析: + +> "分析下面这一坨 diff,它能不能拆成多个独立的 PR,每个都可以独立 merge 或独立 +> revert?如果可以,列出来。" + +如果回答是"可以,N 个 PR",就先拆再提。 + +### Examples + +**✅ 可以作为一个 PR 的 bug** —— 单一根因,单一修复 +- `图片只指定 width 时 height fallback 错了` +- `body 级 find: 锚点抛 ArgumentException` +- `AddParagraph --index N 在 body 含 table 时偏移` + +**✅ 可以作为一个 PR 的 feature** —— 单一 coherent 能力 +- `query ole: 列出所有嵌入的 OLE 对象及其 ProgID 和尺寸` +- `set wrap/hposition/vposition on floating pictures` + +**❌ 必须拆** —— 多个独立改动被打包 +- `修图片索引 bug + 加 OLE 检测 + 加 HTML heading 编号` + → 3 个 PR,零共享代码 +- `加 OLE 对象检测 + 加 EMF→PNG 转换` + → 2 个 PR,两个独立 layer +- `加自动宽高比 + 修索引 off-by-one + 修行距裁剪` + → 3 个 PR,三个不相关的根因 + +**🤔 可拆可不拆** —— 默认选拆 +- `加一个 helper 函数 + 第一处调用者` + → 1 或 2 个 PR;helper 有独立复用价值就拆 +- `加 read 支持 + 加 write 支持(同一属性)` + → 1 或 2 个 PR;希望 read 先被 vet 就拆 + +## Rule 2: 每个 PR 必须附带可验证的验证方法 + +在 PR description 或关联 issue 里写清楚:reviewer 怎么才能验证你的改动真的有效。 + +### Bug 修复 PR —— 至少给出一种(按优先顺序) + +1. **officecli 命令序列**,展示改动前的错误输出和改动后的正确输出 +2. **shell 或 python 脚本**,能复现 bug、在修复后干净退出 +3. **权威文档引用**,说明正确行为应该是什么样(OOXML spec、Microsoft / ECMA + 文档等) +4. **截图** —— 仅当 bug 纯粹是视觉问题时 + +### Feature PR —— 至少包含 + +- **一张截图**,展示 feature 实际效果(Word / Excel / PowerPoint 窗口、HTML + 预览、或终端输出) +- 可选:一段 shell 命令序列说明如何触发这个 feature + +### Examples + +**Bug 修复 —— 命令序列格式(最理想):** + +```bash +# Before my fix: +officecli blank test.docx +officecli add test.docx picture --prop "path=photo-2x1.png" --prop "width=10cm" +officecli query test.docx picture +# → height: "10.2cm" ❌ 错(硬编码 4 英寸 fallback) + +# After my fix: +officecli blank test.docx +officecli add test.docx picture --prop "path=photo-2x1.png" --prop "width=10cm" +officecli query test.docx picture +# → height: "5.0cm" ✓ 对(根据 2:1 像素比例自动计算) +``` + +**Feature —— 截图格式(最理想):** + +> **标题自动编号(从 style chain 解析)** +> +> Before: ![heading-before.png] (纯 "Chapter One",无编号) +> After: ![heading-after.png] ("1. Chapter One",带自动编号 span) +> +> 如何触发: +> ```bash +> officecli blank demo.docx +> officecli add demo.docx paragraph --prop "style=Heading1" --prop "text=Chapter One" +> officecli watch demo.docx +> ``` + +## 如果你不遵守这两条规则 + +维护者保留以下两种处理方式。 + +### Option A —— 拒绝并要求重新提交(首选) + +维护者关闭 PR,留一条指向本 guide 的 comment,请你按规则拆分后重新提交。 + +**你的 credit:** PR 完全归你,重新提交成功后仍然拿 **"Merged"** badge。 + +### Option B —— Cherry-pick 有价值的部分(最后手段) + +如果你的 PR 里有一部分明显有价值、值得保留,维护者会用 `git cherry-pick` 直接把 +这些 commit 摘到 `main`,然后关闭原 PR。 + +**你的 credit:** +- `git cherry-pick` 保留原作者,所以 `git log` 和 `git blame` 里那些代码行仍然 + 显示你是作者。 +- 维护者创建的 reconcile commit message 会附带 + `Co-authored-by: ` trailer,GitHub 贡献图会把它算进你的 + contribution。 +- **但原 PR 会显示为 "Closed" 而不是 "Merged"**。 diff --git a/README.md b/README.md index a2f27439d..c7db2e279 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Open-source. Single binary. No Office installation. No dependencies. Works every [![GitHub Release](https://img.shields.io/github/v/release/iOfficeAI/OfficeCLI)](https://github.com/iOfficeAI/OfficeCLI/releases) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) -**English** | [中文](README_zh.md) +**English** | [中文](README_zh.md) | [日本語](README_ja.md) | [한국어](README_ko.md)

OfficeCLI creating a PowerPoint presentation on AionUi @@ -66,38 +66,59 @@ 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. -## Quick Start +## For Humans + +**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. + +**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. -From zero to a finished presentation in seconds: +## For Developers — See It Live in 30 Seconds ```bash -# Create a new PowerPoint +# 1. Install (macOS / Linux) +curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh | bash +# Windows (PowerShell): irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex + +# 2. Create a blank PowerPoint officecli create deck.pptx -# Add a slide with a title and background color -officecli add deck.pptx / --type slide --prop title="Q4 Report" --prop background=1A1A2E +# 3. Start live preview — opens http://localhost:26315 in your browser +officecli watch deck.pptx --port 26315 -# Add a text shape to the slide -officecli add deck.pptx /slide[1] --type shape \ +# 4. Open another terminal, add a slide — watch the browser update instantly +officecli add deck.pptx / --type slide --prop title="Hello, World!" +``` + +That's it. Every `add`, `set`, or `remove` command you run will refresh the preview in real time. Keep experimenting — the browser is your live feedback loop. + +## Quick Start + +```bash +# 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 \ --prop text="Revenue grew 25%" --prop x=2cm --prop y=5cm \ --prop font=Arial --prop size=24 --prop color=FFFFFF -# View the outline of the presentation +# View as outline officecli view deck.pptx outline -``` - -Output: +# → Slide 1: Q4 Report +# → Shape 1 [TextBox]: Revenue grew 25% -``` -Slide 1: Q4 Report - Shape 1 [TextBox]: Revenue grew 25% -``` +# View as HTML — opens a rendered preview in your browser, no server needed +officecli view deck.pptx html -```bash # 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 @@ -148,11 +169,11 @@ officecli add deck.pptx / --type slide --prop title="Q4 Report" | Excel (.xlsx) | ✅ | ✅ | ✅ | | PowerPoint (.pptx) | ✅ | ✅ | ✅ | -**Word** — [paragraphs](https://github.com/iOfficeAI/OfficeCLI/wiki/word-paragraph), [runs](https://github.com/iOfficeAI/OfficeCLI/wiki/word-run), [tables](https://github.com/iOfficeAI/OfficeCLI/wiki/word-table), [styles](https://github.com/iOfficeAI/OfficeCLI/wiki/word-style), [headers/footers](https://github.com/iOfficeAI/OfficeCLI/wiki/word-header-footer), [images](https://github.com/iOfficeAI/OfficeCLI/wiki/word-picture), [equations](https://github.com/iOfficeAI/OfficeCLI/wiki/word-equation), [comments](https://github.com/iOfficeAI/OfficeCLI/wiki/word-comment), [footnotes](https://github.com/iOfficeAI/OfficeCLI/wiki/word-footnote), [watermarks](https://github.com/iOfficeAI/OfficeCLI/wiki/word-watermark), [bookmarks](https://github.com/iOfficeAI/OfficeCLI/wiki/word-bookmark), [TOC](https://github.com/iOfficeAI/OfficeCLI/wiki/word-toc), [charts](https://github.com/iOfficeAI/OfficeCLI/wiki/word-chart), [hyperlinks](https://github.com/iOfficeAI/OfficeCLI/wiki/word-hyperlink), [sections](https://github.com/iOfficeAI/OfficeCLI/wiki/word-section), [form fields](https://github.com/iOfficeAI/OfficeCLI/wiki/word-formfield), [content controls (SDT)](https://github.com/iOfficeAI/OfficeCLI/wiki/word-sdt), [fields](https://github.com/iOfficeAI/OfficeCLI/wiki/word-field), [document properties](https://github.com/iOfficeAI/OfficeCLI/wiki/word-document) +**Word** — [paragraphs](https://github.com/iOfficeAI/OfficeCLI/wiki/word-paragraph), [runs](https://github.com/iOfficeAI/OfficeCLI/wiki/word-run), [tables](https://github.com/iOfficeAI/OfficeCLI/wiki/word-table), [styles](https://github.com/iOfficeAI/OfficeCLI/wiki/word-style), [headers/footers](https://github.com/iOfficeAI/OfficeCLI/wiki/word-header-footer), [images](https://github.com/iOfficeAI/OfficeCLI/wiki/word-picture) (PNG/JPG/GIF/SVG), [equations](https://github.com/iOfficeAI/OfficeCLI/wiki/word-equation), [comments](https://github.com/iOfficeAI/OfficeCLI/wiki/word-comment), [footnotes](https://github.com/iOfficeAI/OfficeCLI/wiki/word-footnote), [watermarks](https://github.com/iOfficeAI/OfficeCLI/wiki/word-watermark), [bookmarks](https://github.com/iOfficeAI/OfficeCLI/wiki/word-bookmark), [TOC](https://github.com/iOfficeAI/OfficeCLI/wiki/word-toc), [charts](https://github.com/iOfficeAI/OfficeCLI/wiki/word-chart), [hyperlinks](https://github.com/iOfficeAI/OfficeCLI/wiki/word-hyperlink), [sections](https://github.com/iOfficeAI/OfficeCLI/wiki/word-section), [form fields](https://github.com/iOfficeAI/OfficeCLI/wiki/word-formfield), [content controls (SDT)](https://github.com/iOfficeAI/OfficeCLI/wiki/word-sdt), [fields](https://github.com/iOfficeAI/OfficeCLI/wiki/word-field) (22 zero-param types + MERGEFIELD / REF / PAGEREF / SEQ / STYLEREF / DOCPROPERTY / IF), [OLE objects](https://github.com/iOfficeAI/OfficeCLI/wiki/word-ole), [document properties](https://github.com/iOfficeAI/OfficeCLI/wiki/word-document) -**Excel** — [cells](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-cell), formulas (150+ built-in functions with auto-evaluation), [sheets](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-sheet), [tables](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-table), [conditional formatting](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-conditionalformatting), [charts](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-chart), [pivot tables](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-pivottable), [named ranges](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-namedrange), [data validation](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-validation), [images](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-picture), [sparklines](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-sparkline), [comments](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-comment), [autofilter](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-autofilter), [shapes](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-shape), CSV/TSV import, `$Sheet:A1` cell addressing +**Excel** — [cells](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-cell), formulas (150+ built-in functions with auto-evaluation, `_xlfn.` auto-prefix for dynamic-array functions), [sheets](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-sheet), [tables](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-table), [sort](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-sort) (sheet / range, multi-key, sidecar-aware), [conditional formatting](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-conditionalformatting), [charts](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-chart) (including box-whisker, [pareto](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-chart-add) with auto-sort + cumulative-%, log axis), [pivot tables](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-pivottable) (multi-field, date grouping, showDataAs, sort, grandTotals, subtotals, compact/outline/tabular layout, repeat item labels, blank rows, calculated fields), [slicers](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-slicer), [named ranges](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-namedrange), [data validation](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-validation), [images](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-picture) (PNG/JPG/GIF/SVG with dual-representation fallback), [sparklines](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-sparkline), [comments](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-comment), [autofilter](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-autofilter), [shapes](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-shape), [OLE objects](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-ole), CSV/TSV import, `$Sheet:A1` cell addressing -**PowerPoint** — [slides](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-slide), [shapes](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-shape), [images](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-picture), [tables](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-table), [charts](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-chart), [animations](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-slide), [morph transitions](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-morph-check), [3D models (.glb)](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-3dmodel), [slide zoom](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-zoom), [equations](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-equation), [themes](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-theme), [connectors](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-connector), [video/audio](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-video), [groups](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-group), [notes](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-notes), [placeholders](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-placeholder) +**PowerPoint** — [slides](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-slide) (header/footer/date/slidenum toggles), [shapes](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-shape) (pattern fill, blur effect, hyperlink tooltip + slide-jump links), [images](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-picture) (PNG/JPG/GIF/SVG, fill modes: stretch/contain/cover/tile), [tables](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-table), [charts](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-chart), [animations](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-slide), [morph transitions](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-morph-check), [3D models (.glb)](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-3dmodel), [slide zoom](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-zoom), [equations](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-equation), [themes](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-theme), [connectors](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-connector), [video/audio](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-video), [groups](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-group), [notes](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-notes), [OLE objects](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-ole), [placeholders](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-placeholder) (add/set by phType) ## Use Cases @@ -198,10 +219,11 @@ irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex Verify installation: `officecli --version` -**Or self-install from a downloaded binary:** +**Or self-install from a downloaded binary (or run bare `officecli` to auto-install):** ```bash -officecli install +officecli install # explicit +officecli # bare invocation also triggers install ``` Updates are checked automatically in the background. Disable with `officecli config autoUpdate false` or skip per-invocation with `OFFICECLI_SKIP_UPDATE=1`. Configuration lives under `~/.officecli/config.json`. @@ -214,10 +236,10 @@ Updates are checked automatically in the background. Disable with `officecli con ```bash officecli watch deck.pptx -# Opens http://localhost:18080 — refreshes on every set/add/remove +# Opens http://localhost:26315 — refreshes on every set/add/remove ``` -Renders shapes, charts, equations, 3D models (Three.js), morph transitions, zoom navigation, and all shape effects. +Renders shapes, charts (with trendlines, error bars, pseudo-3D, waterfall, stock candlestick), equations, 3D models (Three.js), morph transitions, zoom navigation, and all shape effects. Excel watch features native-style green cell selection, rectangular range selection, **double-click inline cell editing**, and **drag-to-reposition charts**. ### Resident Mode & Batch @@ -230,10 +252,16 @@ officecli set report.docx /body/p[1]/r[1] --prop bold=true officecli set report.docx /body/p[2]/r[1] --prop color=FF0000 officecli close report.docx -# Batch mode — atomic multi-command execution +# Batch mode — atomic multi-command execution (stops on first error by default) echo '[{"command":"set","path":"/slide[1]/shape[1]","props":{"text":"Hello"}}, {"command":"set","path":"/slide[1]/shape[2]","props":{"fill":"FF0000"}}]' \ - | officecli batch deck.pptx --stop-on-error + | officecli batch deck.pptx --json + +# Inline batch with --commands (no stdin needed) +officecli batch deck.pptx --commands '[{"op":"set","path":"/slide[1]/shape[1]","props":{"text":"Hi"}}]' + +# Use --force to continue past errors +officecli batch deck.pptx --input updates.json --force --json ``` ### Three-Layer Architecture @@ -243,7 +271,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 @@ -257,7 +285,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' @@ -303,7 +331,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. @@ -435,10 +463,11 @@ 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 (JSON on stdin or `--input`) | +| `view issues` | Enumerate document issues (text overflow, missing alt text, formula errors, ...) | +| [`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) | | [`merge`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-merge) | Template merge — replace `{{key}}` placeholders with JSON data | | [`watch`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-watch) | Live HTML preview in browser with auto-refresh | | [`mcp`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-mcp) | Start MCP server for AI tool integration | @@ -461,10 +490,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 @@ -474,7 +503,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 @@ -551,6 +580,8 @@ Bug reports and contributions are welcome on [GitHub Issues](https://github.com/ --- +If you find OfficeCLI useful, please [give it a star on GitHub](https://github.com/iOfficeAI/OfficeCLI) — it helps others discover the project. + [OfficeCLI.AI](https://OfficeCLI.AI) | [GitHub](https://github.com/iOfficeAI/OfficeCLI) @@ -583,7 +614,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 new file mode 100644 index 000000000..25bdfa0c8 --- /dev/null +++ b/README_ja.md @@ -0,0 +1,620 @@ +# OfficeCLI + +> **OfficeCLI は世界初にして最高の、AI エージェント向けに設計されたコマンドラインツールです。** + +**あらゆる AI エージェントに Word、Excel、PowerPoint の完全な制御権を — たった一行のコードで。** + +オープンソース。単一バイナリ。Office のインストール不要。依存関係ゼロ。全プラットフォーム対応。 + +[![GitHub Release](https://img.shields.io/github/v/release/iOfficeAI/OfficeCLI)](https://github.com/iOfficeAI/OfficeCLI/releases) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) + +[English](README.md) | [中文](README_zh.md) | **日本語** | [한국어](README_ko.md) + +

+ AionUi で OfficeCLI を使った PPT 作成プロセス +

+ +

AionUi で OfficeCLI を使った PPT 作成プロセス

+ +

PowerPoint プレゼンテーション

+ + + + + + + + + + + + +
OfficeCLI デザインプレゼン (PowerPoint)OfficeCLI ビジネスプレゼン (PowerPoint)OfficeCLI テクノロジープレゼン (PowerPoint)
OfficeCLI 宇宙プレゼン (PowerPoint)OfficeCLI ゲームプレゼン (PowerPoint)OfficeCLI クリエイティブプレゼン (PowerPoint)
+ +

+

Word 文書

+ + + + + + + +
OfficeCLI 学術論文 (Word)OfficeCLI プロジェクト提案書 (Word)OfficeCLI 年次報告書 (Word)
+ +

+

Excel スプレッドシート

+ + + + + + + +
OfficeCLI 予算管理 (Excel)OfficeCLI 成績管理 (Excel)OfficeCLI 売上ダッシュボード (Excel)
+ +

上記の文書はすべて AI エージェントが OfficeCLI を使って全自動で作成 — テンプレートなし、手動編集なし。

+ +## AI エージェント向け — 一行で開始 + +これを AI エージェントのチャットに貼り付けるだけ — スキルファイルを自動で読み込み、インストールを完了します: + +``` +curl -fsSL https://officecli.ai/SKILL.md +``` + +これだけです。スキルファイルがエージェントにバイナリのインストール方法と全コマンドの使い方を教えます。 + +> **技術詳細:** OfficeCLI には [SKILL.md](SKILL.md) が付属し、コマンド構文、アーキテクチャ、よくある落とし穴をカバーしています。インストール後、エージェントはすぐに Office 文書の作成・読み取り・変更が可能です。 + +## 一般ユーザー向け + +**オプション A — GUI:** [**AionUi**](https://github.com/iOfficeAI/AionUi) をインストール — 自然言語で Office 文書を作成・編集できるデスクトップアプリ。内部で OfficeCLI が動いています。やりたいことを説明するだけで、AionUi がすべて処理します。 + +**オプション B — CLI:** [GitHub Releases](https://github.com/iOfficeAI/OfficeCLI/releases) からお使いのプラットフォーム用バイナリをダウンロードして、以下を実行: + +```bash +officecli install +``` + +バイナリを PATH にコピーし、検出されたすべての AI コーディングエージェント(Claude Code、Cursor、Windsurf、GitHub Copilot など)に **officecli スキル**を自動インストールします。エージェントはすぐに Office 文書の作成・読み取り・編集が可能になります。追加設定は不要です。 + +## 開発者向け — 30秒でライブ体験 + +```bash +# 1. インストール(macOS / Linux) +curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh | bash +# Windows (PowerShell): irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex + +# 2. 空の PowerPoint を作成 +officecli create deck.pptx + +# 3. ライブプレビューを開始 — ブラウザで http://localhost:26315 が開きます +officecli watch deck.pptx --port 26315 + +# 4. 別のターミナルを開いてスライドを追加 — ブラウザが即座に更新されます +officecli add deck.pptx / --type slide --prop title="Hello, World!" +``` + +これだけです。`add`、`set`、`remove` コマンドを実行するたびに、プレビューがリアルタイムで更新されます。どんどん試してみてください — ブラウザがあなたのライブフィードバックループです。 + +## クイックスタート + +```bash +# プレゼンテーションを作成してコンテンツを追加 +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 \ + --prop text="Revenue grew 25%" --prop x=2cm --prop y=5cm \ + --prop font=Arial --prop size=24 --prop color=FFFFFF + +# アウトラインを表示 +officecli view deck.pptx outline +# → Slide 1: Q4 Report +# → Shape 1 [TextBox]: Revenue grew 25% + +# HTML で表示 — サーバー不要、ブラウザでレンダリングされたプレビューを開きます +officecli view deck.pptx html + +# 任意の要素の構造化 JSON を取得 +officecli get deck.pptx '/slide[1]/shape[1]' --json +``` + +```json +{ + "tag": "shape", + "path": "/slide[1]/shape[1]", + "attributes": { + "name": "TextBox 1", + "text": "Revenue grew 25%", + "x": "720000", + "y": "1800000" + } +} +``` + +## なぜ OfficeCLI? + +以前は 50行の Python と 3つのライブラリが必要でした: + +```python +from pptx import Presentation +from pptx.util import Inches, Pt +prs = Presentation() +slide = prs.slides.add_slide(prs.slide_layouts[0]) +title = slide.shapes.title +title.text = "Q4 Report" +# ... さらに 45行 ... +prs.save('deck.pptx') +``` + +今はコマンド一つで: + +```bash +officecli add deck.pptx / --type slide --prop title="Q4 Report" +``` + +**OfficeCLI でできること:** + +- **作成** ドキュメント -- 空白またはコンテンツ付き +- **読み取り** テキスト、構造、スタイル、数式 -- プレーンテキストまたは構造化 JSON +- **分析** フォーマットの問題、スタイルの不整合、構造的な欠陥 +- **修正** 任意の要素 -- テキスト、フォント、色、レイアウト、数式、チャート、画像 +- **再構成** コンテンツ -- 要素の追加、削除、移動、文書間コピー + +| フォーマット | 読み取り | 修正 | 作成 | +|-------------|---------|------|------| +| Word (.docx) | ✅ | ✅ | ✅ | +| Excel (.xlsx) | ✅ | ✅ | ✅ | +| PowerPoint (.pptx) | ✅ | ✅ | ✅ | + +**Word** — [段落](https://github.com/iOfficeAI/OfficeCLI/wiki/word-paragraph)、[ラン](https://github.com/iOfficeAI/OfficeCLI/wiki/word-run)、[表](https://github.com/iOfficeAI/OfficeCLI/wiki/word-table)、[スタイル](https://github.com/iOfficeAI/OfficeCLI/wiki/word-style)、[ヘッダー/フッター](https://github.com/iOfficeAI/OfficeCLI/wiki/word-header-footer)、[画像](https://github.com/iOfficeAI/OfficeCLI/wiki/word-picture)、[数式](https://github.com/iOfficeAI/OfficeCLI/wiki/word-equation)、[コメント](https://github.com/iOfficeAI/OfficeCLI/wiki/word-comment)、[脚注](https://github.com/iOfficeAI/OfficeCLI/wiki/word-footnote)、[透かし](https://github.com/iOfficeAI/OfficeCLI/wiki/word-watermark)、[ブックマーク](https://github.com/iOfficeAI/OfficeCLI/wiki/word-bookmark)、[目次](https://github.com/iOfficeAI/OfficeCLI/wiki/word-toc)、[チャート](https://github.com/iOfficeAI/OfficeCLI/wiki/word-chart)、[ハイパーリンク](https://github.com/iOfficeAI/OfficeCLI/wiki/word-hyperlink)、[セクション](https://github.com/iOfficeAI/OfficeCLI/wiki/word-section)、[フォームフィールド](https://github.com/iOfficeAI/OfficeCLI/wiki/word-formfield)、[コンテンツコントロール (SDT)](https://github.com/iOfficeAI/OfficeCLI/wiki/word-sdt)、[フィールド](https://github.com/iOfficeAI/OfficeCLI/wiki/word-field)、[文書プロパティ](https://github.com/iOfficeAI/OfficeCLI/wiki/word-document) + +**Excel** — [セル](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-cell)、数式(150以上の組み込み関数を自動計算)、[シート](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-sheet)、[テーブル](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-table)、[条件付き書式](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-conditionalformatting)、[チャート](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-chart)、[ピボットテーブル](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-pivottable)(マルチフィールド、日付グループ化、showDataAs、ソート、総計、小計)、[名前付き範囲](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-namedrange)、[データ入力規則](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-validation)、[画像](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-picture)、[スパークライン](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-sparkline)、[コメント](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-comment)、[オートフィルター](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-autofilter)、[図形](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-shape)、CSV/TSV インポート、`$Sheet:A1` セルアドレッシング + +**PowerPoint** — [スライド](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-slide)、[図形](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-shape)、[画像](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-picture)、[表](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-table)、[チャート](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-chart)、[アニメーション](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-slide)、[モーフトランジション](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-morph-check)、[3D モデル (.glb)](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-3dmodel)、[スライドズーム](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-zoom)、[数式](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-equation)、[テーマ](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-theme)、[コネクタ](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-connector)、[ビデオ/オーディオ](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-video)、[グループ](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-group)、[ノート](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-notes)、[プレースホルダー](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-placeholder) + +## 使用シーン + +**開発者向け:** +- データベースや API からのレポート自動生成 +- 文書の一括処理(一括検索/置換、スタイル更新) +- CI/CD 環境でのドキュメントパイプライン構築(テスト結果からドキュメント生成) +- Docker/コンテナ環境でのヘッドレス Office 自動化 + +**AI エージェント向け:** +- ユーザーのプロンプトからプレゼンテーションを生成(上記の例を参照) +- ドキュメントから構造化データを JSON に抽出 +- 納品前のドキュメント品質検証 + +**チーム向け:** +- ドキュメントテンプレートを複製してデータを入力 +- CI/CD パイプラインでの自動ドキュメント検証 + +## インストール + +単一の自己完結型バイナリとして配布。.NET ランタイムは内蔵 -- インストール不要、ランタイム管理不要。 + +**ワンライナーインストール:** + +```bash +# macOS / Linux +curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh | bash + +# Windows (PowerShell) +irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex +``` + +**または手動ダウンロード** [GitHub Releases](https://github.com/iOfficeAI/OfficeCLI/releases): + +| プラットフォーム | バイナリ | +|----------------|---------| +| macOS Apple Silicon | `officecli-mac-arm64` | +| macOS Intel | `officecli-mac-x64` | +| Linux x64 | `officecli-linux-x64` | +| Linux ARM64 | `officecli-linux-arm64` | +| Windows x64 | `officecli-win-x64.exe` | +| Windows ARM64 | `officecli-win-arm64.exe` | + +インストール確認:`officecli --version` + +**またはダウンロード済みバイナリからセルフインストール(`officecli` を直接実行してもインストールがトリガーされます):** + +```bash +officecli install # 明示的インストール +officecli # 直接実行でもインストールがトリガー +``` + +更新はバックグラウンドで自動チェックされます。`officecli config autoUpdate false` で無効化、または `OFFICECLI_SKIP_UPDATE=1` で単回スキップ可能。設定は `~/.officecli/config.json` にあります。 + +## 主な機能 + +### ライブプレビュー + +`watch` はローカル HTTP サーバーを起動し、PowerPoint ファイルのライブ HTML プレビューを提供します。変更のたびにブラウザが自動更新 — AI エージェントとの反復デザインに最適です。 + +```bash +officecli watch deck.pptx +# http://localhost:26315 を開く — set/add/remove のたびに自動更新 +``` + +図形、チャート、数式、3D モデル(Three.js)、モーフトランジション、ズームナビゲーション、全シェイプエフェクトをレンダリングします。 + +### レジデントモードとバッチ + +複数ステップのワークフローでは、レジデントモードがドキュメントをメモリに保持。バッチモードは一度の open/save サイクルで複数操作を実行します。 + +```bash +# レジデントモード — 名前付きパイプ経由で遅延ほぼゼロ +officecli open report.docx +officecli set report.docx /body/p[1]/r[1] --prop bold=true +officecli set report.docx /body/p[2]/r[1] --prop color=FF0000 +officecli close report.docx + +# バッチモード — アトミックなマルチコマンド実行(デフォルトで最初のエラーで停止) +echo '[{"command":"set","path":"/slide[1]/shape[1]","props":{"text":"Hello"}}, + {"command":"set","path":"/slide[1]/shape[2]","props":{"fill":"FF0000"}}]' \ + | officecli batch deck.pptx --json + +# インラインバッチ — stdin 不要 +officecli batch deck.pptx --commands '[{"op":"set","path":"/slide[1]/shape[1]","props":{"text":"Hi"}}]' + +# --force でエラーをスキップして続行 +officecli batch deck.pptx --input updates.json --force --json +``` + +### 三層アーキテクチャ + +シンプルに始めて、必要な時だけ深く。 + +| レイヤー | 用途 | コマンド | +|---------|------|---------| +| **L1:読み取り** | コンテンツのセマンティックビュー | `view`(text、annotated、outline、stats、issues、html) | +| **L2:DOM** | 構造化された要素操作 | `get`、`query`、`set`、`add`、`remove`、`move`、`swap` | +| **L3:生 XML** | XPath による直接アクセス — 万能フォールバック | `raw`、`raw-set`、`add-part`、`validate` | + +```bash +# L1 — 高レベルビュー +officecli view report.docx annotated +officecli view budget.xlsx text --cols A,B,C --max-lines 50 + +# L2 — 要素レベルの操作 +officecli query report.docx "run:contains(TODO)" +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-set report.docx document \ + --xpath "//w:p[1]" --action append \ + --xml 'Injected text' +``` + +## AI 統合 + +### MCP サーバー + +組み込み [MCP](https://modelcontextprotocol.io) サーバー — コマンド一つで登録: + +```bash +officecli mcp claude # Claude Code +officecli mcp cursor # Cursor +officecli mcp vscode # VS Code / Copilot +officecli mcp lmstudio # LM Studio +officecli mcp list # 登録状態を確認 +``` + +JSON-RPC で全ドキュメント操作を公開 — シェルアクセス不要。 + +### 直接 CLI 統合 + +2ステップで OfficeCLI を任意の AI エージェントに統合: + +1. **バイナリをインストール** -- コマンド一つ([インストール](#インストール)参照) +2. **完了。** OfficeCLI は AI ツール(Claude Code、GitHub Copilot、Codex)を自動検出し、既知の設定ディレクトリを確認してスキルファイルをインストールします。エージェントはすぐに Office 文書の作成・読み取り・変更が可能です。 + +
+手動設定(オプション) + +自動インストールがお使いの環境に対応していない場合、手動でスキルファイルをインストールできます: + +**SKILL.md を直接エージェントに読み込ませる:** + +```bash +curl -fsSL https://officecli.ai/SKILL.md +``` + +**Claude Code のローカルスキルとしてインストール:** + +```bash +curl -fsSL https://officecli.ai/SKILL.md -o ~/.claude/skills/officecli.md +``` + +**その他のエージェント:** `SKILL.md` の内容をエージェントのシステムプロンプトまたはツール説明に含めてください。 + +
+ +**任意の言語から呼び出し:** + +```python +# Python +import subprocess, json +def cli(*args): return subprocess.check_output(["officecli", *args], text=True) +cli("create", "deck.pptx") +cli("set", "deck.pptx", "/slide[1]/shape[1]", "--prop", "text=Hello") +``` + +```js +// JavaScript +const { execFileSync } = require('child_process') +const cli = (...args) => execFileSync('officecli', args, { encoding: 'utf8' }) +cli('set', 'deck.pptx', '/slide[1]/shape[1]', '--prop', 'text=Hello') +``` + +全コマンドが `--json` で構造化出力に対応。パスベースのアドレッシングにより、エージェントは XML 名前空間を理解する必要がありません。 + +### エージェントが OfficeCLI を好む理由 + +- **決定論的な JSON 出力** -- 全コマンドが `--json` に対応し、一貫したスキーマの構造化データを返却。正規表現によるパース不要。 +- **パスベースのアドレッシング** -- 全要素が安定したパスを持つ(`/slide[1]/shape[2]`)。XML 名前空間を理解せずにドキュメントをナビゲート可能。注:パスは OfficeCLI 独自の構文(1始まりのインデックス、要素ローカル名)を使用し、XPath ではありません。 +- **段階的な複雑さ** -- L1(読み取り)から始め、L2(変更)にエスカレート、必要な時だけ L3(生 XML)にフォールバック。トークン消費を最小化。 +- **自己修復ワークフロー** -- `validate`、`view issues`、ヘルプシステムにより、エージェントは人間の介入なしに問題を検出・自己修正可能。 +- **組み込みヘルプ** -- プロパティ名や値の形式が不明な場合、`officecli set ` を実行して確認。推測不要。 +- **自動インストール** -- スキルファイルの手動設定不要。OfficeCLI が AI ツールを自動検出して設定を完了。 + +### 組み込みヘルプ + +プロパティ名がわからない時は、階層型ヘルプで確認: + +```bash +officecli pptx set # 全設定可能な要素とプロパティ +officecli pptx set shape # 特定の要素タイプの詳細 +officecli pptx set shape.fill # 単一プロパティのフォーマットと例 +officecli docx query # セレクタリファレンス:属性、:contains、:has() など +``` + +`pptx` を `docx` や `xlsx` に置き換え可能。動詞は `view`、`get`、`query`、`set`、`add`、`raw`。 + +`officecli --help` で全体概要を確認。 + +### JSON 出力スキーマ + +全コマンドが `--json` に対応。一般的なレスポンス形式: + +**単一要素**(`get --json`): + +```json +{"tag": "shape", "path": "/slide[1]/shape[1]", "attributes": {"name": "TextBox 1", "text": "Hello"}} +``` + +**要素リスト**(`query --json`): + +```json +[ + {"tag": "paragraph", "path": "/body/p[1]", "attributes": {"style": "Heading1", "text": "Title"}}, + {"tag": "paragraph", "path": "/body/p[5]", "attributes": {"style": "Heading1", "text": "Summary"}} +] +``` + +**エラー** は構造化エラーオブジェクトを返却。エラーコード、修正提案、利用可能な値を含みます: + +```json +{ + "success": false, + "error": { + "error": "Slide 50 not found (total: 8)", + "code": "not_found", + "suggestion": "Valid Slide index range: 1-8" + } +} +``` + +エラーコード:`not_found`、`invalid_value`、`unsupported_property`、`invalid_path`、`unsupported_type`、`missing_property`、`file_not_found`、`file_locked`、`invalid_selector`。プロパティ名は自動修正対応 -- プロパティ名のスペルミスは最も近い候補を提案します。 + +**エラー回復** -- エージェントは利用可能な要素を確認して自己修正: + +```bash +# エージェントが無効なパスを試行 +officecli get report.docx /body/p[99] --json +# 返却: {"success": false, "error": {"error": "...", "code": "not_found", "suggestion": "..."}} + +# エージェントが利用可能な要素を確認して自己修正 +officecli get report.docx /body --depth 1 --json +# 利用可能な子要素のリストを返却、エージェントが正しいパスを選択 +``` + +**変更確認**(`set`、`add`、`remove`、`move`、`create` で `--json` 使用時): + +```json +{"success": true, "path": "/slide[1]/shape[1]"} +``` + +`officecli --help` で終了コードとエラー形式の完全な説明を確認。 + +## 比較 + +| | OfficeCLI | Microsoft Office | LibreOffice | python-docx / openpyxl | +|---|---|---|---|---| +| オープンソース&無料 | ✓ (Apache 2.0) | ✗(有料ライセンス) | ✓ | ✓ | +| AI ネイティブ CLI + JSON | ✓ | ✗ | ✗ | ✗ | +| ゼロインストール(単一バイナリ) | ✓ | ✗ | ✗ | ✗(Python + pip 必要) | +| 任意の言語から呼び出し | ✓ (CLI) | ✗ (COM/Add-in) | ✗ (UNO API) | Python のみ | +| パスベースの要素アクセス | ✓ | ✗ | ✗ | ✗ | +| 生 XML フォールバック | ✓ | ✗ | ✗ | 部分対応 | +| ライブプレビュー | ✓ | ✓ | ✗ | ✗ | +| ヘッドレス / CI | ✓ | ✗ | 部分対応 | ✓ | +| クロスプラットフォーム | ✓ | Windows/Mac | ✓ | ✓ | +| Word + Excel + PowerPoint | ✓ | ✓ | ✓ | 複数ライブラリが必要 | + +## 更新と設定 + +```bash +officecli config autoUpdate false # 自動更新チェックを無効化 +OFFICECLI_SKIP_UPDATE=1 officecli ... # 単回のチェックをスキップ(CI 向け) +``` + +## コマンドリファレンス + +| コマンド | 説明 | +|---------|------| +| [`create`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-create) | 空白の .docx、.xlsx、.pptx を作成(拡張子からタイプを判定) | +| [`view`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-view) | コンテンツを表示(モード:`outline`、`text`、`annotated`、`stats`、`issues`、`html`) | +| [`get`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-get) | 要素と子要素を取得(`--depth N`、`--json`) | +| [`query`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-query) | CSS スタイルのクエリ(`[attr=value]`、`:contains()`、`:has()` など) | +| [`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`、`--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`;デフォルトで最初のエラーで停止、`--force` で続行) | +| [`merge`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-merge) | テンプレートマージ — `{{key}}` プレースホルダーを JSON データで置換 | +| [`watch`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-watch) | ブラウザでライブ HTML プレビュー、自動更新 | +| [`mcp`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-mcp) | AI ツール統合用の MCP サーバーを起動 | +| [`raw`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-raw) | ドキュメントパートの生 XML を表示 | +| [`raw-set`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-raw) | XPath で生 XML を変更 | +| `add-part` | 新しいドキュメントパート(ヘッダー、チャートなど)を追加 | +| [`open`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-open) | レジデントモードを開始(ドキュメントをメモリに保持) | +| `close` | 保存してレジデントモードを終了 | +| [`install`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-install) | バイナリ + スキル + MCP をインストール(`all`、`claude`、`cursor` など) | +| `config` | 設定の取得または変更 | +| ` ` | [組み込みヘルプ](https://github.com/iOfficeAI/OfficeCLI/wiki/command-reference)(例:`officecli pptx set shape`) | + +## エンドツーエンドワークフロー例 + +典型的なエージェント自己修復ワークフロー:プレゼンテーションの作成、コンテンツの入力、検証、問題の修正 -- すべて人間の介入なし。 + +```bash +# 1. 作成 +officecli create report.pptx + +# 2. コンテンツを追加 +officecli add report.pptx / --type slide --prop title="Q4 Results" +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 \ + --prop text="Growth driven by new markets" --prop x=2cm --prop y=5cm + +# 3. 検証 +officecli view report.pptx outline +officecli validate report.pptx + +# 4. 問題の修正 +officecli view report.pptx issues --json +# 出力に基づいて問題を修正: +officecli set report.pptx '/slide[1]/shape[1]' --prop font=Arial +``` + +### テンプレートマージ + +ドキュメント内の `{{key}}` プレースホルダーを JSON データで置換 -- 段落、表セル、図形、ヘッダー、フッター、チャートタイトルなど全テキストコンテンツに対応。 + +```bash +# インライン JSON データ +officecli merge template.docx output.docx '{"name":"Alice","dept":"Sales","date":"2026-03-30"}' + +# JSON ファイルから読み込み +officecli merge template.pptx report.pptx data.json + +# Excel テンプレート +officecli merge budget-template.xlsx q4-budget.xlsx '{"quarter":"Q4","year":"2026"}' +``` + +### 単位と色 + +すべての寸法・色プロパティは柔軟な入力形式に対応: + +| タイプ | 対応形式 | 例 | +|-------|---------|-----| +| **寸法** | cm、in、pt、px または生 EMU | `2cm`、`1in`、`72pt`、`96px`、`914400` | +| **色** | 16進数、色名、RGB、テーマ色 | `#FF0000`、`FF0000`、`red`、`rgb(255,0,0)`、`accent1` | +| **フォントサイズ** | 数値のみまたは pt 接尾辞付き | `14`、`14pt`、`10.5pt` | +| **間隔** | pt、cm、in または倍率 | `12pt`、`0.5cm`、`1.5x`、`150%` | + +## よく使うパターン + +```bash +# Word 文書の全 Heading1 テキストを置換 +officecli query report.docx "paragraph[style=Heading1]" --json | ... +officecli set report.docx /body/p[1]/r[1] --prop text="New Title" + +# 全スライドのコンテンツを JSON でエクスポート +officecli get deck.pptx / --depth 2 --json + +# Excel セルを一括更新 +officecli batch budget.xlsx --input updates.json --json + +# CSV データを Excel シートにインポート +officecli add budget.xlsx / --type sheet --prop name="Q1 Data" --prop csv=sales.csv + +# テンプレートマージでレポートを一括生成 +officecli merge invoice-template.docx invoice-001.docx '{"client":"Acme","total":"$5,200"}' + +# 納品前にドキュメント品質をチェック +officecli validate report.docx && officecli view report.docx issues --json +``` + +## ドキュメント + +[Wiki](https://github.com/iOfficeAI/OfficeCLI/wiki) に全コマンド、要素タイプ、プロパティの詳細ガイドがあります: + +- **フォーマット別:**[Word](https://github.com/iOfficeAI/OfficeCLI/wiki/word-reference) | [Excel](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-reference) | [PowerPoint](https://github.com/iOfficeAI/OfficeCLI/wiki/powerpoint-reference) +- **ワークフロー:**[エンドツーエンド例](https://github.com/iOfficeAI/OfficeCLI/wiki/workflows) -- Word レポート、Excel ダッシュボード、PPT プレゼン、一括変更、レジデントモード +- **トラブルシューティング:**[よくあるエラーと解決策](https://github.com/iOfficeAI/OfficeCLI/wiki/troubleshooting) +- **AI エージェントガイド:**[Wiki ナビゲーション決定木](https://github.com/iOfficeAI/OfficeCLI/wiki/agent-guide) + +## ソースからビルド + +コンパイルには [.NET 10 SDK](https://dotnet.microsoft.com/download) が必要です。出力は自己完結型のネイティブバイナリ -- .NET は内蔵されているため、実行時にはインストール不要です。 + +```bash +./build.sh +``` + +## ライセンス + +[Apache License 2.0](LICENSE) + +バグ報告やコントリビューションは [GitHub Issues](https://github.com/iOfficeAI/OfficeCLI/issues) まで。 + +--- + +OfficeCLI が役に立ったら、ぜひ [GitHub でスターを付けてください](https://github.com/iOfficeAI/OfficeCLI) — より多くの人にプロジェクトを届ける力になります。 + +[OfficeCLI.AI](https://OfficeCLI.AI) | [GitHub](https://github.com/iOfficeAI/OfficeCLI) + + + + diff --git a/README_ko.md b/README_ko.md new file mode 100644 index 000000000..5679c200e --- /dev/null +++ b/README_ko.md @@ -0,0 +1,620 @@ +# OfficeCLI + +> **OfficeCLI는 세계 최초이자 최고의, AI 에이전트를 위해 설계된 커맨드라인 도구입니다.** + +**모든 AI 에이전트에게 Word, Excel, PowerPoint의 완전한 제어권을 — 단 한 줄의 코드로.** + +오픈소스. 단일 바이너리. Office 설치 불필요. 의존성 제로. 모든 플랫폼 지원. + +[![GitHub Release](https://img.shields.io/github/v/release/iOfficeAI/OfficeCLI)](https://github.com/iOfficeAI/OfficeCLI/releases) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) + +[English](README.md) | [中文](README_zh.md) | [日本語](README_ja.md) | **한국어** + +

+ AionUi에서 OfficeCLI로 PPT 제작 과정 +

+ +

AionUi에서 OfficeCLI로 PPT 제작 과정

+ +

PowerPoint 프레젠테이션

+ + + + + + + + + + + + +
OfficeCLI 디자인 프레젠테이션 (PowerPoint)OfficeCLI 비즈니스 프레젠테이션 (PowerPoint)OfficeCLI 테크 프레젠테이션 (PowerPoint)
OfficeCLI 우주 프레젠테이션 (PowerPoint)OfficeCLI 게임 프레젠테이션 (PowerPoint)OfficeCLI 크리에이티브 프레젠테이션 (PowerPoint)
+ +

+

Word 문서

+ + + + + + + +
OfficeCLI 학술 논문 (Word)OfficeCLI 프로젝트 제안서 (Word)OfficeCLI 연간 보고서 (Word)
+ +

+

Excel 스프레드시트

+ + + + + + + +
OfficeCLI 예산 관리 (Excel)OfficeCLI 성적 관리 (Excel)OfficeCLI 매출 대시보드 (Excel)
+ +

위의 모든 문서는 AI 에이전트가 OfficeCLI를 사용하여 완전 자동으로 생성 — 템플릿 없음, 수동 편집 없음.

+ +## AI 에이전트용 — 한 줄로 시작 + +이 한 줄을 AI 에이전트 채팅에 붙여넣기만 하면 — 스킬 파일을 자동으로 읽고 설치를 완료합니다: + +``` +curl -fsSL https://officecli.ai/SKILL.md +``` + +이게 전부입니다. 스킬 파일이 에이전트에게 바이너리 설치 방법과 모든 명령어 사용법을 알려줍니다. + +> **기술 세부사항:** OfficeCLI에는 [SKILL.md](SKILL.md)가 포함되어 있으며, 명령어 구문, 아키텍처, 자주 발생하는 실수를 다룹니다. 설치 후 에이전트는 즉시 Office 문서를 생성, 읽기, 수정할 수 있습니다. + +## 일반 사용자용 + +**옵션 A — GUI:** [**AionUi**](https://github.com/iOfficeAI/AionUi)를 설치하세요 — 자연어로 Office 문서를 만들고 편집할 수 있는 데스크톱 앱입니다. 내부적으로 OfficeCLI가 구동됩니다. 원하는 것을 설명하기만 하면 AionUi가 모든 것을 처리합니다. + +**옵션 B — CLI:** [GitHub Releases](https://github.com/iOfficeAI/OfficeCLI/releases)에서 플랫폼에 맞는 바이너리를 다운로드한 후 실행: + +```bash +officecli install +``` + +바이너리를 PATH에 복사하고, 감지된 모든 AI 코딩 에이전트(Claude Code, Cursor, Windsurf, GitHub Copilot 등)에 **officecli 스킬**을 자동 설치합니다. 에이전트는 즉시 Office 문서를 생성, 읽기, 편집할 수 있으며 추가 설정이 필요 없습니다. + +## 개발자용 — 30초 만에 라이브로 확인 + +```bash +# 1. 설치 (macOS / Linux) +curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh | bash +# Windows (PowerShell): irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex + +# 2. 빈 PowerPoint 생성 +officecli create deck.pptx + +# 3. 라이브 미리보기 시작 — 브라우저에서 http://localhost:26315 이 열립니다 +officecli watch deck.pptx --port 26315 + +# 4. 다른 터미널을 열고 슬라이드 추가 — 브라우저가 즉시 업데이트됩니다 +officecli add deck.pptx / --type slide --prop title="Hello, World!" +``` + +이게 전부입니다. `add`, `set`, `remove` 명령을 실행할 때마다 미리보기가 실시간으로 갱신됩니다. 계속 실험해 보세요 — 브라우저가 바로 여러분의 라이브 피드백 루프입니다. + +## 빠른 시작 + +```bash +# 프레젠테이션을 생성하고 콘텐츠 추가 +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 \ + --prop text="Revenue grew 25%" --prop x=2cm --prop y=5cm \ + --prop font=Arial --prop size=24 --prop color=FFFFFF + +# 개요 보기 +officecli view deck.pptx outline +# → Slide 1: Q4 Report +# → Shape 1 [TextBox]: Revenue grew 25% + +# HTML로 보기 — 서버 없이 브라우저에서 렌더링된 미리보기를 엽니다 +officecli view deck.pptx html + +# 모든 요소의 구조화된 JSON 가져오기 +officecli get deck.pptx '/slide[1]/shape[1]' --json +``` + +```json +{ + "tag": "shape", + "path": "/slide[1]/shape[1]", + "attributes": { + "name": "TextBox 1", + "text": "Revenue grew 25%", + "x": "720000", + "y": "1800000" + } +} +``` + +## 왜 OfficeCLI인가? + +이전에는 50줄의 Python과 3개의 라이브러리가 필요했습니다: + +```python +from pptx import Presentation +from pptx.util import Inches, Pt +prs = Presentation() +slide = prs.slides.add_slide(prs.slide_layouts[0]) +title = slide.shapes.title +title.text = "Q4 Report" +# ... 45줄 더 ... +prs.save('deck.pptx') +``` + +이제 명령어 하나면 됩니다: + +```bash +officecli add deck.pptx / --type slide --prop title="Q4 Report" +``` + +**OfficeCLI로 할 수 있는 것:** + +- **생성** 문서 -- 빈 문서 또는 콘텐츠 포함 +- **읽기** 텍스트, 구조, 스타일, 수식 -- 일반 텍스트 또는 구조화된 JSON +- **분석** 서식 문제, 스타일 불일치, 구조적 결함 +- **수정** 모든 요소 -- 텍스트, 글꼴, 색상, 레이아웃, 수식, 차트, 이미지 +- **재구성** 콘텐츠 -- 요소 추가, 삭제, 이동, 문서 간 복사 + +| 형식 | 읽기 | 수정 | 생성 | +|------|------|------|------| +| Word (.docx) | ✅ | ✅ | ✅ | +| Excel (.xlsx) | ✅ | ✅ | ✅ | +| PowerPoint (.pptx) | ✅ | ✅ | ✅ | + +**Word** — [단락](https://github.com/iOfficeAI/OfficeCLI/wiki/word-paragraph), [런](https://github.com/iOfficeAI/OfficeCLI/wiki/word-run), [표](https://github.com/iOfficeAI/OfficeCLI/wiki/word-table), [스타일](https://github.com/iOfficeAI/OfficeCLI/wiki/word-style), [머리글/바닥글](https://github.com/iOfficeAI/OfficeCLI/wiki/word-header-footer), [이미지](https://github.com/iOfficeAI/OfficeCLI/wiki/word-picture), [수식](https://github.com/iOfficeAI/OfficeCLI/wiki/word-equation), [메모](https://github.com/iOfficeAI/OfficeCLI/wiki/word-comment), [각주](https://github.com/iOfficeAI/OfficeCLI/wiki/word-footnote), [워터마크](https://github.com/iOfficeAI/OfficeCLI/wiki/word-watermark), [북마크](https://github.com/iOfficeAI/OfficeCLI/wiki/word-bookmark), [목차](https://github.com/iOfficeAI/OfficeCLI/wiki/word-toc), [차트](https://github.com/iOfficeAI/OfficeCLI/wiki/word-chart), [하이퍼링크](https://github.com/iOfficeAI/OfficeCLI/wiki/word-hyperlink), [섹션](https://github.com/iOfficeAI/OfficeCLI/wiki/word-section), [양식 필드](https://github.com/iOfficeAI/OfficeCLI/wiki/word-formfield), [콘텐츠 컨트롤 (SDT)](https://github.com/iOfficeAI/OfficeCLI/wiki/word-sdt), [필드](https://github.com/iOfficeAI/OfficeCLI/wiki/word-field), [문서 속성](https://github.com/iOfficeAI/OfficeCLI/wiki/word-document) + +**Excel** — [셀](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-cell), 수식(150개 이상의 내장 함수 자동 계산), [시트](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-sheet), [테이블](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-table), [조건부 서식](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-conditionalformatting), [차트](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-chart), [피벗 테이블](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-pivottable) (다중 필드, 날짜 그룹화, showDataAs, 정렬, 총합계, 부분합), [이름 범위](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-namedrange), [데이터 유효성 검사](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-validation), [이미지](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-picture), [스파크라인](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-sparkline), [메모](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-comment), [자동 필터](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-autofilter), [도형](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-shape), CSV/TSV 가져오기, `$Sheet:A1` 셀 주소 지정 + +**PowerPoint** — [슬라이드](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-slide), [도형](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-shape), [이미지](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-picture), [표](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-table), [차트](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-chart), [애니메이션](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-slide), [모프 전환](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-morph-check), [3D 모델 (.glb)](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-3dmodel), [슬라이드 줌](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-zoom), [수식](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-equation), [테마](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-theme), [연결선](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-connector), [비디오/오디오](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-video), [그룹](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-group), [노트](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-notes), [플레이스홀더](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-placeholder) + +## 사용 사례 + +**개발자용:** +- 데이터베이스나 API에서 보고서 자동 생성 +- 문서 일괄 처리(일괄 검색/교체, 스타일 업데이트) +- CI/CD 환경에서 문서 파이프라인 구축(테스트 결과에서 문서 생성) +- Docker/컨테이너 환경에서의 헤드리스 Office 자동화 + +**AI 에이전트용:** +- 사용자 프롬프트에서 프레젠테이션 생성(위 예시 참조) +- 문서에서 구조화된 데이터를 JSON으로 추출 +- 납품 전 문서 품질 검증 + +**팀용:** +- 문서 템플릿을 복제하고 데이터 입력 +- CI/CD 파이프라인에서 자동 문서 검증 + +## 설치 + +단일 자체 완결형 바이너리로 제공. .NET 런타임 내장 -- 설치할 것도, 관리할 런타임도 없습니다. + +**원라인 설치:** + +```bash +# macOS / Linux +curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh | bash + +# Windows (PowerShell) +irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex +``` + +**또는 수동 다운로드** [GitHub Releases](https://github.com/iOfficeAI/OfficeCLI/releases): + +| 플랫폼 | 바이너리 | +|--------|---------| +| macOS Apple Silicon | `officecli-mac-arm64` | +| macOS Intel | `officecli-mac-x64` | +| Linux x64 | `officecli-linux-x64` | +| Linux ARM64 | `officecli-linux-arm64` | +| Windows x64 | `officecli-win-x64.exe` | +| Windows ARM64 | `officecli-win-arm64.exe` | + +설치 확인: `officecli --version` + +**또는 다운로드한 바이너리에서 셀프 설치 (`officecli`를 직접 실행해도 설치가 트리거됩니다):** + +```bash +officecli install # 명시적 설치 +officecli # 직접 실행으로도 설치 트리거 +``` + +업데이트는 백그라운드에서 자동 확인됩니다. `officecli config autoUpdate false`로 비활성화하거나 `OFFICECLI_SKIP_UPDATE=1`로 단일 실행 시 건너뛸 수 있습니다. 설정은 `~/.officecli/config.json`에 있습니다. + +## 주요 기능 + +### 라이브 미리보기 + +`watch`는 로컬 HTTP 서버를 시작하여 PowerPoint 파일의 라이브 HTML 미리보기를 제공합니다. 수정할 때마다 브라우저가 자동 새로고침 — AI 에이전트와의 반복 디자인에 최적입니다. + +```bash +officecli watch deck.pptx +# http://localhost:26315 열기 — set/add/remove 시마다 자동 새로고침 +``` + +도형, 차트, 수식, 3D 모델(Three.js), 모프 전환, 줌 내비게이션, 모든 도형 효과를 렌더링합니다. + +### 레지던트 모드와 배치 + +다단계 워크플로우에서 레지던트 모드는 문서를 메모리에 유지합니다. 배치 모드는 한 번의 open/save 사이클에서 여러 작업을 실행합니다. + +```bash +# 레지던트 모드 — 명명된 파이프로 거의 제로 지연 +officecli open report.docx +officecli set report.docx /body/p[1]/r[1] --prop bold=true +officecli set report.docx /body/p[2]/r[1] --prop color=FF0000 +officecli close report.docx + +# 배치 모드 — 원자적 다중 명령 실행 (기본적으로 첫 오류에서 중지) +echo '[{"command":"set","path":"/slide[1]/shape[1]","props":{"text":"Hello"}}, + {"command":"set","path":"/slide[1]/shape[2]","props":{"fill":"FF0000"}}]' \ + | officecli batch deck.pptx --json + +# 인라인 배치 — stdin 불필요 +officecli batch deck.pptx --commands '[{"op":"set","path":"/slide[1]/shape[1]","props":{"text":"Hi"}}]' + +# --force로 오류를 건너뛰고 계속 실행 +officecli batch deck.pptx --input updates.json --force --json +``` + +### 3계층 아키텍처 + +간단하게 시작하고, 필요할 때만 깊이 들어가세요. + +| 레이어 | 용도 | 명령어 | +|--------|------|--------| +| **L1: 읽기** | 콘텐츠의 시맨틱 뷰 | `view` (text, annotated, outline, stats, issues, html) | +| **L2: DOM** | 구조화된 요소 작업 | `get`, `query`, `set`, `add`, `remove`, `move`, `swap` | +| **L3: 원시 XML** | XPath 직접 접근 — 범용 폴백 | `raw`, `raw-set`, `add-part`, `validate` | + +```bash +# L1 — 고수준 뷰 +officecli view report.docx annotated +officecli view budget.xlsx text --cols A,B,C --max-lines 50 + +# L2 — 요소 수준 작업 +officecli query report.docx "run:contains(TODO)" +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-set report.docx document \ + --xpath "//w:p[1]" --action append \ + --xml 'Injected text' +``` + +## AI 통합 + +### MCP 서버 + +내장 [MCP](https://modelcontextprotocol.io) 서버 — 명령어 하나로 등록: + +```bash +officecli mcp claude # Claude Code +officecli mcp cursor # Cursor +officecli mcp vscode # VS Code / Copilot +officecli mcp lmstudio # LM Studio +officecli mcp list # 등록 상태 확인 +``` + +JSON-RPC로 모든 문서 작업을 제공 — 셸 접근 불필요. + +### 직접 CLI 통합 + +2단계로 OfficeCLI를 모든 AI 에이전트에 통합: + +1. **바이너리 설치** -- 명령어 하나 ([설치](#설치) 참조) +2. **완료.** OfficeCLI가 AI 도구(Claude Code, GitHub Copilot, Codex)를 자동 감지하고, 알려진 설정 디렉토리를 확인하여 스킬 파일을 설치합니다. 에이전트는 즉시 Office 문서를 생성, 읽기, 수정할 수 있습니다. + +
+수동 설정 (선택사항) + +자동 설치가 환경을 지원하지 않는 경우, 스킬 파일을 수동으로 설치할 수 있습니다: + +**SKILL.md를 에이전트에 직접 제공:** + +```bash +curl -fsSL https://officecli.ai/SKILL.md +``` + +**Claude Code 로컬 스킬로 설치:** + +```bash +curl -fsSL https://officecli.ai/SKILL.md -o ~/.claude/skills/officecli.md +``` + +**기타 에이전트:** `SKILL.md`의 내용을 에이전트의 시스템 프롬프트 또는 도구 설명에 포함하세요. + +
+ +**모든 언어에서 호출:** + +```python +# Python +import subprocess, json +def cli(*args): return subprocess.check_output(["officecli", *args], text=True) +cli("create", "deck.pptx") +cli("set", "deck.pptx", "/slide[1]/shape[1]", "--prop", "text=Hello") +``` + +```js +// JavaScript +const { execFileSync } = require('child_process') +const cli = (...args) => execFileSync('officecli', args, { encoding: 'utf8' }) +cli('set', 'deck.pptx', '/slide[1]/shape[1]', '--prop', 'text=Hello') +``` + +모든 명령어가 `--json`으로 구조화된 출력을 지원합니다. 경로 기반 주소 지정으로 에이전트가 XML 네임스페이스를 이해할 필요가 없습니다. + +### 에이전트가 OfficeCLI를 선호하는 이유 + +- **결정론적 JSON 출력** -- 모든 명령어가 `--json`을 지원하며, 일관된 스키마의 구조화된 데이터를 반환. 정규식 파싱 불필요. +- **경로 기반 주소 지정** -- 모든 요소가 안정적인 경로를 가짐(`/slide[1]/shape[2]`). XML 네임스페이스를 이해하지 않고도 문서 탐색 가능. 참고: 경로는 OfficeCLI 고유 구문(1부터 시작하는 인덱스, 요소 로컬 이름)을 사용하며, XPath가 아닙니다. +- **단계적 복잡성** -- L1(읽기)에서 시작, L2(수정)로 확대, 필요할 때만 L3(원시 XML)로 폴백. 토큰 소비 최소화. +- **자가 치유 워크플로우** -- `validate`, `view issues`, 도움말 시스템으로 에이전트가 사람의 개입 없이 문제를 감지하고 자체 수정 가능. +- **내장 도움말** -- 속성 이름이나 값 형식이 불확실할 때 `officecli set `을 실행하여 확인. 추측 불필요. +- **자동 설치** -- 스킬 파일 수동 설정 불필요. OfficeCLI가 AI 도구를 자동 감지하고 설정 완료. + +### 내장 도움말 + +속성 이름을 모를 때, 계층형 도움말로 확인: + +```bash +officecli pptx set # 모든 설정 가능한 요소와 속성 +officecli pptx set shape # 특정 요소 유형의 세부사항 +officecli pptx set shape.fill # 단일 속성 형식과 예시 +officecli docx query # 셀렉터 참조: 속성, :contains, :has() 등 +``` + +`pptx`를 `docx`나 `xlsx`로 대체 가능. 동사는 `view`, `get`, `query`, `set`, `add`, `raw`. + +`officecli --help`로 전체 개요 확인. + +### JSON 출력 스키마 + +모든 명령어가 `--json`을 지원합니다. 일반적인 응답 형식: + +**단일 요소** (`get --json`): + +```json +{"tag": "shape", "path": "/slide[1]/shape[1]", "attributes": {"name": "TextBox 1", "text": "Hello"}} +``` + +**요소 목록** (`query --json`): + +```json +[ + {"tag": "paragraph", "path": "/body/p[1]", "attributes": {"style": "Heading1", "text": "Title"}}, + {"tag": "paragraph", "path": "/body/p[5]", "attributes": {"style": "Heading1", "text": "Summary"}} +] +``` + +**오류**는 구조화된 오류 객체를 반환합니다. 오류 코드, 수정 제안, 사용 가능한 값을 포함: + +```json +{ + "success": false, + "error": { + "error": "Slide 50 not found (total: 8)", + "code": "not_found", + "suggestion": "Valid Slide index range: 1-8" + } +} +``` + +오류 코드: `not_found`, `invalid_value`, `unsupported_property`, `invalid_path`, `unsupported_type`, `missing_property`, `file_not_found`, `file_locked`, `invalid_selector`. 속성 이름은 자동 교정 지원 -- 속성 이름 오타 시 가장 근접한 매칭을 제안합니다. + +**오류 복구** -- 에이전트가 사용 가능한 요소를 확인하여 자체 수정: + +```bash +# 에이전트가 잘못된 경로 시도 +officecli get report.docx /body/p[99] --json +# 반환: {"success": false, "error": {"error": "...", "code": "not_found", "suggestion": "..."}} + +# 에이전트가 사용 가능한 요소를 확인하여 자체 수정 +officecli get report.docx /body --depth 1 --json +# 사용 가능한 하위 요소 목록 반환, 에이전트가 올바른 경로 선택 +``` + +**변경 확인** (`set`, `add`, `remove`, `move`, `create`에서 `--json` 사용 시): + +```json +{"success": true, "path": "/slide[1]/shape[1]"} +``` + +`officecli --help`로 종료 코드와 오류 형식의 전체 설명 확인. + +## 비교 + +| | OfficeCLI | Microsoft Office | LibreOffice | python-docx / openpyxl | +|---|---|---|---|---| +| 오픈소스 & 무료 | ✓ (Apache 2.0) | ✗ (유료 라이선스) | ✓ | ✓ | +| AI 네이티브 CLI + JSON | ✓ | ✗ | ✗ | ✗ | +| 제로 설치 (단일 바이너리) | ✓ | ✗ | ✗ | ✗ (Python + pip 필요) | +| 모든 언어에서 호출 | ✓ (CLI) | ✗ (COM/Add-in) | ✗ (UNO API) | Python만 | +| 경로 기반 요소 접근 | ✓ | ✗ | ✗ | ✗ | +| 원시 XML 폴백 | ✓ | ✗ | ✗ | 부분 지원 | +| 라이브 미리보기 | ✓ | ✓ | ✗ | ✗ | +| 헤드리스 / CI | ✓ | ✗ | 부분 지원 | ✓ | +| 크로스 플랫폼 | ✓ | Windows/Mac | ✓ | ✓ | +| Word + Excel + PowerPoint | ✓ | ✓ | ✓ | 여러 라이브러리 필요 | + +## 업데이트 및 설정 + +```bash +officecli config autoUpdate false # 자동 업데이트 확인 비활성화 +OFFICECLI_SKIP_UPDATE=1 officecli ... # 단일 실행 시 확인 건너뛰기 (CI) +``` + +## 명령어 참조 + +| 명령어 | 설명 | +|--------|------| +| [`create`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-create) | 빈 .docx, .xlsx, .pptx 생성 (확장자로 유형 결정) | +| [`view`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-view) | 콘텐츠 보기 (모드: `outline`, `text`, `annotated`, `stats`, `issues`, `html`) | +| [`get`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-get) | 요소와 하위 요소 가져오기 (`--depth N`, `--json`) | +| [`query`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-query) | CSS 스타일 쿼리 (`[attr=value]`, `:contains()`, `:has()` 등) | +| [`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`, `--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`; 기본적으로 첫 오류에서 중지, `--force`로 계속) | +| [`merge`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-merge) | 템플릿 병합 — `{{key}}` 플레이스홀더를 JSON 데이터로 교체 | +| [`watch`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-watch) | 브라우저에서 라이브 HTML 미리보기, 자동 새로고침 | +| [`mcp`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-mcp) | AI 도구 통합용 MCP 서버 시작 | +| [`raw`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-raw) | 문서 파트의 원시 XML 보기 | +| [`raw-set`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-raw) | XPath로 원시 XML 수정 | +| `add-part` | 새 문서 파트 추가 (머리글, 차트 등) | +| [`open`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-open) | 레지던트 모드 시작 (문서를 메모리에 유지) | +| `close` | 저장하고 레지던트 모드 종료 | +| [`install`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-install) | 바이너리 + 스킬 + MCP 설치 (`all`, `claude`, `cursor` 등) | +| `config` | 설정 가져오기 또는 변경 | +| ` ` | [내장 도움말](https://github.com/iOfficeAI/OfficeCLI/wiki/command-reference) (예: `officecli pptx set shape`) | + +## 엔드투엔드 워크플로우 예시 + +전형적인 에이전트 자가 치유 워크플로우: 프레젠테이션 생성, 콘텐츠 입력, 검증, 문제 수정 -- 모두 사람의 개입 없이. + +```bash +# 1. 생성 +officecli create report.pptx + +# 2. 콘텐츠 추가 +officecli add report.pptx / --type slide --prop title="Q4 Results" +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 \ + --prop text="Growth driven by new markets" --prop x=2cm --prop y=5cm + +# 3. 검증 +officecli view report.pptx outline +officecli validate report.pptx + +# 4. 문제 수정 +officecli view report.pptx issues --json +# 출력에 따라 문제 수정: +officecli set report.pptx '/slide[1]/shape[1]' --prop font=Arial +``` + +### 템플릿 병합 + +문서 내 `{{key}}` 플레이스홀더를 JSON 데이터로 교체 -- 단락, 표 셀, 도형, 머리글, 바닥글, 차트 제목 등 모든 텍스트 콘텐츠 지원. + +```bash +# 인라인 JSON 데이터 +officecli merge template.docx output.docx '{"name":"Alice","dept":"Sales","date":"2026-03-30"}' + +# JSON 파일에서 읽기 +officecli merge template.pptx report.pptx data.json + +# Excel 템플릿 +officecli merge budget-template.xlsx q4-budget.xlsx '{"quarter":"Q4","year":"2026"}' +``` + +### 단위와 색상 + +모든 치수 및 색상 속성은 유연한 입력 형식을 지원: + +| 유형 | 지원 형식 | 예시 | +|------|----------|------| +| **치수** | cm, in, pt, px 또는 원시 EMU | `2cm`, `1in`, `72pt`, `96px`, `914400` | +| **색상** | 16진수, 색상 이름, RGB, 테마 색상 | `#FF0000`, `FF0000`, `red`, `rgb(255,0,0)`, `accent1` | +| **글꼴 크기** | 숫자만 또는 pt 접미사 | `14`, `14pt`, `10.5pt` | +| **간격** | pt, cm, in 또는 배율 | `12pt`, `0.5cm`, `1.5x`, `150%` | + +## 자주 사용하는 패턴 + +```bash +# Word 문서의 모든 Heading1 텍스트 교체 +officecli query report.docx "paragraph[style=Heading1]" --json | ... +officecli set report.docx /body/p[1]/r[1] --prop text="New Title" + +# 모든 슬라이드 콘텐츠를 JSON으로 내보내기 +officecli get deck.pptx / --depth 2 --json + +# Excel 셀 일괄 업데이트 +officecli batch budget.xlsx --input updates.json --json + +# CSV 데이터를 Excel 시트로 가져오기 +officecli add budget.xlsx / --type sheet --prop name="Q1 Data" --prop csv=sales.csv + +# 템플릿 병합으로 보고서 일괄 생성 +officecli merge invoice-template.docx invoice-001.docx '{"client":"Acme","total":"$5,200"}' + +# 납품 전 문서 품질 확인 +officecli validate report.docx && officecli view report.docx issues --json +``` + +## 문서 + +[Wiki](https://github.com/iOfficeAI/OfficeCLI/wiki)에서 모든 명령어, 요소 유형, 속성의 상세 가이드를 확인하세요: + +- **형식별:** [Word](https://github.com/iOfficeAI/OfficeCLI/wiki/word-reference) | [Excel](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-reference) | [PowerPoint](https://github.com/iOfficeAI/OfficeCLI/wiki/powerpoint-reference) +- **워크플로우:** [엔드투엔드 예시](https://github.com/iOfficeAI/OfficeCLI/wiki/workflows) -- Word 보고서, Excel 대시보드, PPT 프레젠테이션, 일괄 수정, 레지던트 모드 +- **문제 해결:** [자주 발생하는 오류와 해결책](https://github.com/iOfficeAI/OfficeCLI/wiki/troubleshooting) +- **AI 에이전트 가이드:** [Wiki 내비게이션 결정 트리](https://github.com/iOfficeAI/OfficeCLI/wiki/agent-guide) + +## 소스에서 빌드 + +컴파일에는 [.NET 10 SDK](https://dotnet.microsoft.com/download)가 필요합니다. 출력은 자체 완결형 네이티브 바이너리 -- .NET이 내장되어 있어 실행 시 설치 불필요. + +```bash +./build.sh +``` + +## 라이선스 + +[Apache License 2.0](LICENSE) + +버그 리포트와 기여는 [GitHub Issues](https://github.com/iOfficeAI/OfficeCLI/issues)로 환영합니다. + +--- + +OfficeCLI가 유용하다면 [GitHub에서 스타를 눌러주세요](https://github.com/iOfficeAI/OfficeCLI) — 더 많은 사람들이 프로젝트를 발견하는 데 도움이 됩니다. + +[OfficeCLI.AI](https://OfficeCLI.AI) | [GitHub](https://github.com/iOfficeAI/OfficeCLI) + + + + diff --git a/README_zh.md b/README_zh.md index 1b6df7edf..ff7f8f1b6 100644 --- a/README_zh.md +++ b/README_zh.md @@ -1,6 +1,6 @@ # OfficeCLI -> **OfficeCLI 是全球首个、也是最好的专为 AI 智能体设计的 Office 套件。** +> **OfficeCLI 是全球首个、也是最好的专为 AI 智能体设计的命令行工具。** **让任何 AI 智能体完全掌控 Word、Excel 和 PowerPoint -- 只需一行代码。** @@ -9,7 +9,7 @@ [![GitHub Release](https://img.shields.io/github/v/release/iOfficeAI/OfficeCLI)](https://github.com/iOfficeAI/OfficeCLI/releases) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) -[English](README.md) | **中文** +[English](README.md) | **中文** | [日本語](README_ja.md) | [한국어](README_ko.md)

在 AionUi 上使用 OfficeCLI 的 PPT 制作过程 @@ -66,38 +66,59 @@ curl -fsSL https://officecli.ai/SKILL.md 就这一步。技能文件会教智能体如何安装二进制文件并使用所有命令。 -> **技术细节:** OfficeCLI 附带 [SKILL.md](SKILL.md)(239 行,约 8K tokens),涵盖命令语法、架构设计和常见陷阱。安装后,您的智能体可以立即创建、读取和修改任何 Office 文档。 +> **技术细节:** OfficeCLI 附带 [SKILL.md](SKILL.md),涵盖命令语法、架构设计和常见陷阱。安装后,您的智能体可以立即创建、读取和修改任何 Office 文档。 -## 快速开始 +## 普通用户 + +**方式 A — 图形界面:** 安装 [**AionUi**](https://github.com/iOfficeAI/AionUi) — 一款桌面应用,用自然语言就能创建和编辑 Office 文档,底层由 OfficeCLI 驱动。只需描述你想要什么,AionUi 帮你搞定。 + +**方式 B — 命令行:** 从 [GitHub Releases](https://github.com/iOfficeAI/OfficeCLI/releases) 下载对应平台的二进制文件,然后运行: + +```bash +officecli install +``` -从零到完成一个演示文稿,只需几秒钟: +该命令会将二进制文件复制到 PATH,并自动将 **officecli 技能文件**安装到检测到的所有 AI 编程助手 — Claude Code、Cursor、Windsurf、GitHub Copilot 等。您的智能体可以立即创建、读取和编辑 Office 文档,无需额外配置。 + +## 开发者 — 30 秒亲眼看到效果 ```bash -# 创建新的 PowerPoint +# 1. 安装(macOS / Linux) +curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh | bash +# Windows (PowerShell): irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex + +# 2. 创建一个空白 PowerPoint officecli create deck.pptx -# 添加带标题和背景色的幻灯片 -officecli add deck.pptx / --type slide --prop title="Q4 Report" --prop background=1A1A2E +# 3. 启动实时预览 — 浏览器自动打开 http://localhost:26315 +officecli watch deck.pptx --port 26315 + +# 4. 打开另一个终端,添加一页幻灯片 — 浏览器即时刷新 +officecli add deck.pptx / --type slide --prop title="Hello, World!" +``` + +就这么简单。你执行的每一条 `add`、`set`、`remove` 命令都会实时刷新预览。继续尝试吧 — 浏览器就是你的实时反馈窗口。 -# 在幻灯片上添加文本形状 -officecli add deck.pptx /slide[1] --type shape \ +## 快速开始 + +```bash +# 创建演示文稿并添加内容 +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 \ --prop text="Revenue grew 25%" --prop x=2cm --prop y=5cm \ --prop font=Arial --prop size=24 --prop color=FFFFFF -# 查看演示文稿大纲 +# 查看大纲 officecli view deck.pptx outline -``` - -输出: +# → Slide 1: Q4 Report +# → Shape 1 [TextBox]: Revenue grew 25% -``` -Slide 1: Q4 Report - Shape 1 [TextBox]: Revenue grew 25% -``` +# 查看 HTML — 在浏览器中打开渲染预览,无需启动服务器 +officecli view deck.pptx html -```bash # 获取任意元素的结构化 JSON -officecli get deck.pptx /slide[1]/shape[1] --json +officecli get deck.pptx '/slide[1]/shape[1]' --json ``` ```json @@ -150,7 +171,7 @@ officecli add deck.pptx / --type slide --prop title="Q4 Report" **Word** — [段落](https://github.com/iOfficeAI/OfficeCLI/wiki/word-paragraph)、[文本片段](https://github.com/iOfficeAI/OfficeCLI/wiki/word-run)、[表格](https://github.com/iOfficeAI/OfficeCLI/wiki/word-table)、[样式](https://github.com/iOfficeAI/OfficeCLI/wiki/word-style)、[页眉/页脚](https://github.com/iOfficeAI/OfficeCLI/wiki/word-header-footer)、[图片](https://github.com/iOfficeAI/OfficeCLI/wiki/word-picture)、[公式](https://github.com/iOfficeAI/OfficeCLI/wiki/word-equation)、[批注](https://github.com/iOfficeAI/OfficeCLI/wiki/word-comment)、[脚注](https://github.com/iOfficeAI/OfficeCLI/wiki/word-footnote)、[水印](https://github.com/iOfficeAI/OfficeCLI/wiki/word-watermark)、[书签](https://github.com/iOfficeAI/OfficeCLI/wiki/word-bookmark)、[目录](https://github.com/iOfficeAI/OfficeCLI/wiki/word-toc)、[图表](https://github.com/iOfficeAI/OfficeCLI/wiki/word-chart)、[超链接](https://github.com/iOfficeAI/OfficeCLI/wiki/word-hyperlink)、[节](https://github.com/iOfficeAI/OfficeCLI/wiki/word-section)、[表单域](https://github.com/iOfficeAI/OfficeCLI/wiki/word-formfield)、[内容控件 (SDT)](https://github.com/iOfficeAI/OfficeCLI/wiki/word-sdt)、[域](https://github.com/iOfficeAI/OfficeCLI/wiki/word-field)、[文档属性](https://github.com/iOfficeAI/OfficeCLI/wiki/word-document) -**Excel** — [单元格](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-cell)、公式(内置 150+ 函数自动求值)、[工作表](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-sheet)、[表格](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-table)、[条件格式](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-conditionalformatting)、[图表](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-chart)、[数据透视表](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-pivottable)、[命名范围](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-namedrange)、[数据验证](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-validation)、[图片](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-picture)、[迷你图](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-sparkline)、[批注](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-comment)、[自动筛选](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-autofilter)、[形状](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-shape)、CSV/TSV 导入、`$Sheet:A1` 单元格寻址 +**Excel** — [单元格](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-cell)、公式(内置 150+ 函数自动求值)、[工作表](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-sheet)、[表格](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-table)、[条件格式](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-conditionalformatting)、[图表](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-chart)、[数据透视表](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-pivottable)(多字段、日期分组、showDataAs、排序、总计、分类汇总)、[命名范围](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-namedrange)、[数据验证](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-validation)、[图片](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-picture)、[迷你图](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-sparkline)、[批注](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-comment)、[自动筛选](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-autofilter)、[形状](https://github.com/iOfficeAI/OfficeCLI/wiki/excel-shape)、CSV/TSV 导入、`$Sheet:A1` 单元格寻址 **PowerPoint** — [幻灯片](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-slide)、[形状](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-shape)、[图片](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-picture)、[表格](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-table)、[图表](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-chart)、[动画](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-slide)、[morph 过渡](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-morph-check)、[3D 模型(.glb)](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-3dmodel)、[幻灯片缩放](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-zoom)、[公式](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-equation)、[主题](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-theme)、[连接线](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-connector)、[视频/音频](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-video)、[组合](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-group)、[备注](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-notes)、[占位符](https://github.com/iOfficeAI/OfficeCLI/wiki/ppt-placeholder) @@ -198,10 +219,11 @@ irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex 验证安装:`officecli --version` -**或从已下载的二进制文件自安装:** +**或从已下载的二进制文件自安装(直接运行 `officecli` 也会触发安装):** ```bash -officecli install +officecli install # 显式安装 +officecli # 直接运行也会触发安装 ``` OfficeCLI 会在后台自动检查更新。通过 `officecli config autoUpdate false` 关闭,或通过 `OFFICECLI_SKIP_UPDATE=1` 跳过单次检查。配置文件位于 `~/.officecli/config.json`。 @@ -214,7 +236,7 @@ OfficeCLI 会在后台自动检查更新。通过 `officecli config autoUpdate f ```bash officecli watch deck.pptx -# 打开 http://localhost:18080 — 每次 set/add/remove 自动刷新 +# 打开 http://localhost:26315 — 每次 set/add/remove 自动刷新 ``` 支持形状、图表、公式、3D 模型(Three.js)、morph 过渡、缩放导航和所有形状效果的渲染。 @@ -230,10 +252,16 @@ officecli set report.docx /body/p[1]/r[1] --prop bold=true officecli set report.docx /body/p[2]/r[1] --prop color=FF0000 officecli close report.docx -# 批量模式 — 原子化多命令执行 +# 批量模式 — 原子化多命令执行(默认遇到第一个错误即停止) echo '[{"command":"set","path":"/slide[1]/shape[1]","props":{"text":"Hello"}}, {"command":"set","path":"/slide[1]/shape[2]","props":{"fill":"FF0000"}}]' \ - | officecli batch deck.pptx --stop-on-error + | officecli batch deck.pptx --json + +# 内联 batch,无需标准输入 +officecli batch deck.pptx --commands '[{"op":"set","path":"/slide[1]/shape[1]","props":{"text":"Hi"}}]' + +# 使用 --force 跳过错误继续执行 +officecli batch deck.pptx --input updates.json --force --json ``` ### 三层架构 @@ -243,7 +271,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 @@ -257,7 +285,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' @@ -303,7 +331,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` 的内容添加到智能体的系统提示词或工具描述中。 @@ -437,10 +465,10 @@ 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`) | +| [`batch`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-batch) | 单次打开/保存周期内执行多条操作(stdin、`--input` 或 `--commands`;默认遇到第一个错误停止,`--force` 跳过错误继续) | | [`merge`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-merge) | 模板合并 — 用 JSON 数据替换 `{{key}}` 占位符 | | [`watch`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-watch) | 在浏览器中实时 HTML 预览,自动刷新 | | [`mcp`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-mcp) | 启动 MCP 服务器,用于 AI 工具集成 | @@ -463,10 +491,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. 验证 @@ -476,7 +504,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 ``` ### 模板合并 @@ -553,6 +581,8 @@ officecli validate report.docx && officecli view report.docx issues --json --- +如果觉得 OfficeCLI 好用,请在 [GitHub 上点个 Star](https://github.com/iOfficeAI/OfficeCLI) — 帮助更多人发现这个项目。 + [OfficeCLI.AI](https://OfficeCLI.AI) | [GitHub](https://github.com/iOfficeAI/OfficeCLI) @@ -585,7 +614,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 d64ff8732..0dcdec306 100644 --- a/SKILL.md +++ b/SKILL.md @@ -7,23 +7,29 @@ description: Create, analyze, proofread, and modify Office documents (.docx, .xl AI-friendly CLI for .docx, .xlsx, .pptx. Single binary, no dependencies, no Office installation needed. -## Install & Update +## Install -Same command for both install and upgrade: +If `officecli` is not installed: -```bash -# macOS / Linux -curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh | bash +`macOS / Linux` -# Windows (PowerShell) -irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex +```bash +if ! command -v officecli >/dev/null 2>&1; then + curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh | bash +fi ``` -After installation, run `source ~/.zshrc` (macOS) or `source ~/.bashrc` (Linux) to make the `officecli` command available. +`Windows (PowerShell)` + +```powershell +if (-not (Get-Command officecli -ErrorAction SilentlyContinue)) { + irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex +} +``` Verify: `officecli --version` -officecli auto-updates daily in the background. +If `officecli` is still not found after first install, open a new terminal and run the verify command again. --- @@ -50,13 +56,15 @@ Replace `pptx` with `docx` or `xlsx`. Commands: `view`, `get`, `query`, `set`, ` ## Performance: Resident Mode -For multi-step workflows (3+ commands on the same file), use `open`/`close`: +**Every command auto-starts a resident on first access** (60s idle timeout) — file-lock conflicts are automatically avoided. Explicit `open`/`close` is still recommended for longer sessions (12min idle): ```bash -officecli open report.docx # keep in memory — fast subsequent commands +officecli open report.docx # explicitly keep in memory officecli set report.docx ... # no file I/O overhead officecli close report.docx # save and release ``` +Opt out of auto-start: `OFFICECLI_NO_AUTO_RESIDENT=1`. Skipping `close` still works (resident exits on idle), but explicit `close` guarantees the file is flushed before the next command reads it. + --- ## Quick Start @@ -65,8 +73,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 +87,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 ``` --- @@ -95,6 +100,7 @@ officecli view # outline | stats | issues | text | annota officecli get --depth N # Get a node and its children [--json] officecli query # CSS-like query officecli validate # Validate against OpenXML schema +officecli view issues # Enumerate issues (text overflow, missing alt, formula errors, ...) ``` ### view modes @@ -106,10 +112,18 @@ 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 -Any XML path via element localName. Use `--depth N` to expand children. Add `--json` for structured output. +Any XML path via element localName. Use `--depth N` to expand children. Add `--json` for structured output. Default text output is grep-friendly single-line per node: `path (type) "text" key=val key=val ...` ```bash officecli get report.docx '/body/p[3]' --depth 2 --json @@ -119,6 +133,19 @@ 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. Prefer these in multi-step workflows — positional indices shift on insert/delete, stable IDs do not. + +``` +/slide[1]/shape[@id=550950021] # PPT shape +/slide[1]/table[@id=1388430425]/tr[1]/tc[2] # PPT table +/body/p[@paraId=1A2B3C4D] # Word paragraph +/comments/comment[@commentId=1] # Word comment +``` + +Use returned paths directly for subsequent `set`/`remove`. PPT also accepts `@name=` (e.g. `shape[@name=Title 1]`), with morph `!!` prefix awareness. Elements without stable IDs (slide, run, tr/tc, row) fall back to positional indices. Run `officecli get` for the full list. + ### query CSS-like selectors: `[attr=value]`, `[attr!=value]`, `[attr~=text]`, `[attr>=value]`, `[attr<=value]`, `:contains("text")`, `:empty`, `:has(formula)`, `:no-alt`. @@ -135,7 +162,96 @@ officecli validate report.docx # Check for schema errors officecli validate slides.pptx # Must pass before delivery ``` -**For large documents**, ALWAYS use `--max-lines` or `--start`/`--end` to limit output. +**For large documents**, ALWAYS use `--max-lines` to limit output. + +--- + +## 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 element to select; shift/cmd/ctrl+click to multi-select; drag from empty space to box-select (rubber-band). PPT/Word uses blue outline; Excel uses native-style green selection with crosshair and rectangular range selection. **Excel extras:** double-click a cell to edit inline (shows formula, commits on Enter/Tab); drag a chart to reposition it. + +### `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:** + +```bash +officecli watch report.docx & +# Propose +officecli mark report.docx /body/p[3] --prop find="资钱" --prop tofix="资金" --prop color=red --prop note="术语错误" +# Review — human eyeballs highlights in browser, unmarks bad proposals +# Apply — read accepted marks, run real set commands +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 +``` + +All mark commands support `--json`. For >3 mutations, wrap the apply loop in `batch` or `open`/`close` for performance. --- @@ -149,6 +265,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,45 +277,146 @@ 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 for formatting or replacement. Works the same in Word and PPT — just swap paths. Format props are separate `--prop` flags — do NOT nest them. + +```bash +# Format matched text (auto-splits runs) +officecli set doc.docx '/body/p[1]' --prop find=weather --prop bold=true --prop color=red + +# 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 + +# PPT — same syntax, different paths +officecli set slides.pptx / --prop find=draft --prop replace=final +``` + +**Path controls search scope:** `/` = whole document, `/body/p[1]` or `/slide[N]/shape[M]` = specific element, `/header[1]` = header, `/footer[1]` = footer. + +**Notes:** +- Case-sensitive by default. Case-insensitive: `--prop 'find=(?i)error' --prop regex=true` +- Matches work across run boundaries +- No match = no error (silent success). `--json` includes `"matched": N` +- Batch JSON regex: `{"props":{"find":"\\d+%","regex":"true","color":"FF0000"}}` +- **Excel:** only `find` + `replace` supported (no find + format props) + ### 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 (0-based, legacy) +officecli add --from # clone existing element ``` +`--after`, `--before`, `--index` are mutually exclusive. No position flag = append to end. + **Element types (with aliases):** | Format | Types | |--------|-------| -| **pptx** | slide, shape (textbox), picture (image/img), chart, table, row (tr), connector (connection/line), group, video (audio/media), equation (formula/math), notes, paragraph (para), run, zoom (slidezoom) | -| **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) | +| **pptx** | slide, shape (textbox), picture (image/img — SVG supported, auto-dual-representation), chart, table, row (tr), connector (connection/line), group, video (audio/media), equation (formula/math), notes, paragraph (para, supports level/lineSpacing/spaceBefore/spaceAfter), run, zoom (slidezoom), ole (oleobject/object/embed), placeholder (phType=title/body/subtitle/footer/...) | +| **docx** | paragraph (para), run, table, row (tr), cell (td), image (picture/img — SVG supported), header, footer, section, bookmark, comment, footnote, endnote, formfield (text/checkbox/dropdown), sdt (contentcontrol), chart, equation (formula/math), field (22 zero-param types: pagenum/date/author/...; 6 parameterized: mergefield/ref/pageref/seq/styleref/docproperty/if), hyperlink, style, toc, watermark, break (pagebreak/columnbreak), ole (oleobject/object/embed). Document protection: `set / --prop protection=forms\|readOnly\|comments\|trackedChanges\|none` | +| **xlsx** | sheet, row, cell, chart (includes pareto with auto-sort + cumulative-%), image (picture — SVG supported), comment, table (listobject), namedrange (definedname), pivottable (pivot, supports calculatedField), sparkline, validation (datavalidation), autofilter, shape, textbox, databar/colorscale/iconset/formulacf/cellIs/topN/aboveAverage (conditional formatting), ole (oleobject/object/embed — no Remove yet), csv (tsv). `value="=SUM(...)"` auto-detects as formula. Formulas auto-evaluated on write (150+ functions including VLOOKUP, SUMIF, IF, DATE, PMT, etc.). Chart/picture/shape/slicer accept `anchor=A1:E10` cell-range. | + +### Pivot tables (xlsx) + +```bash +officecli add data.xlsx /Sheet1 --type pivottable \ + --prop source="Sheet1!A1:E100" --prop rows=Region,Category \ + --prop cols=Year --prop values="Sales:sum,Qty:count" \ + --prop grandTotals=rows --prop subtotals=off --prop sort=asc +``` + +Key props: `rows`, `cols`, `values` (Field:func[:showDataAs]), `filters`, `source`, `position`, `layout` (compact/outline/tabular), `repeatLabels` (true/false — repeat outer row labels on every data row), `blankRows` (true/false — insert blank line after each group), `aggregate`, `showDataAs` (percent_of_total/row/col, running_total), `grandTotals` (both/rows/cols/none), `subtotals` (on/off), `sort` (asc/desc/locale/locale-desc). Aggregators: sum, count, average, max, min, product, stdDev, stdDevp, var, varp, countNums. Date columns auto-group. Multiple data fields and N×N row/col hierarchies supported. Run `officecli xlsx set pivottable` for full property list. -**Clone:** `officecli add / --from /slide[1]` — copies with all cross-part relationships. +### Document-level properties (all formats) + +```bash +officecli set doc.docx / --prop docDefaults.font=Arial --prop docDefaults.fontSize=11pt +officecli set doc.docx / --prop protection=forms --prop evenAndOddHeaders=true +officecli set data.xlsx / --prop calc.mode=manual --prop calc.refMode=r1c1 +officecli set slides.pptx / --prop defaultFont=Arial --prop show.loop=true --prop print.what=handouts +``` + +Run `officecli set /` for all available document-level properties (docDefaults, docGrid, CJK spacing, calc, print, show, theme, extended). + +### Sort (xlsx) + +```bash +# Sheet-level: sort entire used range by column C descending +officecli set data.xlsx /Sheet1 --prop sort="C desc" --prop sortHeader=true + +# Range-level: sort a specific range by column A +officecli set data.xlsx '/Sheet1/A1:D100' --prop sort="A asc" --prop sortHeader=true +``` + +Sort key format: `COL DIR[, COL DIR ...]` (column letter + `asc`/`desc`). Rejects ranges with merged cells or formulas. Sidecar metadata (hyperlinks, comments, conditional formatting, drawings) follows rows automatically. + +**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. + ```bash +# Via stdin echo '[ {"command":"set","path":"/Sheet1/A1","props":{"value":"Name","bold":"true"}}, {"command":"set","path":"/Sheet1/B1","props":{"value":"Score","bold":"true"}} ]' | officecli batch data.xlsx --json -``` -Batch supports: `add`, `set`, `get`, `query`, `remove`, `move`, `view`, `raw`, `raw-set`, `validate`. +# Via --commands (inline) or --input (file) +officecli batch data.xlsx --commands '[{"op":"set","path":"/Sheet1/A1","props":{"value":"Done"}}]' --json +officecli batch data.xlsx --input updates.json --force --json +``` -Batch fields: `command`, `path`, `parent`, `type`, `from`, `to`, `index`, `props` (dict), `selector`, `mode`, `depth`, `part`, `xpath`, `action`, `xml`. +Batch supports: `add`, `set`, `get`, `query`, `remove`, `move`, `swap`, `view`, `raw`, `raw-set`, `validate`. Fields: `command` (or `op`), `path`, `parent`, `type`, `from`, `to`, `index`, `after`, `before`, `props`, `selector`, `mode`, `depth`, `part`, `xpath`, `action`, `xml`. --- @@ -223,28 +442,28 @@ 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]'` | --- ## Specialized Skills -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 | - -> **How to load:** Ask your AI tool to enable the skill by name, or load the skill file from `skills//SKILL.md`. +For complex scenarios, load the dedicated skill from `skills//SKILL.md`: + +| Skill | Scope | +|-------|-------| +| `officecli-docx` | Word documents — reports, letters, memos | +| `officecli-academic-paper` | Academic papers with TOC, equations, footnotes, bibliography | +| `officecli-pptx` | Presentations — general slide decks | +| `officecli-pitch-deck` | Investor/product/sales decks with charts and callouts | +| `morph-ppt` | Morph-animated cinematic presentations | +| `officecli-xlsx` | Excel — financial models, trackers, formulas | +| `officecli-data-dashboard` | CSV/tabular data → Excel dashboards with charts, sparklines | --- diff --git a/build.sh b/build.sh index 36aee6a55..68aea7653 100755 --- a/build.sh +++ b/build.sh @@ -59,16 +59,24 @@ build_config() { echo "[$CONFIG] Building $RID -> $NAME" dotnet publish "$PROJECT" -c "$CONFIG" -r "$RID" -o "$TMPDIR" --nologo -v quiet + # Atomic replace: stage as .new alongside the target, sign there, then rename. + # Overwriting the binary in place would trash the text segment of any + # running officecli process that happens to be mmap'd on this path + # (macOS does not block ETXTBSY), leaving it stuck in uninterruptible + # `UE` state on the next code page fault. if [ -f "$TMPDIR/officecli.exe" ]; then - cp "$TMPDIR/officecli.exe" "$OUTPUT/$NAME" + cp "$TMPDIR/officecli.exe" "$OUTPUT/$NAME.new" else - cp "$TMPDIR/officecli" "$OUTPUT/$NAME" + cp "$TMPDIR/officecli" "$OUTPUT/$NAME.new" fi - # Ad-hoc codesign on macOS (required by AppleSystemPolicy) + # Ad-hoc codesign on macOS (required by AppleSystemPolicy). + # Done on the staged .new copy so the live binary is never mutated in place. if [ "$(uname -s)" = "Darwin" ] && [[ "$RID" == osx-* ]]; then - codesign -s - -f "$OUTPUT/$NAME" 2>/dev/null || true + codesign -s - -f "$OUTPUT/$NAME.new" 2>/dev/null || true fi + + mv -f "$OUTPUT/$NAME.new" "$OUTPUT/$NAME" cp "$TMPDIR/officecli.pdb" "$OUTPUT/${NAME%.*}.pdb" rm -rf "$TMPDIR" diff --git a/dev-install.sh b/dev-install.sh index b3040ced1..68c4df4cc 100755 --- a/dev-install.sh +++ b/dev-install.sh @@ -55,16 +55,23 @@ else fi mkdir -p "$INSTALL_DIR" -cp "$TMPDIR/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME" -chmod +x "$INSTALL_DIR/$BINARY_NAME" +# Atomic replace: stage as .new alongside the target, sign there, then rename. +# Overwriting the binary in place would trash the text segment of any +# running officecli process (macOS does not block ETXTBSY), leaving it +# stuck in uninterruptible `UE` state on the next code page fault. +cp "$TMPDIR/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME.new" +chmod +x "$INSTALL_DIR/$BINARY_NAME.new" rm -rf "$TMPDIR" # macOS: remove quarantine flag and ad-hoc codesign (required by AppleSystemPolicy) +# Done on the staged .new copy so the live binary is never mutated in place. if [ "$(uname -s)" = "Darwin" ]; then - xattr -d com.apple.quarantine "$INSTALL_DIR/$BINARY_NAME" 2>/dev/null || true - codesign -s - -f "$INSTALL_DIR/$BINARY_NAME" 2>/dev/null || true + xattr -d com.apple.quarantine "$INSTALL_DIR/$BINARY_NAME.new" 2>/dev/null || true + codesign -s - -f "$INSTALL_DIR/$BINARY_NAME.new" 2>/dev/null || true fi +mv -f "$INSTALL_DIR/$BINARY_NAME.new" "$INSTALL_DIR/$BINARY_NAME" + # Hint if not in PATH case ":$PATH:" in *":$INSTALL_DIR:"*) ;; diff --git a/examples/excel/README.md b/examples/excel/README.md deleted file mode 100644 index 05b31adee..000000000 --- a/examples/excel/README.md +++ /dev/null @@ -1,206 +0,0 @@ -# Excel (.xlsx) Examples - -Examples demonstrating OfficeCLI capabilities for Excel spreadsheet automation. - -## 📊 Scripts - -### [gen-beautiful-charts.sh](gen-beautiful-charts.sh) -**Create professional charts with custom styling** - -```bash -bash gen-beautiful-charts.sh -``` - -**Demonstrates:** -- Multiple chart types (bar, line, pie, scatter, area) -- Chart styling and colors -- Data series configuration -- Legend and axis formatting -- Chart positioning - -**Output:** [`outputs/beautiful_charts.xlsx`](outputs/beautiful_charts.xlsx) - ---- - -### [gen-charts-demo.sh](gen-charts-demo.sh) -**Comprehensive chart examples** - -```bash -bash gen-charts-demo.sh -``` - -**Demonstrates:** -- 14+ chart types -- Chart variations (stacked, clustered, 3D) -- Data range selection -- Title and label configuration -- Chart layout - -**Output:** [`outputs/charts_demo.xlsx`](outputs/charts_demo.xlsx) - ---- - -## 📈 Sample Output - -[`outputs/sales_report.xlsx`](outputs/sales_report.xlsx) - Pre-generated sales report example - ---- - -## 🎓 Key Concepts - -### Workbook Structure -``` -/Workbook - /Sheet1 - /A1 # Cell A1 - /B2 # Cell B2 - /A1:C10 # Range A1 to C10 - /Sheet2 - /Chart1 # Chart objects -``` - -### Common Commands - -**Set cell value:** -```bash -officecli set data.xlsx /Sheet1/A1 \ - --prop value="Revenue" \ - --prop bold=true \ - --prop size=14 -``` - -**Add formula:** -```bash -officecli set data.xlsx /Sheet1/B10 \ - --prop formula="=SUM(B2:B9)" \ - --prop numFmt="$#,##0.00" -``` - -**Create chart:** -```bash -officecli add data.xlsx /Sheet1 --type chart \ - --prop chartType=column \ - --prop dataRange="A1:B10" \ - --prop title="Sales Report" -``` - -**Add sheet:** -```bash -officecli add data.xlsx / --type sheet \ - --prop name="Q2 Data" -``` - ---- - -## 📊 Chart Types - -### Supported Chart Types - -| Type | Description | Usage | -|------|-------------|-------| -| `column` | Vertical bar chart | Comparing values | -| `bar` | Horizontal bar chart | Ranking data | -| `line` | Line chart | Trends over time | -| `pie` | Pie chart | Part-to-whole | -| `scatter` | Scatter plot | Correlation | -| `area` | Area chart | Volume over time | -| `doughnut` | Doughnut chart | Part-to-whole with center | -| `radar` | Radar chart | Multivariate data | -| `combo` | Combination chart | Multiple series types | - -**Variations:** -- `columnStacked` - Stacked columns -- `columnClustered` - Grouped columns -- `column3D` - 3D columns -- `lineMarkers` - Line with data points -- `areaStacked` - Stacked areas - -**View all chart types:** -```bash -officecli xlsx add -``` - ---- - -## 📊 Available Properties - -### Cell -- `value` - Cell value (text or number) -- `formula` - Excel formula (e.g., =SUM(A1:A10)) -- `bold` - true/false -- `italic` - true/false -- `size` - Font size -- `font` - Font name -- `color` - Text color (hex) -- `fill` - Background color (hex) -- `numFmt` - Number format (e.g., "0.00%", "$#,##0.00") -- `alignment` - horizontal, vertical -- `border` - Border style - -### Chart -- `chartType` - Chart type (column, bar, line, pie, etc.) -- `dataRange` - Data range (e.g., "A1:B10") -- `title` - Chart title -- `x` - X position -- `y` - Y position -- `width` - Chart width -- `height` - Chart height -- `legend` - Legend position (top, bottom, left, right, none) - -### Sheet -- `name` - Sheet name -- `tabColor` - Tab color (hex) -- `hidden` - true/false - -**For complete property list:** -```bash -officecli xlsx set -officecli xlsx set cell -officecli xlsx set chart -``` - ---- - -## 🔧 Tips - -1. **View data:** - ```bash - officecli view data.xlsx text --cols A,B,C --max-lines 50 - ``` - -2. **Check formulas:** - ```bash - officecli view data.xlsx issues --type content - ``` - -3. **Query cells:** - ```bash - officecli query data.xlsx "cell[formula~=SUM]" - ``` - -4. **Batch cell updates:** - ```bash - cat << EOF | officecli batch data.xlsx - [ - {"command":"set","path":"/Sheet1/A1","props":{"value":"Name","bold":"true"}}, - {"command":"set","path":"/Sheet1/B1","props":{"value":"Score","bold":"true"}}, - {"command":"set","path":"/Sheet1/A2","props":{"value":"Alice"}}, - {"command":"set","path":"/Sheet1/B2","props":{"value":"95"}} - ] - EOF - ``` - -5. **Number formats:** - - Currency: `"$#,##0.00"` - - Percentage: `"0.00%"` - - Date: `"yyyy-mm-dd"` - - Custom: `"#,##0.00;[Red]-#,##0.00"` - ---- - -## 📚 More Resources - -- [Complete Excel documentation](../../SKILL.md#excel-xlsx) -- [All examples](../) -- [Word examples](../word/) -- [PowerPoint examples](../powerpoint/) diff --git a/examples/excel/charts-advanced.md b/examples/excel/charts-advanced.md new file mode 100644 index 000000000..dea669c36 --- /dev/null +++ b/examples/excel/charts-advanced.md @@ -0,0 +1,151 @@ +# Advanced Charts Showcase + +This demo consists of three files that work together: + +- **charts-advanced.py** — Python script that calls `officecli` commands to generate the workbook. Each chart command is shown as a copyable shell command in the comments. +- **charts-advanced.xlsx** — The generated workbook with 3 sheets (12 charts total). +- **charts-advanced.md** — This file. Maps each sheet to the features it demonstrates. + +## Regenerate + +```bash +cd examples/excel +python3 charts-advanced.py +# → charts-advanced.xlsx +``` + +## Chart Sheets + +### Sheet: 1-Scatter & Bubble + +Four charts covering scatter plot and bubble chart fundamentals. + +```bash +# Scatter with circle markers and connecting lines +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter \ + --prop categories=1,2,3,4,5,6 \ + --prop series1="SeriesA:10,25,15,40,30,50" \ + --prop series2="SeriesB:5,18,22,35,28,42" \ + --prop colors=4472C4,ED7D31 \ + --prop marker=circle --prop markerSize=8 \ + --prop lineWidth=1.5 --prop legend=bottom + +# Scatter with smooth curve and reference line +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter \ + --prop smooth=true --prop marker=diamond --prop markerSize=7 \ + --prop referenceLine=25:FF0000:Target:dash \ + --prop axisTitle=Value --prop catTitle=Period + +# Scatter with per-series marker styles +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter \ + --prop series1.marker=square --prop series2.marker=triangle \ + --prop series3.marker=star --prop markerSize=9 \ + --prop lineWidth=1 --prop gridlines=D9D9D9:0.5:dot + +# Bubble chart with scale control +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bubble \ + --prop bubbleScale=80 --prop legend=right \ + --prop axisTitle=Revenue --prop catTitle=Market Size +``` + +**Features:** `scatter`, `bubble`, `marker` (circle, diamond, square, triangle, star), `markerSize`, `series{N}.marker` (per-series), `smooth`, `lineWidth`, `referenceLine`, `bubbleScale`, `catTitle`, `axisTitle`, `gridlines`, `legend` + +### Sheet: 2-Combo & Radar + +Four charts covering combo (bar+line) and radar (spider) charts. + +```bash +# Combo chart with comboSplit (bar+line split) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=combo \ + --prop comboSplit=2 \ + --prop series1="Revenue:120,145,132,168,155,180" \ + --prop series2="Expenses:80,92,85,98,90,105" \ + --prop series3="Growth:8,12,6,15,10,16" \ + --prop legend=bottom --prop axisTitle=Amount --prop catTitle=Month + +# Combo with secondary axis +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=combo \ + --prop comboSplit=1 --prop secondaryAxis=2 \ + --prop series1="Volume:1200,1450,1320,1680" \ + --prop series2="AvgPrice:45,52,48,58" + +# Combo with per-series type control (combotypes) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=combo \ + --prop combotypes=column,column,line,area + +# Radar chart with radarStyle=marker +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=radar \ + --prop radarStyle=marker \ + --prop categories=Speed,Strength,Stamina,Agility,Accuracy \ + --prop series1="AthleteA:80,65,90,75,85" \ + --prop series2="AthleteB:70,85,60,90,70" +``` + +**Features:** `combo`, `comboSplit` (bar/line split point), `combotypes` (per-series type: column/line/area), `secondaryAxis`, `radar`, `radarStyle` (marker/filled/standard), `categories` as spoke labels + +### Sheet: 3-Stock & Radar + +Four charts covering stock (OHLC) and additional radar/bubble variants. + +```bash +# Stock OHLC chart with 4 series (Open/High/Low/Close) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=stock \ + --prop categories=Mon,Tue,Wed,Thu,Fri \ + --prop series1="Open:145,148,150,147,152" \ + --prop series2="High:152,155,157,153,160" \ + --prop series3="Low:143,146,148,144,150" \ + --prop series4="Close:148,150,147,152,158" \ + --prop catTitle=Day --prop axisTitle=Price + +# Stock chart — weekly OHLC with gridlines +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=stock \ + --prop gridlines=E0E0E0:0.75 + +# Radar — filled style with transparency +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=radar \ + --prop radarStyle=filled \ + --prop transparency=40 --prop legend=bottom + +# Bubble with single series and axis titles +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bubble \ + --prop bubbleScale=100 --prop legend=none \ + --prop axisTitle=Revenue --prop catTitle=Market Size +``` + +**Features:** `stock` (OHLC format: 4 series = Open/High/Low/Close), `radarStyle=filled`, `transparency` (fill alpha on radar), `bubbleScale=100`, `legend=none`, `gridlines` styling + +## Complete Feature Coverage + +| Feature | Sheet | +|---------|-------| +| **Chart types:** scatter, bubble, combo, radar, stock | 1, 2, 3 | +| **Scatter:** marker styles, smooth, lineWidth | 1 | +| **Bubble:** bubbleScale, single/multi-series | 1, 3 | +| **Combo:** comboSplit, combotypes, secondaryAxis | 2 | +| **Radar:** radarStyle (marker, filled, standard), transparency | 2, 3 | +| **Stock:** OHLC (4 series), gridlines | 3 | +| **Markers:** circle, diamond, square, triangle, star, per-series | 1 | +| **Data input:** inline series, categories | 1, 2, 3 | +| **Axis:** catTitle, axisTitle | 1, 2, 3 | +| **Legend:** position (bottom, right, none) | 1, 2, 3 | +| **Reference line:** value:color:label:dash | 1 | +| **Gridlines:** color:width:dash | 1, 3 | + +## Inspect the Generated File + +```bash +officecli query charts-advanced.xlsx chart +officecli get charts-advanced.xlsx "/1-Scatter & Bubble/chart[1]" +``` diff --git a/examples/excel/charts-advanced.py b/examples/excel/charts-advanced.py new file mode 100644 index 000000000..08fa4b698 --- /dev/null +++ b/examples/excel/charts-advanced.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 +""" +Advanced Charts Showcase — scatter, bubble, combo, radar, and stock charts. + +Generates: charts-advanced.xlsx + +Usage: + python3 charts-advanced.py +""" + +import subprocess, sys, os, json, atexit + +FILE = "charts-advanced.xlsx" + +def cli(cmd): + """Run: officecli """ + r = subprocess.run(f"officecli {cmd}", shell=True, capture_output=True, text=True) + out = (r.stdout or "").strip() + if out: + for line in out.split("\n"): + if line.strip(): + print(f" {line.strip()}") + if r.returncode != 0: + err = (r.stderr or "").strip() + if err and "UNSUPPORTED" not in err and "process cannot access" not in err: + print(f" ERROR: {err}") + +if os.path.exists(FILE): + os.remove(FILE) + +cli(f'create "{FILE}"') +cli(f'open "{FILE}"') +atexit.register(lambda: cli(f'close "{FILE}"')) + +# ========================================================================== +# Sheet: 1-Scatter & Bubble +# ========================================================================== +print("\n--- 1-Scatter & Bubble ---") +cli(f'add "{FILE}" / --type sheet --prop name="1-Scatter & Bubble"') + +# -------------------------------------------------------------------------- +# Chart 1: Scatter with markers — circle markers, line connecting points +# +# officecli add charts-advanced.xlsx "/1-Scatter & Bubble" --type chart \ +# --prop chartType=scatter \ +# --prop title="Scatter: Markers & Line" \ +# --prop categories=1,2,3,4,5,6 \ +# --prop series1="SeriesA:10,25,15,40,30,50" \ +# --prop series2="SeriesB:5,18,22,35,28,42" \ +# --prop colors=4472C4,ED7D31 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop marker=circle --prop markerSize=8 \ +# --prop lineWidth=1.5 \ +# --prop legend=bottom +# +# Features: chartType=scatter, categories as X values, marker=circle, +# markerSize, lineWidth, legend=bottom +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Scatter & Bubble" --type chart' + f' --prop chartType=scatter' + f' --prop title="Scatter: Markers & Line"' + f' --prop categories=1,2,3,4,5,6' + f' --prop series1=SeriesA:10,25,15,40,30,50' + f' --prop series2=SeriesB:5,18,22,35,28,42' + f' --prop colors=4472C4,ED7D31' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop marker=circle --prop markerSize=8' + f' --prop lineWidth=1.5' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 2: Scatter with smooth curve and trendline (reference line) +# +# officecli add charts-advanced.xlsx "/1-Scatter & Bubble" --type chart \ +# --prop chartType=scatter \ +# --prop title="Scatter: Smooth + Trendline" \ +# --prop categories=1,2,3,4,5,6,7,8 \ +# --prop series1="Growth:3,7,12,20,28,35,40,45" \ +# --prop colors=70AD47 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop smooth=true \ +# --prop marker=diamond --prop markerSize=7 \ +# --prop referenceLine=25:FF0000:Target:dash \ +# --prop axisTitle=Value --prop catTitle=Period +# +# Features: smooth=true (smooth curve), marker=diamond, +# referenceLine (trendline overlay), axisTitle, catTitle +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Scatter & Bubble" --type chart' + f' --prop chartType=scatter' + f' --prop title="Scatter: Smooth + Trendline"' + f' --prop categories=1,2,3,4,5,6,7,8' + f' --prop series1=Growth:3,7,12,20,28,35,40,45' + f' --prop colors=70AD47' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop smooth=true' + f' --prop marker=diamond --prop markerSize=7' + f' --prop referenceLine=25:FF0000:Target:dash' + f' --prop axisTitle=Value --prop catTitle=Period') + +# -------------------------------------------------------------------------- +# Chart 3: Scatter with varied marker styles per series +# +# officecli add charts-advanced.xlsx "/1-Scatter & Bubble" --type chart \ +# --prop chartType=scatter \ +# --prop title="Scatter: Marker Styles" \ +# --prop categories=10,20,30,40,50 \ +# --prop series1="Squares:8,22,18,35,30" \ +# --prop series2="Triangles:15,10,28,20,42" \ +# --prop series3="Stars:5,30,12,45,25" \ +# --prop colors=4472C4,ED7D31,70AD47 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop series1.marker=square \ +# --prop series2.marker=triangle \ +# --prop series3.marker=star \ +# --prop markerSize=9 \ +# --prop lineWidth=1 \ +# --prop gridlines=D9D9D9:0.5:dot +# +# Features: per-series marker style (series{N}.marker), gridlines styling +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Scatter & Bubble" --type chart' + f' --prop chartType=scatter' + f' --prop title="Scatter: Marker Styles"' + f' --prop categories=10,20,30,40,50' + f' --prop series1=Squares:8,22,18,35,30' + f' --prop series2=Triangles:15,10,28,20,42' + f' --prop series3=Stars:5,30,12,45,25' + f' --prop colors=4472C4,ED7D31,70AD47' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop series1.marker=square' + f' --prop series2.marker=triangle' + f' --prop series3.marker=star' + f' --prop markerSize=9' + f' --prop lineWidth=1' + f' --prop gridlines=D9D9D9:0.5:dot') + +# -------------------------------------------------------------------------- +# Chart 4: Bubble chart with size data +# +# officecli add charts-advanced.xlsx "/1-Scatter & Bubble" --type chart \ +# --prop chartType=bubble \ +# --prop title="Bubble: Market Size" \ +# --prop categories=10,25,40,60,80 \ +# --prop series1="ProductA:30,50,20,70,45" \ +# --prop series2="ProductB:15,35,55,40,60" \ +# --prop colors=4472C4,ED7D31 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop bubbleScale=80 \ +# --prop legend=right \ +# --prop dataLabels=false +# +# Features: chartType=bubble, categories as X, series as Y values, +# bubble sizes default to Y values, bubbleScale to control sizing +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Scatter & Bubble" --type chart' + f' --prop chartType=bubble' + f' --prop title="Bubble: Market Size"' + f' --prop categories=10,25,40,60,80' + f' --prop series1=ProductA:30,50,20,70,45' + f' --prop series2=ProductB:15,35,55,40,60' + f' --prop colors=4472C4,ED7D31' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop bubbleScale=80' + f' --prop legend=right') + +# ========================================================================== +# Sheet: 2-Combo & Radar +# ========================================================================== +print("\n--- 2-Combo & Radar ---") +cli(f'add "{FILE}" / --type sheet --prop name="2-Combo & Radar"') + +# -------------------------------------------------------------------------- +# Chart 1: Combo chart — bar+line with comboSplit +# +# officecli add charts-advanced.xlsx "/2-Combo & Radar" --type chart \ +# --prop chartType=combo \ +# --prop title="Combo: Sales (Bar) + Growth % (Line)" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun \ +# --prop series1="Revenue:120,145,132,168,155,180" \ +# --prop series2="Expenses:80,92,85,98,90,105" \ +# --prop series3="Growth:8,12,6,15,10,16" \ +# --prop colors=4472C4,ED7D31,70AD47 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop comboSplit=2 \ +# --prop legend=bottom \ +# --prop axisTitle=Amount --prop catTitle=Month +# +# Features: chartType=combo, comboSplit=2 (first 2 series as bars, +# remaining as lines), categories as X labels +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Combo & Radar" --type chart' + f' --prop chartType=combo' + f' --prop title="Combo: Sales (Bar) + Growth % (Line)"' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun' + f' --prop series1=Revenue:120,145,132,168,155,180' + f' --prop series2=Expenses:80,92,85,98,90,105' + f' --prop series3=Growth:8,12,6,15,10,16' + f' --prop colors=4472C4,ED7D31,70AD47' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop comboSplit=2' + f' --prop legend=bottom' + f' --prop axisTitle=Amount --prop catTitle=Month') + +# -------------------------------------------------------------------------- +# Chart 2: Combo with secondary axis +# +# officecli add charts-advanced.xlsx "/2-Combo & Radar" --type chart \ +# --prop chartType=combo \ +# --prop title="Combo: Volume (Bar) + Price (Line, 2nd Axis)" \ +# --prop categories=Q1,Q2,Q3,Q4 \ +# --prop series1="Volume:1200,1450,1320,1680" \ +# --prop series2="AvgPrice:45,52,48,58" \ +# --prop colors=5B9BD5,FF0000 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop comboSplit=1 \ +# --prop secondaryAxis=2 \ +# --prop legend=bottom +# +# Features: comboSplit=1, secondaryAxis=2 (series 2 on right Y-axis) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Combo & Radar" --type chart' + f' --prop chartType=combo' + f' --prop title="Combo: Volume (Bar) + Price (Line, 2nd Axis)"' + f' --prop categories=Q1,Q2,Q3,Q4' + f' --prop series1=Volume:1200,1450,1320,1680' + f' --prop series2=AvgPrice:45,52,48,58' + f' --prop colors=5B9BD5,FF0000' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop comboSplit=1' + f' --prop secondaryAxis=2' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: Combo with combotypes — per-series type control +# +# officecli add charts-advanced.xlsx "/2-Combo & Radar" --type chart \ +# --prop chartType=combo \ +# --prop title="Combo: Mixed Types (combotypes)" \ +# --prop categories=A,B,C,D,E \ +# --prop series1="Bars:30,45,28,52,40" \ +# --prop series2="MoreBars:20,30,22,38,28" \ +# --prop series3="Lines:12,18,15,22,16" \ +# --prop series4="Area:8,12,10,15,11" \ +# --prop colors=4472C4,5B9BD5,ED7D31,70AD47 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop combotypes=column,column,line,area \ +# --prop legend=bottom +# +# Features: combotypes (per-series type: column, column, line, area) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Combo & Radar" --type chart' + f' --prop chartType=combo' + f' --prop title="Combo: Mixed Types (combotypes)"' + f' --prop categories=A,B,C,D,E' + f' --prop series1=Bars:30,45,28,52,40' + f' --prop series2=MoreBars:20,30,22,38,28' + f' --prop series3=Lines:12,18,15,22,16' + f' --prop series4=Area:8,12,10,15,11' + f' --prop colors=4472C4,5B9BD5,ED7D31,70AD47' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop combotypes=column,column,line,area' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 4: Radar (spider) chart with multiple series +# +# officecli add charts-advanced.xlsx "/2-Combo & Radar" --type chart \ +# --prop chartType=radar \ +# --prop title="Radar: Skills Comparison" \ +# --prop categories=Speed,Strength,Stamina,Agility,Accuracy \ +# --prop series1="AthleteA:80,65,90,75,85" \ +# --prop series2="AthleteB:70,85,60,90,70" \ +# --prop series3="AthleteC:90,70,75,65,80" \ +# --prop colors=4472C4,ED7D31,70AD47 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop radarStyle=marker \ +# --prop legend=bottom +# +# Features: chartType=radar, categories as spoke labels, +# multiple series, radarStyle=marker +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Combo & Radar" --type chart' + f' --prop chartType=radar' + f' --prop title="Radar: Skills Comparison"' + f' --prop categories=Speed,Strength,Stamina,Agility,Accuracy' + f' --prop series1=AthleteA:80,65,90,75,85' + f' --prop series2=AthleteB:70,85,60,90,70' + f' --prop series3=AthleteC:90,70,75,65,80' + f' --prop colors=4472C4,ED7D31,70AD47' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop radarStyle=marker' + f' --prop legend=bottom') + +# ========================================================================== +# Sheet: 3-Stock & More Radar +# ========================================================================== +print("\n--- 3-Stock & More Radar ---") +cli(f'add "{FILE}" / --type sheet --prop name="3-Stock & Radar"') + +# -------------------------------------------------------------------------- +# Chart 1: Stock (OHLC) chart — Open-High-Low-Close +# +# officecli add charts-advanced.xlsx "/3-Stock & Radar" --type chart \ +# --prop chartType=stock \ +# --prop title="Stock: OHLC Daily Prices" \ +# --prop categories=Mon,Tue,Wed,Thu,Fri \ +# --prop series1="Open:145,148,150,147,152" \ +# --prop series2="High:152,155,157,153,160" \ +# --prop series3="Low:143,146,148,144,150" \ +# --prop series4="Close:148,150,147,152,158" \ +# --prop x=0 --prop y=0 --prop width=14 --prop height=18 \ +# --prop legend=bottom \ +# --prop catTitle=Day --prop axisTitle=Price +# +# Features: chartType=stock, 4 series (Open/High/Low/Close), +# categories as date labels, catTitle, axisTitle +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Stock & Radar" --type chart' + f' --prop chartType=stock' + f' --prop title="Stock: OHLC Daily Prices"' + f' --prop categories=Mon,Tue,Wed,Thu,Fri' + f' --prop series1=Open:145,148,150,147,152' + f' --prop series2=High:152,155,157,153,160' + f' --prop series3=Low:143,146,148,144,150' + f' --prop series4=Close:148,150,147,152,158' + f' --prop x=0 --prop y=0 --prop width=14 --prop height=18' + f' --prop legend=bottom' + f' --prop catTitle=Day --prop axisTitle=Price') + +# -------------------------------------------------------------------------- +# Chart 2: Stock chart — weekly OHLC with date categories +# +# officecli add charts-advanced.xlsx "/3-Stock & Radar" --type chart \ +# --prop chartType=stock \ +# --prop title="Stock: Weekly OHLC (6 Weeks)" \ +# --prop categories=W1,W2,W3,W4,W5,W6 \ +# --prop series1="Open:100,104,102,108,105,110" \ +# --prop series2="High:106,110,108,115,112,118" \ +# --prop series3="Low:98,101,100,105,103,107" \ +# --prop series4="Close:104,102,108,105,110,115" \ +# --prop x=15 --prop y=0 --prop width=14 --prop height=18 \ +# --prop gridlines=E0E0E0:0.75 \ +# --prop legend=bottom +# +# Features: stock chart with 6 weeks of OHLC, gridlines styling +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Stock & Radar" --type chart' + f' --prop chartType=stock' + f' --prop title="Stock: Weekly OHLC (6 Weeks)"' + f' --prop categories=W1,W2,W3,W4,W5,W6' + f' --prop series1=Open:100,104,102,108,105,110' + f' --prop series2=High:106,110,108,115,112,118' + f' --prop series3=Low:98,101,100,105,103,107' + f' --prop series4=Close:104,102,108,105,110,115' + f' --prop x=15 --prop y=0 --prop width=14 --prop height=18' + f' --prop gridlines=E0E0E0:0.75' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: Radar — filled style (spider web) +# +# officecli add charts-advanced.xlsx "/3-Stock & Radar" --type chart \ +# --prop chartType=radar \ +# --prop title="Radar: Product Ratings (Filled)" \ +# --prop categories=Quality,Price,Design,Support,Delivery \ +# --prop series1="BrandX:85,70,90,75,80" \ +# --prop series2="BrandY:70,90,65,85,75" \ +# --prop colors=4472C4,70AD47 \ +# --prop x=0 --prop y=19 --prop width=14 --prop height=18 \ +# --prop radarStyle=filled \ +# --prop transparency=40 \ +# --prop legend=bottom +# +# Features: radarStyle=filled, transparency (fill alpha), multiple series +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Stock & Radar" --type chart' + f' --prop chartType=radar' + f' --prop title="Radar: Product Ratings (Filled)"' + f' --prop categories=Quality,Price,Design,Support,Delivery' + f' --prop series1=BrandX:85,70,90,75,80' + f' --prop series2=BrandY:70,90,65,85,75' + f' --prop colors=4472C4,70AD47' + f' --prop x=0 --prop y=19 --prop width=14 --prop height=18' + f' --prop radarStyle=filled' + f' --prop transparency=40' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 4: Bubble — single series with explicit large differences in size +# +# officecli add charts-advanced.xlsx "/3-Stock & Radar" --type chart \ +# --prop chartType=bubble \ +# --prop title="Bubble: Regional Opportunity" \ +# --prop categories=5,15,30,50,70,90 \ +# --prop series1="Regions:20,45,30,80,55,65" \ +# --prop colors=4472C4 \ +# --prop x=15 --prop y=19 --prop width=14 --prop height=18 \ +# --prop bubbleScale=100 \ +# --prop legend=none \ +# --prop axisTitle=Revenue --prop catTitle=Market Size +# +# Features: bubble with single series, bubbleScale=100, legend=none, +# axisTitle and catTitle labels +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Stock & Radar" --type chart' + f' --prop chartType=bubble' + f' --prop title="Bubble: Regional Opportunity"' + f' --prop categories=5,15,30,50,70,90' + f' --prop series1=Regions:20,45,30,80,55,65' + f' --prop colors=4472C4' + f' --prop x=15 --prop y=19 --prop width=14 --prop height=18' + f' --prop bubbleScale=100' + f' --prop legend=none' + f' --prop axisTitle=Revenue --prop catTitle=Market Size') + +print(f"\nDone! Generated: {FILE}") +print(" 3 sheets (1-Scatter & Bubble, 2-Combo & Radar, 3-Stock & Radar)") +print(" 12 charts total: scatter(3), bubble(2), combo(3), radar(2), stock(2)") diff --git a/examples/excel/charts-advanced.xlsx b/examples/excel/charts-advanced.xlsx new file mode 100644 index 000000000..21aa0c4e7 Binary files /dev/null and b/examples/excel/charts-advanced.xlsx differ diff --git a/examples/excel/charts-area.md b/examples/excel/charts-area.md new file mode 100644 index 000000000..f4337ae95 --- /dev/null +++ b/examples/excel/charts-area.md @@ -0,0 +1,220 @@ +# Area Charts Showcase + +This demo consists of three files that work together: + +- **charts-area.py** — Python script that calls `officecli` commands to generate the workbook. Each chart command is shown as a copyable shell command in the comments. +- **charts-area.xlsx** — The generated workbook with 6 sheets (1 data + 5 chart sheets, 20 charts total). +- **charts-area.md** — This file. Maps each sheet to the features it demonstrates. + +## Regenerate + +```bash +cd examples/excel +python3 charts-area.py +# → charts-area.xlsx +``` + +## Chart Sheets + +### Sheet: 1-Area Fundamentals + +Four area charts covering data input methods, transparency, area fills, and gradients. + +```bash +# Basic area with dataRange and axis titles +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=area \ + --prop dataRange=Sheet1!A1:E13 \ + --prop colors=4472C4,ED7D31,70AD47,FFC000 \ + --prop catTitle=Month --prop axisTitle=Visitors \ + --prop gridlines=D9D9D9:0.5:dot + +# Inline series with transparency +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=area \ + --prop series1="Subscriptions:120,180,210,250" \ + --prop series2="One-time:90,140,160,200" \ + --prop transparency=40 --prop legend=bottom + +# Area with areafill gradient (single series) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=area \ + --prop series1="Users:3200,3800,4500,5100,5800,6400" \ + --prop areafill=4472C4-BDD7EE:90 --prop legend=none + +# Per-series gradient fills +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=area \ + --prop 'gradients=4472C4-BDD7EE:90;ED7D31-FBE5D6:90' \ + --prop legend=right --prop legendfont=10:333333:Calibri +``` + +**Features:** `area`, `dataRange`, `categories`, `colors`, `catTitle`, `axisTitle`, `gridlines`, `transparency`, `areafill` (gradient from-to:angle), `gradients` (per-series), `legend` (bottom, right, none), `legendfont` + +### Sheet: 2-Area Variants + +Four charts covering all area chart type variants — stacked, percent stacked, and 3D. + +```bash +# Stacked area with solid plot fill +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=areaStacked \ + --prop plotFill=F5F5F5 --prop roundedCorners=true + +# 100% stacked area with axis number format +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=areaPercentStacked \ + --prop axisNumFmt=0% --prop axisLine=333333:1:solid + +# 3D area with perspective rotation +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=area3d \ + --prop view3d=20,25,15 + +# 3D area with multiple series and gridlines +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=area3d \ + --prop view3d=15,20,20 --prop gridlines=D9D9D9:0.5:dot +``` + +**Features:** `areaStacked`, `areaPercentStacked`, `area3d`, `plotFill` (solid), `roundedCorners`, `axisNumFmt`, `axisLine`, `view3d` (rotX,rotY,perspective) + +### Sheet: 3-Area Styling + +Four charts demonstrating visual styling — title effects, shadows, gridlines, and fills. + +```bash +# Title styling with shadow +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=area \ + --prop title.font=Georgia --prop title.size=16 \ + --prop title.color=1F4E79 --prop title.bold=true \ + --prop title.shadow=000000-3-315-2-30 + +# Series shadow, outline, and smooth curve +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=area \ + --prop smooth=true \ + --prop series.shadow=000000-4-315-2-40 \ + --prop series.outline=333333-1 + +# Axis font with gridlines and minor gridlines +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=area \ + --prop axisfont=9:58626E:Arial \ + --prop gridlines=D9D9D9:0.5:dot \ + --prop minorGridlines=EEEEEE:0.3:dot + +# Chart fill, plot fill gradient, and borders +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=area \ + --prop chartFill=FAFAFA \ + --prop plotFill=E8F0FE-D6E4F0:90 \ + --prop chartArea.border=D0D0D0:1:solid \ + --prop plotArea.border=E0E0E0:0.5:dot +``` + +**Features:** `title.font`/`.size`/`.color`/`.bold`/`.shadow`, `smooth`, `series.shadow` (color-blur-angle-dist-opacity), `series.outline` (color-width), `axisfont` (size:color:font), `gridlines`, `minorGridlines`, `chartFill`, `plotFill` (gradient), `chartArea.border`, `plotArea.border`, `roundedCorners` + +### Sheet: 4-Labels & Legend + +Four charts demonstrating data label and legend customization plus manual layout. + +```bash +# Data labels with position, font, and number format +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=area \ + --prop dataLabels=true --prop labelPos=top \ + --prop labelFont=9:333333:true \ + --prop dataLabels.numFmt=#,##0 + +# Individual label deletion and per-point colors +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=area \ + --prop dataLabels=true \ + --prop dataLabel1.delete=true --prop dataLabel2.delete=true \ + --prop point4.color=C00000 + +# Legend overlay with font styling +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=area \ + --prop legend=right --prop legendfont=10:1F4E79:Calibri \ + --prop legend.overlay=true + +# Manual layout — plotArea, title, legend positioning +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=area \ + --prop plotArea.x=0.12 --prop plotArea.y=0.18 \ + --prop plotArea.w=0.82 --prop plotArea.h=0.55 \ + --prop title.x=0.25 --prop title.y=0.02 \ + --prop legend.x=0.15 --prop legend.y=0.82 \ + --prop legend.w=0.7 --prop legend.h=0.12 +``` + +**Features:** `dataLabels`, `labelPos` (top), `labelFont`, `dataLabels.numFmt`, `dataLabel{N}.delete`, `point{N}.color`, `legend` (right), `legendfont`, `legend.overlay`, `plotArea.x/y/w/h`, `title.x/y`, `legend.x/y/w/h` + +### Sheet: 5-Advanced + +Four charts demonstrating advanced features — secondary axis, reference lines, axis scaling, and effects. + +```bash +# Secondary axis (dual scale) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=area \ + --prop secondaryAxis=2 \ + --prop series1="Revenue:120,180,250,310,280,340" \ + --prop series2="Conv %:2.1,2.8,3.2,3.9,3.5,4.1" + +# Reference line (target/threshold) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=area \ + --prop referenceLine=100:FF0000:1.5:dash \ + --prop areafill=4472C4-BDD7EE:90 + +# Axis scaling with display units +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=area \ + --prop axisMin=3000 --prop axisMax=7000 \ + --prop majorUnit=500 --prop dispUnits=thousands + +# Color rule with title glow and series shadow +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=area \ + --prop colorRule=50:C00000:70AD47 \ + --prop referenceLine=50:888888:1:solid \ + --prop title.glow=4472C4-8-60 \ + --prop series.shadow=000000-3-315-1-30 +``` + +**Features:** `secondaryAxis` (1-based series index), `referenceLine` (value:color:width:dash), `axisMin`, `axisMax`, `majorUnit`, `dispUnits` (thousands), `colorRule` (threshold:belowColor:aboveColor), `title.glow` (color-radius-opacity), `areafill` + +## Complete Feature Coverage + +| Feature | Sheet | +|---------|-------| +| **Chart types:** area, areaStacked, areaPercentStacked, area3d | 1, 2 | +| **Data input:** dataRange, series, categories, colors | 1 | +| **Area fills:** areafill (gradient), gradients (per-series), transparency | 1, 5 | +| **Axis titles:** catTitle, axisTitle | 1, 3 | +| **Axis scaling:** axisMin/Max, majorUnit, dispUnits | 5 | +| **Axis features:** axisNumFmt, axisLine | 2 | +| **Gridlines:** gridlines, minorGridlines | 1, 3 | +| **Data labels:** dataLabels, labelPos, labelFont, numFmt | 4 | +| **Custom labels:** dataLabel{N}.delete | 4 | +| **Point color:** point{N}.color | 4 | +| **Legend:** position, legendfont, legend.overlay, legend=none | 1, 4 | +| **Layout:** plotArea.x/y/w/h, title.x/y, legend.x/y/w/h | 4 | +| **Effects:** series.shadow, series.outline, smooth | 3 | +| **Title styling:** font, size, color, bold, shadow, glow | 3, 5 | +| **Fills:** plotFill, chartFill (solid + gradient) | 2, 3 | +| **Borders:** chartArea.border, plotArea.border | 3 | +| **Advanced:** secondaryAxis, referenceLine, colorRule | 5 | +| **3D:** view3d | 2 | +| **Other:** roundedCorners | 2, 3 | + +## Inspect the Generated File + +```bash +officecli query charts-area.xlsx chart +officecli get charts-area.xlsx "/1-Area Fundamentals/chart[1]" +``` diff --git a/examples/excel/charts-area.py b/examples/excel/charts-area.py new file mode 100755 index 000000000..63937b516 --- /dev/null +++ b/examples/excel/charts-area.py @@ -0,0 +1,667 @@ +#!/usr/bin/env python3 +""" +Area Charts Showcase — area, areaStacked, areaPercentStacked, and area3d with all variations. + +Generates: charts-area.xlsx + +Every area chart feature officecli supports is demonstrated at least once: +area fills, gradients, transparency, stacking, axis scaling, gridlines, +data labels, legend positioning, reference lines, secondary axis, +shadows, manual layout, and 3D rotation. + +5 sheets, 20 charts total. + + 1-Area Fundamentals 4 charts — data input variants, transparency, area fills, gradients + 2-Area Variants 4 charts — areaStacked, areaPercentStacked, area3d + 3-Area Styling 4 charts — title styling, shadows, gridlines, chart/plot fills + 4-Labels & Legend 4 charts — data labels, per-point colors, legend, manual layout + 5-Advanced 4 charts — secondary axis, reference line, axis scaling, effects + +Usage: + python3 charts-area.py +""" + +import subprocess, sys, os, json, atexit + +FILE = "charts-area.xlsx" + +def cli(cmd): + """Run: officecli """ + r = subprocess.run(f"officecli {cmd}", shell=True, capture_output=True, text=True) + out = (r.stdout or "").strip() + if out: + for line in out.split("\n"): + if line.strip(): + print(f" {line.strip()}") + if r.returncode != 0: + err = (r.stderr or "").strip() + if err and "UNSUPPORTED" not in err and "process cannot access" not in err: + print(f" ERROR: {err}") + +if os.path.exists(FILE): + os.remove(FILE) + +cli(f'create "{FILE}"') +cli(f'open "{FILE}"') +atexit.register(lambda: cli(f'close "{FILE}"')) + +# ========================================================================== +# Source data — shared across all charts +# ========================================================================== +print("\n--- Populating source data ---") + +data_cmds = [] +for j, h in enumerate(["Month", "Organic", "Paid", "Social", "Referral"]): + data_cmds.append({"command": "set", "path": f"/Sheet1/{'ABCDE'[j]}1", "props": {"text": h, "bold": "true"}}) + +months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] +organic = [4200, 4800, 5100, 5600, 6200, 6800, 7500, 8100, 7600, 7200, 6900, 7800] +paid = [3100, 3500, 3800, 4200, 4800, 5200, 5800, 6300, 5900, 5500, 5100, 5700] +social = [1800, 2100, 2400, 2800, 3200, 3600, 4000, 4300, 3900, 3500, 3200, 3800] +referral = [1200, 1400, 1500, 1700, 1900, 2100, 2300, 2500, 2300, 2100, 1900, 2200] + +for i in range(12): + r = i + 2 + for j, val in enumerate([months[i], organic[i], paid[i], social[i], referral[i]]): + data_cmds.append({"command": "set", "path": f"/Sheet1/{'ABCDE'[j]}{r}", "props": {"text": str(val)}}) + +cli(f'batch "{FILE}" --force --commands \'{json.dumps(data_cmds)}\'') + +# ========================================================================== +# Sheet: 1-Area Fundamentals +# ========================================================================== +print("\n--- 1-Area Fundamentals ---") +cli(f'add "{FILE}" / --type sheet --prop name="1-Area Fundamentals"') + +# -------------------------------------------------------------------------- +# Chart 1: Basic area chart with dataRange, axis titles, and custom colors +# +# officecli add charts-area.xlsx "/1-Area Fundamentals" --type chart \ +# --prop chartType=area \ +# --prop title="Website Traffic Overview" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop colors=4472C4,ED7D31,70AD47,FFC000 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop catTitle=Month --prop axisTitle=Visitors \ +# --prop gridlines=D9D9D9:0.5:dot +# +# Features: chartType=area, dataRange, colors, catTitle, axisTitle, gridlines +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Area Fundamentals" --type chart' + f' --prop chartType=area' + f' --prop title="Website Traffic Overview"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop colors=4472C4,ED7D31,70AD47,FFC000' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop catTitle=Month --prop axisTitle=Visitors' + f' --prop gridlines=D9D9D9:0.5:dot') + +# -------------------------------------------------------------------------- +# Chart 2: Inline series with transparency +# +# officecli add charts-area.xlsx "/1-Area Fundamentals" --type chart \ +# --prop chartType=area \ +# --prop title="Quarterly Revenue Streams" \ +# --prop series1="Subscriptions:120,180,210,250" \ +# --prop series2="One-time:90,140,160,200" \ +# --prop series3="Services:60,85,110,145" \ +# --prop categories=Q1,Q2,Q3,Q4 \ +# --prop colors=2E75B6,70AD47,FFC000 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop transparency=40 \ +# --prop legend=bottom +# +# Features: inline series, transparency (0-100), legend=bottom +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Area Fundamentals" --type chart' + f' --prop chartType=area' + f' --prop title="Quarterly Revenue Streams"' + f' --prop series1="Subscriptions:120,180,210,250"' + f' --prop series2="One-time:90,140,160,200"' + f' --prop series3="Services:60,85,110,145"' + f' --prop categories=Q1,Q2,Q3,Q4' + f' --prop colors=2E75B6,70AD47,FFC000' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop transparency=40' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: Area with areafill gradient +# +# officecli add charts-area.xlsx "/1-Area Fundamentals" --type chart \ +# --prop chartType=area \ +# --prop title="Monthly Active Users" \ +# --prop series1="Users:3200,3800,4500,5100,5800,6400" \ +# --prop categories=Jul,Aug,Sep,Oct,Nov,Dec \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop areafill=4472C4-BDD7EE:90 \ +# --prop legend=none +# +# Features: areafill (gradient from-to:angle), legend=none, single series +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Area Fundamentals" --type chart' + f' --prop chartType=area' + f' --prop title="Monthly Active Users"' + f' --prop series1="Users:3200,3800,4500,5100,5800,6400"' + f' --prop categories=Jul,Aug,Sep,Oct,Nov,Dec' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop areafill=4472C4-BDD7EE:90' + f' --prop legend=none') + +# -------------------------------------------------------------------------- +# Chart 4: Per-series gradient fills +# +# officecli add charts-area.xlsx "/1-Area Fundamentals" --type chart \ +# --prop chartType=area \ +# --prop title="Revenue by Channel" \ +# --prop series1="Direct:45,52,61,70" \ +# --prop series2="Partner:30,38,42,55" \ +# --prop categories=Q1,Q2,Q3,Q4 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop 'gradients=4472C4-BDD7EE:90;ED7D31-FBE5D6:90' \ +# --prop legend=right --prop legendfont=10:333333:Calibri +# +# Features: gradients (per-series gradient fills from-to:angle;...), +# legendfont (size:color:font) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Area Fundamentals" --type chart' + f' --prop chartType=area' + f' --prop title="Revenue by Channel"' + f' --prop series1="Direct:45,52,61,70"' + f' --prop series2="Partner:30,38,42,55"' + f' --prop categories=Q1,Q2,Q3,Q4' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop "gradients=4472C4-BDD7EE:90;ED7D31-FBE5D6:90"' + f' --prop legend=right --prop legendfont=10:333333:Calibri') + +# ========================================================================== +# Sheet: 2-Area Variants +# ========================================================================== +print("\n--- 2-Area Variants ---") +cli(f'add "{FILE}" / --type sheet --prop name="2-Area Variants"') + +# -------------------------------------------------------------------------- +# Chart 1: Stacked area with plotFill and rounded corners +# +# officecli add charts-area.xlsx "/2-Area Variants" --type chart \ +# --prop chartType=areaStacked \ +# --prop title="Cumulative Traffic Sources" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop colors=4472C4,ED7D31,70AD47,FFC000 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop plotFill=F5F5F5 \ +# --prop roundedCorners=true \ +# --prop legend=bottom +# +# Features: chartType=areaStacked, plotFill (solid), roundedCorners +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Area Variants" --type chart' + f' --prop chartType=areaStacked' + f' --prop title="Cumulative Traffic Sources"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop colors=4472C4,ED7D31,70AD47,FFC000' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop plotFill=F5F5F5' + f' --prop roundedCorners=true' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 2: 100% stacked area with axis number format and axis line +# +# officecli add charts-area.xlsx "/2-Area Variants" --type chart \ +# --prop chartType=areaPercentStacked \ +# --prop title="Traffic Share by Channel" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop colors=2E75B6,C55A11,548235,BF8F00 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop axisNumFmt=0% \ +# --prop axisLine=333333:1:solid \ +# --prop legend=bottom +# +# Features: chartType=areaPercentStacked, axisNumFmt, axisLine +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Area Variants" --type chart' + f' --prop chartType=areaPercentStacked' + f' --prop title="Traffic Share by Channel"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop colors=2E75B6,C55A11,548235,BF8F00' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop axisNumFmt=0%' + f' --prop axisLine=333333:1:solid' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: 3D area with perspective rotation +# +# officecli add charts-area.xlsx "/2-Area Variants" --type chart \ +# --prop chartType=area3d \ +# --prop title="3D Regional Sales" \ +# --prop series1="East:120,135,148,162,155,178" \ +# --prop series2="West:95,108,115,128,142,155" \ +# --prop series3="Central:88,92,105,118,125,138" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun \ +# --prop colors=4472C4,ED7D31,70AD47 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop view3d=20,25,15 \ +# --prop legend=right +# +# Features: chartType=area3d, view3d (rotX,rotY,perspective) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Area Variants" --type chart' + f' --prop chartType=area3d' + f' --prop title="3D Regional Sales"' + f' --prop series1="East:120,135,148,162,155,178"' + f' --prop series2="West:95,108,115,128,142,155"' + f' --prop series3="Central:88,92,105,118,125,138"' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun' + f' --prop colors=4472C4,ED7D31,70AD47' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop view3d=20,25,15' + f' --prop legend=right') + +# -------------------------------------------------------------------------- +# Chart 4: 3D stacked area +# +# officecli add charts-area.xlsx "/2-Area Variants" --type chart \ +# --prop chartType=area3d \ +# --prop title="3D Stacked Inventory" \ +# --prop series1="Warehouse A:500,480,520,550,530,560" \ +# --prop series2="Warehouse B:320,350,340,380,400,410" \ +# --prop series3="Warehouse C:180,200,210,230,250,240" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun \ +# --prop colors=1F4E79,2E75B6,9DC3E6 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop view3d=15,20,20 \ +# --prop gridlines=D9D9D9:0.5:dot +# +# Features: area3d stacked appearance, multiple series, gridlines +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Area Variants" --type chart' + f' --prop chartType=area3d' + f' --prop title="3D Stacked Inventory"' + f' --prop series1="Warehouse A:500,480,520,550,530,560"' + f' --prop series2="Warehouse B:320,350,340,380,400,410"' + f' --prop series3="Warehouse C:180,200,210,230,250,240"' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun' + f' --prop colors=1F4E79,2E75B6,9DC3E6' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop view3d=15,20,20' + f' --prop gridlines=D9D9D9:0.5:dot') + +# ========================================================================== +# Sheet: 3-Area Styling +# ========================================================================== +print("\n--- 3-Area Styling ---") +cli(f'add "{FILE}" / --type sheet --prop name="3-Area Styling"') + +# -------------------------------------------------------------------------- +# Chart 1: Title styling (font, size, color, bold, shadow) +# +# officecli add charts-area.xlsx "/3-Area Styling" --type chart \ +# --prop chartType=area \ +# --prop title="Styled Title Demo" \ +# --prop series1="Revenue:80,120,160,200,240,280" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun \ +# --prop colors=4472C4 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop title.font=Georgia --prop title.size=16 \ +# --prop title.color=1F4E79 --prop title.bold=true \ +# --prop title.shadow=000000-3-315-2-30 \ +# --prop transparency=30 +# +# Features: title.font, title.size, title.color, title.bold, title.shadow +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Area Styling" --type chart' + f' --prop chartType=area' + f' --prop title="Styled Title Demo"' + f' --prop series1="Revenue:80,120,160,200,240,280"' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun' + f' --prop colors=4472C4' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop title.font=Georgia --prop title.size=16' + f' --prop title.color=1F4E79 --prop title.bold=true' + f' --prop title.shadow=000000-3-315-2-30' + f' --prop transparency=30') + +# -------------------------------------------------------------------------- +# Chart 2: Series shadow, outline, and smooth curve +# +# officecli add charts-area.xlsx "/3-Area Styling" --type chart \ +# --prop chartType=area \ +# --prop title="Smooth Area with Effects" \ +# --prop series1="Signups:150,180,220,260,310,350" \ +# --prop series2="Trials:90,110,140,170,200,230" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun \ +# --prop colors=4472C4,70AD47 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop smooth=true \ +# --prop series.shadow=000000-4-315-2-40 \ +# --prop series.outline=333333-1 \ +# --prop transparency=25 +# +# Features: smooth, series.shadow (color-blur-angle-dist-opacity), +# series.outline (color-width) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Area Styling" --type chart' + f' --prop chartType=area' + f' --prop title="Smooth Area with Effects"' + f' --prop series1="Signups:150,180,220,260,310,350"' + f' --prop series2="Trials:90,110,140,170,200,230"' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun' + f' --prop colors=4472C4,70AD47' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop smooth=true' + f' --prop series.shadow=000000-4-315-2-40' + f' --prop series.outline=333333-1' + f' --prop transparency=25') + +# -------------------------------------------------------------------------- +# Chart 3: Axis font styling, gridlines, and minor gridlines +# +# officecli add charts-area.xlsx "/3-Area Styling" --type chart \ +# --prop chartType=area \ +# --prop title="Gridline Configuration" \ +# --prop dataRange=Sheet1!A1:C13 \ +# --prop colors=2E75B6,C55A11 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop axisfont=9:58626E:Arial \ +# --prop gridlines=D9D9D9:0.5:dot \ +# --prop minorGridlines=EEEEEE:0.3:dot \ +# --prop catTitle=Month --prop axisTitle=Visitors +# +# Features: axisfont (size:color:font), gridlines (color:width:dash), +# minorGridlines +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Area Styling" --type chart' + f' --prop chartType=area' + f' --prop title="Gridline Configuration"' + f' --prop dataRange=Sheet1!A1:C13' + f' --prop colors=2E75B6,C55A11' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop axisfont=9:58626E:Arial' + f' --prop gridlines=D9D9D9:0.5:dot' + f' --prop minorGridlines=EEEEEE:0.3:dot' + f' --prop catTitle=Month --prop axisTitle=Visitors') + +# -------------------------------------------------------------------------- +# Chart 4: Chart fill, plot fill gradient, chart/plot area borders +# +# officecli add charts-area.xlsx "/3-Area Styling" --type chart \ +# --prop chartType=area \ +# --prop title="Fills and Borders" \ +# --prop series1="Sales:200,240,280,320,360,400" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun \ +# --prop colors=4472C4 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop chartFill=FAFAFA \ +# --prop plotFill=E8F0FE-D6E4F0:90 \ +# --prop chartArea.border=D0D0D0:1:solid \ +# --prop plotArea.border=E0E0E0:0.5:dot \ +# --prop roundedCorners=true +# +# Features: chartFill, plotFill (gradient from-to:angle), +# chartArea.border, plotArea.border, roundedCorners +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Area Styling" --type chart' + f' --prop chartType=area' + f' --prop title="Fills and Borders"' + f' --prop series1="Sales:200,240,280,320,360,400"' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun' + f' --prop colors=4472C4' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop chartFill=FAFAFA' + f' --prop "plotFill=E8F0FE-D6E4F0:90"' + f' --prop chartArea.border=D0D0D0:1:solid' + f' --prop plotArea.border=E0E0E0:0.5:dot' + f' --prop roundedCorners=true') + +# ========================================================================== +# Sheet: 4-Labels & Legend +# ========================================================================== +print("\n--- 4-Labels & Legend ---") +cli(f'add "{FILE}" / --type sheet --prop name="4-Labels & Legend"') + +# -------------------------------------------------------------------------- +# Chart 1: Data labels with position, font, and number format +# +# officecli add charts-area.xlsx "/4-Labels & Legend" --type chart \ +# --prop chartType=area \ +# --prop title="Labeled Area Chart" \ +# --prop series1="Users:3200,3800,4500,5100,5800,6400" \ +# --prop categories=Jul,Aug,Sep,Oct,Nov,Dec \ +# --prop colors=4472C4 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop dataLabels=true --prop labelPos=top \ +# --prop labelFont=9:333333:true \ +# --prop dataLabels.numFmt=#,##0 +# +# Features: dataLabels, labelPos (top), labelFont (size:color:bold), +# dataLabels.numFmt +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Labels & Legend" --type chart' + f' --prop chartType=area' + f' --prop title="Labeled Area Chart"' + f' --prop series1="Users:3200,3800,4500,5100,5800,6400"' + f' --prop categories=Jul,Aug,Sep,Oct,Nov,Dec' + f' --prop colors=4472C4' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop dataLabels=true --prop labelPos=top' + f' --prop labelFont=9:333333:true' + f' --prop dataLabels.numFmt=#,##0') + +# -------------------------------------------------------------------------- +# Chart 2: Individual label deletion and per-point colors +# +# officecli add charts-area.xlsx "/4-Labels & Legend" --type chart \ +# --prop chartType=area \ +# --prop title="Highlighted Peak Month" \ +# --prop series1="Revenue:180,210,250,310,280,260" \ +# --prop categories=Jul,Aug,Sep,Oct,Nov,Dec \ +# --prop colors=2E75B6 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop dataLabels=true \ +# --prop dataLabel1.delete=true --prop dataLabel2.delete=true \ +# --prop dataLabel5.delete=true --prop dataLabel6.delete=true \ +# --prop point4.color=C00000 \ +# --prop transparency=30 +# +# Features: dataLabel{N}.delete, point{N}.color +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Labels & Legend" --type chart' + f' --prop chartType=area' + f' --prop title="Highlighted Peak Month"' + f' --prop series1="Revenue:180,210,250,310,280,260"' + f' --prop categories=Jul,Aug,Sep,Oct,Nov,Dec' + f' --prop colors=2E75B6' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop dataLabels=true' + f' --prop dataLabel1.delete=true --prop dataLabel2.delete=true' + f' --prop dataLabel5.delete=true --prop dataLabel6.delete=true' + f' --prop point4.color=C00000' + f' --prop transparency=30') + +# -------------------------------------------------------------------------- +# Chart 3: Legend positioning with overlay and font styling +# +# officecli add charts-area.xlsx "/4-Labels & Legend" --type chart \ +# --prop chartType=area \ +# --prop title="Legend Overlay Demo" \ +# --prop series1="Desktop:4200,4800,5100,5600" \ +# --prop series2="Mobile:3100,3500,3800,4200" \ +# --prop series3="Tablet:1200,1400,1500,1700" \ +# --prop categories=Q1,Q2,Q3,Q4 \ +# --prop colors=4472C4,ED7D31,70AD47 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop legend=right --prop legendfont=10:1F4E79:Calibri \ +# --prop legend.overlay=true \ +# --prop transparency=35 +# +# Features: legend=right, legendfont, legend.overlay +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Labels & Legend" --type chart' + f' --prop chartType=area' + f' --prop title="Legend Overlay Demo"' + f' --prop series1="Desktop:4200,4800,5100,5600"' + f' --prop series2="Mobile:3100,3500,3800,4200"' + f' --prop series3="Tablet:1200,1400,1500,1700"' + f' --prop categories=Q1,Q2,Q3,Q4' + f' --prop colors=4472C4,ED7D31,70AD47' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop legend=right --prop legendfont=10:1F4E79:Calibri' + f' --prop legend.overlay=true' + f' --prop transparency=35') + +# -------------------------------------------------------------------------- +# Chart 4: Manual layout — plotArea positioning +# +# officecli add charts-area.xlsx "/4-Labels & Legend" --type chart \ +# --prop chartType=area \ +# --prop title="Manual Layout" \ +# --prop series1="Growth:100,130,170,220,280,350" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun \ +# --prop colors=70AD47 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop plotArea.x=0.12 --prop plotArea.y=0.18 \ +# --prop plotArea.w=0.82 --prop plotArea.h=0.55 \ +# --prop title.x=0.25 --prop title.y=0.02 \ +# --prop legend.x=0.15 --prop legend.y=0.82 \ +# --prop legend.w=0.7 --prop legend.h=0.12 +# +# Features: plotArea.x/y/w/h, title.x/y, legend.x/y/w/h (manual layout) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Labels & Legend" --type chart' + f' --prop chartType=area' + f' --prop title="Manual Layout"' + f' --prop series1="Growth:100,130,170,220,280,350"' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun' + f' --prop colors=70AD47' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop plotArea.x=0.12 --prop plotArea.y=0.18' + f' --prop plotArea.w=0.82 --prop plotArea.h=0.55' + f' --prop title.x=0.25 --prop title.y=0.02' + f' --prop legend.x=0.15 --prop legend.y=0.82' + f' --prop legend.w=0.7 --prop legend.h=0.12') + +# ========================================================================== +# Sheet: 5-Advanced +# ========================================================================== +print("\n--- 5-Advanced ---") +cli(f'add "{FILE}" / --type sheet --prop name="5-Advanced"') + +# -------------------------------------------------------------------------- +# Chart 1: Secondary axis (dual scale) +# +# officecli add charts-area.xlsx "/5-Advanced" --type chart \ +# --prop chartType=area \ +# --prop title="Revenue vs Conversion Rate" \ +# --prop series1="Revenue:120,180,250,310,280,340" \ +# --prop series2="Conv %:2.1,2.8,3.2,3.9,3.5,4.1" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun \ +# --prop colors=4472C4,C00000 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop secondaryAxis=2 \ +# --prop transparency=30 +# +# Features: secondaryAxis (1-based series index on secondary Y axis) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Advanced" --type chart' + f' --prop chartType=area' + f' --prop title="Revenue vs Conversion Rate"' + f' --prop series1="Revenue:120,180,250,310,280,340"' + f' --prop series2="Conv %:2.1,2.8,3.2,3.9,3.5,4.1"' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun' + f' --prop colors=4472C4,C00000' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop secondaryAxis=2' + f' --prop transparency=30') + +# -------------------------------------------------------------------------- +# Chart 2: Reference line +# +# officecli add charts-area.xlsx "/5-Advanced" --type chart \ +# --prop chartType=area \ +# --prop title="Sales vs Target" \ +# --prop series1="Sales:85,92,108,115,98,120" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun \ +# --prop colors=4472C4 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop referenceLine=100:FF0000:1.5:dash \ +# --prop transparency=25 \ +# --prop areafill=4472C4-BDD7EE:90 +# +# Features: referenceLine (value:color:width:dash) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Advanced" --type chart' + f' --prop chartType=area' + f' --prop title="Sales vs Target"' + f' --prop series1="Sales:85,92,108,115,98,120"' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun' + f' --prop colors=4472C4' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop referenceLine=100:FF0000:1.5:dash' + f' --prop transparency=25' + f' --prop areafill=4472C4-BDD7EE:90') + +# -------------------------------------------------------------------------- +# Chart 3: Axis min/max, major unit, log scale, display units +# +# officecli add charts-area.xlsx "/5-Advanced" --type chart \ +# --prop chartType=area \ +# --prop title="Axis Scaling Demo" \ +# --prop series1="Visits:3200,3800,4500,5100,5800,6400" \ +# --prop categories=Jul,Aug,Sep,Oct,Nov,Dec \ +# --prop colors=2E75B6 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop axisMin=3000 --prop axisMax=7000 \ +# --prop majorUnit=500 \ +# --prop dispUnits=thousands \ +# --prop axisTitle=Visitors (K) \ +# --prop transparency=30 +# +# Features: axisMin, axisMax, majorUnit, dispUnits (thousands/millions) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Advanced" --type chart' + f' --prop chartType=area' + f' --prop title="Axis Scaling Demo"' + f' --prop series1="Visits:3200,3800,4500,5100,5800,6400"' + f' --prop categories=Jul,Aug,Sep,Oct,Nov,Dec' + f' --prop colors=2E75B6' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop axisMin=3000 --prop axisMax=7000' + f' --prop majorUnit=500' + f' --prop dispUnits=thousands' + f' --prop "axisTitle=Visitors (K)"' + f' --prop transparency=30') + +# -------------------------------------------------------------------------- +# Chart 4: Color rule, title glow, series shadow +# +# officecli add charts-area.xlsx "/5-Advanced" --type chart \ +# --prop chartType=area \ +# --prop title="Performance Threshold" \ +# --prop series1="Score:45,62,38,71,55,80" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop colorRule=50:C00000:70AD47 \ +# --prop referenceLine=50:888888:1:solid \ +# --prop title.glow=4472C4-8-60 \ +# --prop series.shadow=000000-3-315-1-30 \ +# --prop transparency=20 +# +# Features: colorRule (threshold:belowColor:aboveColor), title.glow +# (color-radius-opacity), series.shadow +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Advanced" --type chart' + f' --prop chartType=area' + f' --prop title="Performance Threshold"' + f' --prop series1="Score:45,62,38,71,55,80"' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop colorRule=50:C00000:70AD47' + f' --prop referenceLine=50:888888:1:solid' + f' --prop title.glow=4472C4-8-60' + f' --prop series.shadow=000000-3-315-1-30' + f' --prop transparency=20') + +print(f"\nDone! Generated: {FILE}") +print(" 6 sheets (Sheet1 data + 5 chart sheets, 20 charts total)") diff --git a/examples/excel/charts-area.xlsx b/examples/excel/charts-area.xlsx new file mode 100644 index 000000000..f45ecf4ca Binary files /dev/null and b/examples/excel/charts-area.xlsx differ diff --git a/examples/excel/charts-bar.md b/examples/excel/charts-bar.md new file mode 100644 index 000000000..2a977aa63 --- /dev/null +++ b/examples/excel/charts-bar.md @@ -0,0 +1,274 @@ +# Bar (Horizontal) Charts Showcase + +This demo consists of three files that work together: + +- **charts-bar.py** — Python script that calls `officecli` commands to generate the workbook. Each chart command is shown as a copyable shell command in the comments. +- **charts-bar.xlsx** — The generated workbook with 7 sheets (1 data + 6 chart sheets, 24 charts total). +- **charts-bar.md** — This file. Maps each sheet to the features it demonstrates. + +## Regenerate + +```bash +cd examples/excel +python3 charts-bar.py +# → charts-bar.xlsx +``` + +## Chart Sheets + +### Sheet: 1-Bar Fundamentals + +Four basic horizontal bar charts covering data input variants, colors, stacking, and shorthand syntax. + +```bash +# Basic bar from cell range with axis titles and gridlines +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar \ + --prop dataRange=Sheet1!A1:B9 \ + --prop catTitle=Department --prop axisTitle=Score \ + --prop axisfont=9:333333:Arial \ + --prop gridlines=D9D9D9:0.5:dot + +# Inline series with custom colors and data labels +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar \ + --prop series1="Satisfaction:85,72,91,68,78" \ + --prop colors=4472C4,ED7D31,70AD47,FFC000,5B9BD5 \ + --prop gapwidth=80 --prop dataLabels=outsideEnd + +# Stacked bar with series outline +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=barStacked \ + --prop series1="Q1:30,18,25,12" --prop series2="Q2:35,20,28,14" \ + --prop overlap=0 --prop series.outline=FFFFFF-0.5 + +# data= shorthand with legend at bottom +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar \ + --prop 'data=Technical:45,38,52;Soft Skills:20,28,18;Compliance:12,15,10' \ + --prop legend=bottom +``` + +**Features:** `bar`, `barStacked`, `dataRange`, `catTitle`, `axisTitle`, `axisfont`, `gridlines`, `colors`, `gapwidth`, `dataLabels=outsideEnd`, `overlap`, `series.outline`, `data=` shorthand, `legend=bottom` + +### Sheet: 2-Bar Variants + +Four bar chart type variants: stacked, 100% stacked, 3D, and 3D cylinder. + +```bash +# Stacked bar with tight gap +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=barStacked \ + --prop gapwidth=50 + +# 100% stacked with percentage axis and reference line +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=barPercentStacked \ + --prop axisNumFmt=0% \ + --prop referenceLine=0.5:FF0000:Target:dash + +# 3D bar with perspective +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar3d \ + --prop view3d=10,30,20 --prop style=3 + +# 3D bar with cylinder shape +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar3d \ + --prop shape=cylinder --prop gapwidth=60 +``` + +**Features:** `barStacked`, `barPercentStacked`, `bar3d`, `gapwidth`, `axisNumFmt=0%`, `referenceLine` (with label and dash), `view3d`, `style`, `shape=cylinder` + +### Sheet: 3-Bar Styling + +Four charts demonstrating visual styling: title formatting, shadows, gradients, and background fills. + +```bash +# Title font, size, color, bold +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar \ + --prop title.font=Georgia --prop title.size=16 \ + --prop title.color=1F4E79 --prop title.bold=true + +# Series shadow and outline +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar \ + --prop series.shadow=000000-4-315-2-30 \ + --prop series.outline=1F4E79-1 + +# Per-bar gradient fills (angle=0 for horizontal) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar \ + --prop 'gradients=1F4E79-5B9BD5:0;C55A11-F4B183:0;...' \ + --prop labelFont=9:333333:true + +# Plot/chart fill with transparency and rounded corners +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar \ + --prop plotFill=F0F4F8-D6E4F0:90 --prop chartFill=FFFFFF \ + --prop transparency=20 --prop roundedCorners=true +``` + +**Features:** `title.font/size/color/bold`, `series.shadow`, `series.outline`, `gradients` (per-bar), `labelFont`, `plotFill` gradient, `chartFill`, `transparency`, `roundedCorners` + +### Sheet: 4-Axis & Labels + +Four charts exploring axis configuration and data label customization. + +```bash +# Custom axis scale with gridlines styling +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar \ + --prop axisMin=50 --prop axisMax=250 --prop majorUnit=50 \ + --prop gridlines=D0D0D0:0.5:solid \ + --prop minorGridlines=EEEEEE:0.3:dot \ + --prop axisLine=C00000:1.5:solid --prop catAxisLine=2E75B6:1.5:solid + +# Log scale, reversed axis, display units +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar \ + --prop logBase=10 --prop axisReverse=true \ + --prop dispUnits=thousands + +# Data labels with font, number format, separator +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar \ + --prop dataLabels=true --prop labelPos=outsideEnd \ + --prop labelFont=10:1F4E79:true \ + --prop dataLabels.numFmt=#,##0 --prop "dataLabels.separator=: " + +# Per-point label delete/text and per-point color (highlight winner) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar \ + --prop dataLabel1.delete=true --prop dataLabel4.text="Winner!" \ + --prop point4.color=C00000 --prop point2.color=2E75B6 +``` + +**Features:** `axisMin`, `axisMax`, `majorUnit`, `gridlines`, `minorGridlines`, `axisLine`, `catAxisLine`, `logBase`, `axisReverse`, `dispUnits`, `dataLabels`, `labelPos`, `labelFont`, `dataLabels.numFmt`, `dataLabels.separator`, `dataLabel{N}.delete`, `dataLabel{N}.text`, `point{N}.color` + +### Sheet: 5-Legend & Layout + +Four charts covering legend configuration, manual layout, and dual-axis support. + +```bash +# Legend on right side +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar \ + --prop legend=right + +# Legend font styling with overlay +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar \ + --prop legend=top --prop legend.overlay=true \ + --prop legendfont=10:1F4E79:Calibri + +# Manual layout: plotArea, title, and legend positioning +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar \ + --prop plotArea.x=0.25 --prop plotArea.y=0.15 \ + --prop plotArea.w=0.70 --prop plotArea.h=0.60 \ + --prop title.x=0.20 --prop title.y=0.02 \ + --prop legend.x=0.25 --prop legend.y=0.82 + +# Secondary axis with chart/plot area borders +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar \ + --prop secondaryAxis=2 \ + --prop chartArea.border=D0D0D0:1:solid \ + --prop plotArea.border=E0E0E0:0.5:dot +``` + +**Features:** `legend=right/top/bottom`, `legend.overlay`, `legendfont`, `plotArea.x/y/w/h`, `title.x/y`, `legend.x/y/w/h`, `secondaryAxis`, `chartArea.border`, `plotArea.border` + +### Sheet: 6-Advanced + +Four charts with advanced features: reference lines, conditional coloring, effects, and data tables. + +```bash +# Reference line with label +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar \ + --prop referenceLine=79:FF0000:Average:dash + +# Conditional coloring (profit/loss) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar \ + --prop colorRule=0:C00000:70AD47 \ + --prop referenceLine=0:888888:1:solid + +# Title glow, title shadow, series shadow +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar \ + --prop title.glow=4472C4-8-60 \ + --prop title.shadow=000000-3-315-2-40 \ + --prop series.shadow=000000-3-315-1-30 + +# Error bars and data table +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar \ + --prop errBars=percent:10 --prop dataTable=true \ + --prop legend=none +``` + +**Features:** `referenceLine` (with label), `colorRule` (threshold coloring), `title.glow`, `title.shadow`, `series.shadow`, `errBars=percent:10`, `dataTable=true` + +## Feature Coverage + +| Feature | Sheet | +|---|---| +| `bar` (basic horizontal) | 1, 3, 4, 5, 6 | +| `barStacked` | 1, 2 | +| `barPercentStacked` | 2 | +| `bar3d` | 2 | +| `bar3d shape=cylinder` | 2 | +| `dataRange` (cell reference) | 1, 3, 5, 6 | +| `data=` shorthand | 1 | +| `series1=Name:values` | 1, 2, 3, 4, 5, 6 | +| `colors` | 1, 2, 3, 4, 5, 6 | +| `gapwidth` | 1, 2, 4, 6 | +| `overlap` | 1 | +| `dataLabels` / `labelPos` | 1, 3, 4, 6 | +| `labelFont` | 3, 4, 6 | +| `dataLabels.numFmt` | 4 | +| `dataLabels.separator` | 4 | +| `dataLabel{N}.delete/text` | 4 | +| `point{N}.color` | 4 | +| `catTitle` / `axisTitle` | 1 | +| `axisfont` | 1 | +| `axisMin/Max` / `majorUnit` | 4 | +| `gridlines` / `minorGridlines` | 1, 4, 6 | +| `axisLine` / `catAxisLine` | 4 | +| `logBase` | 4 | +| `axisReverse` | 4 | +| `dispUnits` | 4 | +| `axisNumFmt` | 2 | +| `legend` positions | 1, 2, 5, 6 | +| `legendfont` | 5 | +| `legend.overlay` | 5 | +| `title.font/size/color/bold` | 3 | +| `title.glow` / `title.shadow` | 6 | +| `series.shadow` | 3, 6 | +| `series.outline` | 1, 3 | +| `gradients` | 3 | +| `plotFill` / `chartFill` | 3, 6 | +| `transparency` | 3 | +| `roundedCorners` | 3 | +| `referenceLine` | 2, 6 | +| `colorRule` | 6 | +| `secondaryAxis` | 5 | +| `chartArea.border` / `plotArea.border` | 5 | +| `plotArea.x/y/w/h` | 5 | +| `title.x/y` | 5 | +| `legend.x/y/w/h` | 5 | +| `view3d` / `style` | 2 | +| `shape=cylinder` | 2 | +| `errBars` | 6 | +| `dataTable` | 6 | + +## Inspect the Generated File + +```bash +officecli query charts-bar.xlsx chart +officecli get charts-bar.xlsx "/1-Bar Fundamentals/chart[1]" +``` diff --git a/examples/excel/charts-bar.py b/examples/excel/charts-bar.py new file mode 100644 index 000000000..2c7585304 --- /dev/null +++ b/examples/excel/charts-bar.py @@ -0,0 +1,788 @@ +#!/usr/bin/env python3 +""" +Bar (Horizontal) Charts Showcase — bar, barStacked, barPercentStacked, and bar3d with all variations. + +Generates: charts-bar.xlsx + +Every horizontal bar chart feature officecli supports is demonstrated at least once: +gap width, overlap, data labels, axis scaling, gridlines, legend positioning, +reference lines, secondary axis, error bars, gradients, transparency, shadows, +manual layout, data table, 3D rotation, and conditional coloring. + +6 sheets, 24 charts total. + + 1-Bar Fundamentals 4 charts — data input variants, colors, stacked, data shorthand + 2-Bar Variants 4 charts — barStacked, barPercentStacked, bar3d, cylinder + 3-Bar Styling 4 charts — title styling, shadow/outline, gradients, plot/chart fill + 4-Axis & Labels 4 charts — axis scale, log/reverse/dispUnits, label styling, per-point + 5-Legend & Layout 4 charts — legend positions, overlay, manual layout, secondary axis + 6-Advanced 4 charts — reference line, colorRule, glow/shadow, errBars/dataTable + +Usage: + python3 charts-bar.py +""" + +import subprocess, sys, os, json, atexit + +FILE = "charts-bar.xlsx" + +def cli(cmd): + """Run: officecli """ + r = subprocess.run(f"officecli {cmd}", shell=True, capture_output=True, text=True) + out = (r.stdout or "").strip() + if out: + for line in out.split("\n"): + if line.strip(): + print(f" {line.strip()}") + if r.returncode != 0: + err = (r.stderr or "").strip() + if err and "UNSUPPORTED" not in err and "process cannot access" not in err: + print(f" ERROR: {err}") + +if os.path.exists(FILE): + os.remove(FILE) + +cli(f'create "{FILE}"') +cli(f'open "{FILE}"') +atexit.register(lambda: cli(f'close "{FILE}"')) + +# ========================================================================== +# Source data — shared across all charts +# ========================================================================== +print("\n--- Populating source data ---") + +data_cmds = [] +for j, h in enumerate(["Department", "Q1", "Q2", "Q3", "Q4"]): + data_cmds.append({"command": "set", "path": f"/Sheet1/{'ABCDE'[j]}1", "props": {"text": h, "bold": "true"}}) + +depts = ["Engineering", "Marketing", "Sales", "Support", "Finance", "HR", "Legal", "Operations"] +q1 = [185, 120, 210, 95, 78, 62, 55, 140] +q2 = [195, 135, 225, 105, 82, 68, 58, 152] +q3 = [210, 142, 240, 112, 88, 72, 62, 165] +q4 = [228, 158, 260, 118, 92, 78, 68, 178] + +for i in range(8): + r = i + 2 + for j, val in enumerate([depts[i], q1[i], q2[i], q3[i], q4[i]]): + data_cmds.append({"command": "set", "path": f"/Sheet1/{'ABCDE'[j]}{r}", "props": {"text": str(val)}}) + +cli(f'batch "{FILE}" --force --commands \'{json.dumps(data_cmds)}\'') + +# ========================================================================== +# Sheet: 1-Bar Fundamentals +# ========================================================================== +print("\n--- 1-Bar Fundamentals ---") +cli(f'add "{FILE}" / --type sheet --prop name="1-Bar Fundamentals"') + +# -------------------------------------------------------------------------- +# Chart 1: Basic bar chart with dataRange, axis titles, and gridlines +# +# officecli add charts-bar.xlsx "/1-Bar Fundamentals" --type chart \ +# --prop chartType=bar \ +# --prop title="Department Performance — Q1" \ +# --prop dataRange=Sheet1!A1:B9 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop catTitle=Department --prop axisTitle=Score \ +# --prop axisfont=9:333333:Arial \ +# --prop gridlines=D9D9D9:0.5:dot +# +# Features: chartType=bar, dataRange, catTitle, axisTitle, axisfont, gridlines +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Bar Fundamentals" --type chart' + f' --prop chartType=bar' + f' --prop title="Department Performance — Q1"' + f' --prop dataRange=Sheet1!A1:B9' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop catTitle=Department --prop axisTitle=Score' + f' --prop axisfont=9:333333:Arial' + f' --prop gridlines=D9D9D9:0.5:dot') + +# -------------------------------------------------------------------------- +# Chart 2: Inline series with custom colors, gap width, and data labels +# +# officecli add charts-bar.xlsx "/1-Bar Fundamentals" --type chart \ +# --prop chartType=bar \ +# --prop title="Survey Results" \ +# --prop series1="Satisfaction:85,72,91,68,78" \ +# --prop categories=Product,Service,Delivery,Price,Overall \ +# --prop colors=4472C4,ED7D31,70AD47,FFC000,5B9BD5 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop gapwidth=80 \ +# --prop dataLabels=outsideEnd +# +# Features: inline series, colors per category, gapwidth, dataLabels=outsideEnd +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Bar Fundamentals" --type chart' + f' --prop chartType=bar' + f' --prop title="Survey Results"' + f' --prop series1=Satisfaction:85,72,91,68,78' + f' --prop categories=Product,Service,Delivery,Price,Overall' + f' --prop colors=4472C4,ED7D31,70AD47,FFC000,5B9BD5' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop gapwidth=80' + f' --prop dataLabels=outsideEnd') + +# -------------------------------------------------------------------------- +# Chart 3: Stacked bar with overlap and series outline +# +# officecli add charts-bar.xlsx "/1-Bar Fundamentals" --type chart \ +# --prop chartType=barStacked \ +# --prop title="Quarterly Headcount by Dept" \ +# --prop series1="Q1:30,18,25,12" \ +# --prop series2="Q2:35,20,28,14" \ +# --prop series3="Q3:38,22,30,16" \ +# --prop categories=Engineering,Marketing,Sales,Support \ +# --prop colors=2E75B6,70AD47,FFC000 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop overlap=0 \ +# --prop series.outline=FFFFFF-0.5 +# +# Features: barStacked, overlap=0, series.outline (white separator) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Bar Fundamentals" --type chart' + f' --prop chartType=barStacked' + f' --prop title="Quarterly Headcount by Dept"' + f' --prop series1=Q1:30,18,25,12' + f' --prop series2=Q2:35,20,28,14' + f' --prop series3=Q3:38,22,30,16' + f' --prop categories=Engineering,Marketing,Sales,Support' + f' --prop colors=2E75B6,70AD47,FFC000' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop overlap=0' + f' --prop series.outline=FFFFFF-0.5') + +# -------------------------------------------------------------------------- +# Chart 4: data= shorthand with legend=bottom +# +# officecli add charts-bar.xlsx "/1-Bar Fundamentals" --type chart \ +# --prop chartType=bar \ +# --prop title="Training Hours by Team" \ +# --prop 'data=Technical:45,38,52;Soft Skills:20,28,18;Compliance:12,15,10' \ +# --prop categories=Engineering,Sales,Support \ +# --prop colors=4472C4,ED7D31,70AD47 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop legend=bottom +# +# Features: data= shorthand (inline multi-series), legend=bottom +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Bar Fundamentals" --type chart' + f' --prop chartType=bar' + f' --prop title="Training Hours by Team"' + f' --prop "data=Technical:45,38,52;Soft Skills:20,28,18;Compliance:12,15,10"' + f' --prop categories=Engineering,Sales,Support' + f' --prop colors=4472C4,ED7D31,70AD47' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop legend=bottom') + +# ========================================================================== +# Sheet: 2-Bar Variants +# ========================================================================== +print("\n--- 2-Bar Variants ---") +cli(f'add "{FILE}" / --type sheet --prop name="2-Bar Variants"') + +# -------------------------------------------------------------------------- +# Chart 1: barStacked with tight gap width +# +# officecli add charts-bar.xlsx "/2-Bar Variants" --type chart \ +# --prop chartType=barStacked \ +# --prop title="Budget Allocation" \ +# --prop series1="Salaries:120,80,95,60" \ +# --prop series2="Operations:45,35,40,25" \ +# --prop series3="Marketing:30,50,20,15" \ +# --prop categories=Engineering,Sales,Support,HR \ +# --prop colors=1F4E79,2E75B6,9DC3E6 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop gapwidth=50 \ +# --prop legend=bottom +# +# Features: barStacked, gapwidth=50 (tight bars) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Bar Variants" --type chart' + f' --prop chartType=barStacked' + f' --prop title="Budget Allocation"' + f' --prop series1=Salaries:120,80,95,60' + f' --prop series2=Operations:45,35,40,25' + f' --prop series3=Marketing:30,50,20,15' + f' --prop categories=Engineering,Sales,Support,HR' + f' --prop colors=1F4E79,2E75B6,9DC3E6' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop gapwidth=50' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 2: barPercentStacked with axis number format and reference line +# +# officecli add charts-bar.xlsx "/2-Bar Variants" --type chart \ +# --prop chartType=barPercentStacked \ +# --prop title="Task Completion Ratio" \ +# --prop series1="Done:75,60,90,45,80" \ +# --prop series2="In Progress:15,25,5,30,12" \ +# --prop series3="Blocked:10,15,5,25,8" \ +# --prop categories=Backend,Frontend,QA,Design,DevOps \ +# --prop colors=70AD47,FFC000,C00000 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop axisNumFmt=0% \ +# --prop referenceLine=0.5:FF0000:Target:dash \ +# --prop legend=bottom +# +# Features: barPercentStacked, axisNumFmt=0%, referenceLine with label and dash +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Bar Variants" --type chart' + f' --prop chartType=barPercentStacked' + f' --prop title="Task Completion Ratio"' + f' --prop series1=Done:75,60,90,45,80' + f' --prop series2="In Progress:15,25,5,30,12"' + f' --prop series3=Blocked:10,15,5,25,8' + f' --prop categories=Backend,Frontend,QA,Design,DevOps' + f' --prop colors=70AD47,FFC000,C00000' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop axisNumFmt=0%' + f' --prop referenceLine=0.5:FF0000:Target:dash' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: bar3d with perspective and style +# +# officecli add charts-bar.xlsx "/2-Bar Variants" --type chart \ +# --prop chartType=bar3d \ +# --prop title="3D Revenue by Region" \ +# --prop series1="Revenue:340,280,310,195" \ +# --prop categories=North,South,East,West \ +# --prop colors=4472C4,ED7D31,70AD47,FFC000 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop view3d=10,30,20 \ +# --prop style=3 \ +# --prop legend=right +# +# Features: bar3d, view3d (rotX,rotY,perspective), style=3 +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Bar Variants" --type chart' + f' --prop chartType=bar3d' + f' --prop title="3D Revenue by Region"' + f' --prop series1=Revenue:340,280,310,195' + f' --prop categories=North,South,East,West' + f' --prop colors=4472C4,ED7D31,70AD47,FFC000' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop view3d=10,30,20' + f' --prop style=3' + f' --prop legend=right') + +# -------------------------------------------------------------------------- +# Chart 4: bar3d with cylinder shape +# +# officecli add charts-bar.xlsx "/2-Bar Variants" --type chart \ +# --prop chartType=bar3d \ +# --prop title="Cylinder — Project Milestones" \ +# --prop series1="Completed:8,12,6,10,15" \ +# --prop series2="Remaining:4,3,6,5,2" \ +# --prop categories=Alpha,Beta,Gamma,Delta,Epsilon \ +# --prop colors=2E75B6,BDD7EE \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop shape=cylinder \ +# --prop gapwidth=60 \ +# --prop legend=bottom +# +# Features: bar3d shape=cylinder, multi-series 3D bars +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Bar Variants" --type chart' + f' --prop chartType=bar3d' + f' --prop title="Cylinder — Project Milestones"' + f' --prop series1=Completed:8,12,6,10,15' + f' --prop series2=Remaining:4,3,6,5,2' + f' --prop categories=Alpha,Beta,Gamma,Delta,Epsilon' + f' --prop colors=2E75B6,BDD7EE' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop shape=cylinder' + f' --prop gapwidth=60' + f' --prop legend=bottom') + +# ========================================================================== +# Sheet: 3-Bar Styling +# ========================================================================== +print("\n--- 3-Bar Styling ---") +cli(f'add "{FILE}" / --type sheet --prop name="3-Bar Styling"') + +# -------------------------------------------------------------------------- +# Chart 1: Title styling (font, size, color, bold) +# +# officecli add charts-bar.xlsx "/3-Bar Styling" --type chart \ +# --prop chartType=bar \ +# --prop title="Styled Title Demo" \ +# --prop series1="Score:88,76,92,65,84" \ +# --prop categories=Dept A,Dept B,Dept C,Dept D,Dept E \ +# --prop colors=4472C4 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop title.font=Georgia --prop title.size=16 \ +# --prop title.color=1F4E79 --prop title.bold=true \ +# --prop gapwidth=100 +# +# Features: title.font, title.size, title.color, title.bold +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Bar Styling" --type chart' + f' --prop chartType=bar' + f' --prop title="Styled Title Demo"' + f' --prop series1=Score:88,76,92,65,84' + f' --prop categories=Dept A,Dept B,Dept C,Dept D,Dept E' + f' --prop colors=4472C4' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop title.font=Georgia --prop title.size=16' + f' --prop title.color=1F4E79 --prop title.bold=true' + f' --prop gapwidth=100') + +# -------------------------------------------------------------------------- +# Chart 2: Series shadow and outline effects +# +# officecli add charts-bar.xlsx "/3-Bar Styling" --type chart \ +# --prop chartType=bar \ +# --prop title="Shadow & Outline" \ +# --prop series1="2024:165,142,180,128" \ +# --prop series2="2025:185,158,195,140" \ +# --prop categories=Engineering,Marketing,Sales,Support \ +# --prop colors=2E75B6,ED7D31 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop series.shadow=000000-4-315-2-30 \ +# --prop series.outline=1F4E79-1 \ +# --prop legend=bottom +# +# Features: series.shadow (color-blur-angle-dist-opacity), series.outline +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Bar Styling" --type chart' + f' --prop chartType=bar' + f' --prop title="Shadow & Outline"' + f' --prop series1=2024:165,142,180,128' + f' --prop series2=2025:185,158,195,140' + f' --prop categories=Engineering,Marketing,Sales,Support' + f' --prop colors=2E75B6,ED7D31' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop series.shadow=000000-4-315-2-30' + f' --prop series.outline=1F4E79-1' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: Per-series gradients +# +# officecli add charts-bar.xlsx "/3-Bar Styling" --type chart \ +# --prop chartType=bar \ +# --prop title="Gradient Bars" \ +# --prop series1="Revenue:320,275,410,190,245" \ +# --prop categories=North,South,East,West,Central \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop 'gradients=1F4E79-5B9BD5:0;C55A11-F4B183:0;548235-A9D18E:0;7F6000-FFD966:0;843C0B-DDA15E:0' \ +# --prop dataLabels=outsideEnd \ +# --prop labelFont=9:333333:true +# +# Features: gradients (per-bar gradient fills, angle=0 for horizontal), +# labelFont (size:color:bold) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Bar Styling" --type chart' + f' --prop chartType=bar' + f' --prop title="Gradient Bars"' + f' --prop series1=Revenue:320,275,410,190,245' + f' --prop categories=North,South,East,West,Central' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop "gradients=1F4E79-5B9BD5:0;C55A11-F4B183:0;548235-A9D18E:0;7F6000-FFD966:0;843C0B-DDA15E:0"' + f' --prop dataLabels=outsideEnd' + f' --prop labelFont=9:333333:true') + +# -------------------------------------------------------------------------- +# Chart 4: Plot fill gradient, chart fill, transparency, rounded corners +# +# officecli add charts-bar.xlsx "/3-Bar Styling" --type chart \ +# --prop chartType=bar \ +# --prop title="Styled Background" \ +# --prop dataRange=Sheet1!A1:C9 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop colors=5B9BD5,ED7D31 \ +# --prop plotFill=F0F4F8-D6E4F0:90 \ +# --prop chartFill=FFFFFF \ +# --prop transparency=20 \ +# --prop roundedCorners=true \ +# --prop legend=right +# +# Features: plotFill gradient, chartFill, transparency, roundedCorners +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Bar Styling" --type chart' + f' --prop chartType=bar' + f' --prop title="Styled Background"' + f' --prop dataRange=Sheet1!A1:C9' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop colors=5B9BD5,ED7D31' + f' --prop plotFill=F0F4F8-D6E4F0:90' + f' --prop chartFill=FFFFFF' + f' --prop transparency=20' + f' --prop roundedCorners=true' + f' --prop legend=right') + +# ========================================================================== +# Sheet: 4-Axis & Labels +# ========================================================================== +print("\n--- 4-Axis & Labels ---") +cli(f'add "{FILE}" / --type sheet --prop name="4-Axis & Labels"') + +# -------------------------------------------------------------------------- +# Chart 1: Custom axis min/max, majorUnit, and gridlines styling +# +# officecli add charts-bar.xlsx "/4-Axis & Labels" --type chart \ +# --prop chartType=bar \ +# --prop title="Axis Scale (50–250)" \ +# --prop dataRange=Sheet1!A1:B9 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop axisMin=50 --prop axisMax=250 --prop majorUnit=50 \ +# --prop gridlines=D0D0D0:0.5:solid \ +# --prop minorGridlines=EEEEEE:0.3:dot \ +# --prop axisLine=C00000:1.5:solid \ +# --prop catAxisLine=2E75B6:1.5:solid +# +# Features: axisMin, axisMax, majorUnit, gridlines styling, +# minorGridlines, axisLine, catAxisLine +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Axis & Labels" --type chart' + f' --prop chartType=bar' + f' --prop title="Axis Scale (50–250)"' + f' --prop dataRange=Sheet1!A1:B9' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop axisMin=50 --prop axisMax=250 --prop majorUnit=50' + f' --prop gridlines=D0D0D0:0.5:solid' + f' --prop minorGridlines=EEEEEE:0.3:dot' + f' --prop axisLine=C00000:1.5:solid' + f' --prop catAxisLine=2E75B6:1.5:solid') + +# -------------------------------------------------------------------------- +# Chart 2: Log scale, axis reverse, and display units +# +# officecli add charts-bar.xlsx "/4-Axis & Labels" --type chart \ +# --prop chartType=bar \ +# --prop title="Log Scale & Reverse" \ +# --prop series1="Users:10,100,1000,5000,25000,100000" \ +# --prop categories=Tier 1,Tier 2,Tier 3,Tier 4,Tier 5,Tier 6 \ +# --prop colors=2E75B6 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop logBase=10 \ +# --prop axisReverse=true \ +# --prop dispUnits=thousands \ +# --prop gridlines=E0E0E0:0.5:dash +# +# Features: logBase=10, axisReverse=true, dispUnits=thousands +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Axis & Labels" --type chart' + f' --prop chartType=bar' + f' --prop title="Log Scale & Reverse"' + f' --prop "series1=Users:10,100,1000,5000,25000,100000"' + f' --prop "categories=Tier 1,Tier 2,Tier 3,Tier 4,Tier 5,Tier 6"' + f' --prop colors=2E75B6' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop logBase=10' + f' --prop axisReverse=true' + f' --prop dispUnits=thousands' + f' --prop gridlines=E0E0E0:0.5:dash') + +# -------------------------------------------------------------------------- +# Chart 3: Data labels with labelFont, numFmt, separator +# +# officecli add charts-bar.xlsx "/4-Axis & Labels" --type chart \ +# --prop chartType=bar \ +# --prop title="Labeled Metrics" \ +# --prop series1="FY2025:148,92,215,178,125" \ +# --prop categories=Revenue,Costs,Gross,EBITDA,Net Income \ +# --prop colors=4472C4 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop dataLabels=true --prop labelPos=outsideEnd \ +# --prop labelFont=10:1F4E79:true \ +# --prop dataLabels.numFmt=#,##0 \ +# --prop "dataLabels.separator=: " +# +# Features: dataLabels, labelFont, dataLabels.numFmt, dataLabels.separator +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Axis & Labels" --type chart' + f' --prop chartType=bar' + f' --prop title="Labeled Metrics"' + f' --prop series1=FY2025:148,92,215,178,125' + f' --prop categories=Revenue,Costs,Gross,EBITDA,Net Income' + f' --prop colors=4472C4' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop dataLabels=outsideEnd' + f' --prop labelFont=10:1F4E79:true' + f' --prop dataLabels.numFmt=#,##0' + f' --prop "dataLabels.separator=: "') + +# -------------------------------------------------------------------------- +# Chart 4: Per-point label delete/text and per-point color +# +# officecli add charts-bar.xlsx "/4-Axis & Labels" --type chart \ +# --prop chartType=bar \ +# --prop title="Highlight Winner" \ +# --prop series1="Score:72,85,68,95,78" \ +# --prop categories=Team A,Team B,Team C,Team D,Team E \ +# --prop colors=9DC3E6 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop dataLabels=true --prop labelPos=outsideEnd \ +# --prop dataLabel1.delete=true --prop dataLabel3.delete=true \ +# --prop dataLabel5.delete=true \ +# --prop dataLabel4.text="Winner!" \ +# --prop point4.color=C00000 \ +# --prop point2.color=2E75B6 \ +# --prop gapwidth=70 +# +# Features: dataLabel{N}.delete, dataLabel{N}.text, point{N}.color +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Axis & Labels" --type chart' + f' --prop chartType=bar' + f' --prop title="Highlight Winner"' + f' --prop series1=Score:72,85,68,95,78' + f' --prop categories=Team A,Team B,Team C,Team D,Team E' + f' --prop colors=9DC3E6' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop dataLabels=true --prop labelPos=outsideEnd' + f' --prop dataLabel1.delete=true --prop dataLabel3.delete=true' + f' --prop dataLabel5.delete=true' + f' --prop dataLabel4.text="Winner!"' + f' --prop point4.color=C00000' + f' --prop point2.color=2E75B6' + f' --prop gapwidth=70') + +# ========================================================================== +# Sheet: 5-Legend & Layout +# ========================================================================== +print("\n--- 5-Legend & Layout ---") +cli(f'add "{FILE}" / --type sheet --prop name="5-Legend & Layout"') + +# -------------------------------------------------------------------------- +# Chart 1: Legend positions (right and bottom) +# +# officecli add charts-bar.xlsx "/5-Legend & Layout" --type chart \ +# --prop chartType=bar \ +# --prop title="Legend: Right" \ +# --prop dataRange=Sheet1!A1:E9 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop colors=4472C4,ED7D31,70AD47,FFC000 \ +# --prop legend=right +# +# Features: legend=right (4-series bar with legend on right) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Legend & Layout" --type chart' + f' --prop chartType=bar' + f' --prop title="Legend: Right"' + f' --prop dataRange=Sheet1!A1:E9' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop colors=4472C4,ED7D31,70AD47,FFC000' + f' --prop legend=right') + +# -------------------------------------------------------------------------- +# Chart 2: Legend font styling and overlay +# +# officecli add charts-bar.xlsx "/5-Legend & Layout" --type chart \ +# --prop chartType=bar \ +# --prop title="Legend: Font & Overlay" \ +# --prop dataRange=Sheet1!A1:E9 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop colors=1F4E79,2E75B6,5B9BD5,9DC3E6 \ +# --prop legend=top \ +# --prop legend.overlay=true \ +# --prop legendfont=10:1F4E79:Calibri +# +# Features: legendfont (size:color:fontname), legend.overlay=true +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Legend & Layout" --type chart' + f' --prop chartType=bar' + f' --prop title="Legend: Font & Overlay"' + f' --prop dataRange=Sheet1!A1:E9' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop colors=1F4E79,2E75B6,5B9BD5,9DC3E6' + f' --prop legend=top' + f' --prop legend.overlay=true' + f' --prop legendfont=10:1F4E79:Calibri') + +# -------------------------------------------------------------------------- +# Chart 3: Manual layout — plotArea.x/y/w/h, title.x/y +# +# officecli add charts-bar.xlsx "/5-Legend & Layout" --type chart \ +# --prop chartType=bar \ +# --prop title="Manual Layout" \ +# --prop dataRange=Sheet1!A1:C9 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop colors=2E75B6,70AD47 \ +# --prop plotArea.x=0.25 --prop plotArea.y=0.15 \ +# --prop plotArea.w=0.70 --prop plotArea.h=0.60 \ +# --prop title.x=0.20 --prop title.y=0.02 \ +# --prop legend.x=0.25 --prop legend.y=0.82 \ +# --prop legend.w=0.50 --prop legend.h=0.10 \ +# --prop title.font=Arial --prop title.size=13 \ +# --prop title.bold=true +# +# Features: plotArea.x/y/w/h, title.x/y, legend.x/y/w/h (manual layout) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Legend & Layout" --type chart' + f' --prop chartType=bar' + f' --prop title="Manual Layout"' + f' --prop dataRange=Sheet1!A1:C9' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop colors=2E75B6,70AD47' + f' --prop plotArea.x=0.25 --prop plotArea.y=0.15' + f' --prop plotArea.w=0.70 --prop plotArea.h=0.60' + f' --prop title.x=0.20 --prop title.y=0.02' + f' --prop legend.x=0.25 --prop legend.y=0.82' + f' --prop legend.w=0.50 --prop legend.h=0.10' + f' --prop title.font=Arial --prop title.size=13' + f' --prop title.bold=true') + +# -------------------------------------------------------------------------- +# Chart 4: Secondary axis with chart/plot area borders +# +# officecli add charts-bar.xlsx "/5-Legend & Layout" --type chart \ +# --prop chartType=bar \ +# --prop title="Dual Axis: Revenue vs Margin" \ +# --prop series1="Revenue:340,280,410,195,310" \ +# --prop series2="Margin %:22,18,28,15,25" \ +# --prop categories=North,South,East,West,Central \ +# --prop colors=2E75B6,C00000 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop secondaryAxis=2 \ +# --prop chartArea.border=D0D0D0:1:solid \ +# --prop plotArea.border=E0E0E0:0.5:dot \ +# --prop legend=bottom +# +# Features: secondaryAxis=2, chartArea.border, plotArea.border +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Legend & Layout" --type chart' + f' --prop chartType=bar' + f' --prop title="Dual Axis: Revenue vs Margin"' + f' --prop "series1=Revenue:340,280,410,195,310"' + f' --prop "series2=Margin %:22,18,28,15,25"' + f' --prop categories=North,South,East,West,Central' + f' --prop colors=2E75B6,C00000' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop secondaryAxis=2' + f' --prop chartArea.border=D0D0D0:1:solid' + f' --prop plotArea.border=E0E0E0:0.5:dot' + f' --prop legend=bottom') + +# ========================================================================== +# Sheet: 6-Advanced +# ========================================================================== +print("\n--- 6-Advanced ---") +cli(f'add "{FILE}" / --type sheet --prop name="6-Advanced"') + +# -------------------------------------------------------------------------- +# Chart 1: Reference line with label +# +# officecli add charts-bar.xlsx "/6-Advanced" --type chart \ +# --prop chartType=bar \ +# --prop title="vs Company Average" \ +# --prop series1="Score:82,74,91,68,87,72" \ +# --prop categories=Engineering,Marketing,Sales,Support,Finance,HR \ +# --prop colors=4472C4 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop referenceLine=79:FF0000:Average:dash \ +# --prop gapwidth=80 \ +# --prop gridlines=E0E0E0:0.5:solid +# +# Features: referenceLine (value:color:label:dash style) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/6-Advanced" --type chart' + f' --prop chartType=bar' + f' --prop title="vs Company Average"' + f' --prop series1=Score:82,74,91,68,87,72' + f' --prop categories=Engineering,Marketing,Sales,Support,Finance,HR' + f' --prop colors=4472C4' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop referenceLine=79:FF0000:Average:dash' + f' --prop gapwidth=80' + f' --prop gridlines=E0E0E0:0.5:solid') + +# -------------------------------------------------------------------------- +# Chart 2: Conditional coloring (colorRule) +# +# officecli add charts-bar.xlsx "/6-Advanced" --type chart \ +# --prop chartType=bar \ +# --prop title="Profit/Loss by Division" \ +# --prop series1="P&L:120,85,-45,160,-80,95,-20,140" \ +# --prop categories=Div A,Div B,Div C,Div D,Div E,Div F,Div G,Div H \ +# --prop colors=2E75B6 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop colorRule=0:C00000:70AD47 \ +# --prop referenceLine=0:888888:1:solid \ +# --prop dataLabels=outsideEnd \ +# --prop labelFont=9:333333:false +# +# Features: colorRule (threshold:belowColor:aboveColor), +# referenceLine=0 (zero baseline) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/6-Advanced" --type chart' + f' --prop chartType=bar' + f' --prop title="Profit/Loss by Division"' + f' --prop "series1=P&L:120,85,-45,160,-80,95,-20,140"' + f' --prop categories=Div A,Div B,Div C,Div D,Div E,Div F,Div G,Div H' + f' --prop colors=2E75B6' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop colorRule=0:C00000:70AD47' + f' --prop referenceLine=0:888888:1:solid' + f' --prop dataLabels=outsideEnd' + f' --prop labelFont=9:333333:false') + +# -------------------------------------------------------------------------- +# Chart 3: Title glow, title shadow, series shadow +# +# officecli add charts-bar.xlsx "/6-Advanced" --type chart \ +# --prop chartType=bar \ +# --prop title="Glow & Shadow Effects" \ +# --prop series1="East:185,195,210,228" \ +# --prop series2="West:140,152,165,178" \ +# --prop categories=Q1,Q2,Q3,Q4 \ +# --prop colors=4472C4,ED7D31 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop title.glow=4472C4-8-60 \ +# --prop title.shadow=000000-3-315-2-40 \ +# --prop title.font=Calibri --prop title.size=16 \ +# --prop title.bold=true --prop title.color=1F4E79 \ +# --prop series.shadow=000000-3-315-1-30 \ +# --prop plotFill=F0F4F8 --prop chartFill=FFFFFF \ +# --prop legend=bottom +# +# Features: title.glow (color-radius-opacity), title.shadow, +# series.shadow on bar charts +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/6-Advanced" --type chart' + f' --prop chartType=bar' + f' --prop title="Glow & Shadow Effects"' + f' --prop series1=East:185,195,210,228' + f' --prop series2=West:140,152,165,178' + f' --prop categories=Q1,Q2,Q3,Q4' + f' --prop colors=4472C4,ED7D31' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop title.glow=4472C4-8-60' + f' --prop title.shadow=000000-3-315-2-40' + f' --prop title.font=Calibri --prop title.size=16' + f' --prop title.bold=true --prop title.color=1F4E79' + f' --prop series.shadow=000000-3-315-1-30' + f' --prop plotFill=F0F4F8 --prop chartFill=FFFFFF' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 4: Error bars and data table +# +# officecli add charts-bar.xlsx "/6-Advanced" --type chart \ +# --prop chartType=bar \ +# --prop title="With Error Bars & Data Table" \ +# --prop dataRange=Sheet1!A1:E9 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop colors=2E75B6,ED7D31,70AD47,FFC000 \ +# --prop errBars=percent:10 \ +# --prop dataTable=true \ +# --prop legend=none \ +# --prop plotFill=FAFAFA +# +# Features: errBars=percent:10, dataTable=true, legend=none +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/6-Advanced" --type chart' + f' --prop chartType=bar' + f' --prop title="With Error Bars & Data Table"' + f' --prop dataRange=Sheet1!A1:E9' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop colors=2E75B6,ED7D31,70AD47,FFC000' + f' --prop errBars=percent:10' + f' --prop dataTable=true' + f' --prop legend=none' + f' --prop plotFill=FAFAFA') + +print(f"\nDone! Generated: {FILE}") +print(" 7 sheets (Sheet1 data + 6 chart sheets, 24 charts total)") diff --git a/examples/excel/charts-bar.xlsx b/examples/excel/charts-bar.xlsx new file mode 100644 index 000000000..1332216b6 Binary files /dev/null and b/examples/excel/charts-bar.xlsx differ diff --git a/examples/excel/charts-basic.md b/examples/excel/charts-basic.md new file mode 100644 index 000000000..513d8a49f --- /dev/null +++ b/examples/excel/charts-basic.md @@ -0,0 +1,267 @@ +# Basic Charts Showcase + +This demo consists of three files that work together: + +- **charts-basic.py** — Python script that calls `officecli` commands to generate the workbook. Each chart command is shown as a copyable shell command in the comments, then executed by the script. +- **charts-basic.xlsx** — The generated workbook with 8 sheets (1 data + 7 chart sheets, 28 charts total). Open in Excel to see the rendered charts. +- **charts-basic.md** — This file. Maps each sheet to the features it demonstrates. + +## Regenerate + +```bash +cd examples/excel +python3 charts-basic.py +# → charts-basic.xlsx +``` + +## Source Data + +**Sheet1**: 12 months of regional sales data (East, South, North, West) used by all charts. + +## Chart Sheets + +### Sheet: 1-Column Charts + +Four column chart variants demonstrating the column family. + +```bash +# Basic clustered column with axis titles and axis font +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop title="Regional Sales" \ + --prop dataRange=Sheet1!A1:E13 \ + --prop catTitle=Month --prop axisTitle=Sales \ + --prop axisfont=9:58626E:Arial \ + --prop gridlines=D9D9D9:0.5:dot + +# Stacked column with custom colors, data labels, gap control, series outline +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=columnStacked \ + --prop colors=2E75B6,70AD47,FFC000,C00000 \ + --prop dataLabels=true --prop labelPos=center \ + --prop gapwidth=60 \ + --prop series.outline=FFFFFF-0.5 + +# 100% stacked with legend positioning and plot fill +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=columnPercentStacked \ + --prop legend=bottom --prop legendfont=9:8B949E \ + --prop plotFill=F5F5F5 + +# 3D column with perspective and title styling +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column3d \ + --prop view3d=15,20,30 \ + --prop title.font=Calibri --prop title.size=16 \ + --prop title.color=1F4E79 --prop title.bold=true +``` + +**Features:** `column`, `columnStacked`, `columnPercentStacked`, `column3d`, `dataRange`, `catTitle`, `axisTitle`, `axisfont`, `gridlines`, `colors`, `dataLabels`, `labelPos`, `gapwidth`, `series.outline`, `legend`, `legendfont`, `plotFill`, `view3d`, `title.font/size/color/bold` + +### Sheet: 2-Bar Charts + +Four horizontal bar chart variants. + +```bash +# Horizontal bar with inline data and gap control +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar \ + --prop 'data=East:198;South:158;North:142;West:180' \ + --prop gapwidth=80 \ + --prop dataLabels=true --prop labelPos=outsideEnd + +# Stacked bar with named series and overlap +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=barStacked \ + --prop series1=H1:663,598,528,661 \ + --prop series2=H2:833,718,669,868 \ + --prop gapwidth=50 --prop overlap=0 + +# 100% stacked bar with reference line and axis lines +# Note: value axis of a barPercentStacked chart is 0-1 (= 0%-100%), so a 50% line = 0.5 +# referenceLine forms: value | value:color | value:color:label | value:color:width:dash +# | value:color:label:dash | value:color:width:dash:label +# Width is in points (default 1.5pt). e.g. 0.5:FF0000:2:dash draws a 2pt dashed line. +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=barPercentStacked \ + --prop referenceLine=0.5:FF0000:Target:dash \ + --prop axisLine=333333:1:solid \ + --prop catAxisLine=333333:1:solid + +# 3D bar with chart area fill and preset style +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bar3d \ + --prop view3d=10,30,20 \ + --prop chartFill=F2F2F2 \ + --prop style=3 +``` + +**Features:** `bar`, `barStacked`, `barPercentStacked`, `bar3d`, inline `data`, named `series`, `gapwidth`, `overlap`, `labelPos=outsideEnd`, `referenceLine`, `axisLine`, `catAxisLine`, `chartFill`, `style` + +### Sheet: 3-Line Charts + +Four line chart variants with markers, smoothing, and data tables. + +```bash +# Line with cell-range series (dotted syntax) and markers +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop series1.name=East \ + --prop series1.values=Sheet1!B2:B13 \ + --prop series1.categories=Sheet1!A2:A13 \ + --prop showMarkers=true --prop marker=circle:6:2E75B6 \ + --prop gridlines=D9D9D9:0.5:dot \ + --prop minorGridlines=EEEEEE:0.3:dot + +# Smooth line with series shadow +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop smooth=true --prop lineWidth=2.5 \ + --prop gridlines=none \ + --prop series.shadow=000000-4-315-2-40 + +# Stacked line with tick marks +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=lineStacked \ + --prop majorTickMark=outside --prop tickLabelPos=low + +# Dashed line with data table and hidden legend +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop lineDash=dash --prop lineWidth=1.5 \ + --prop dataTable=true --prop legend=none +``` + +**Features:** `series1.name/values/categories` (cell range), `showMarkers`, `marker` (style:size:color), `smooth`, `lineWidth`, `lineDash`, `gridlines`, `minorGridlines`, `series.shadow`, `lineStacked`, `majorTickMark`, `tickLabelPos`, `dataTable`, `legend=none` + +### Sheet: 4-Area Charts + +Four area chart variants with transparency and gradients. + +```bash +# Area with transparency and gradient +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=area \ + --prop transparency=40 \ + --prop gradient=4472C4-BDD7EE:90 + +# Stacked area with plot fill and rounded corners +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=areaStacked \ + --prop plotFill=F5F5F5 --prop roundedCorners=true + +# 100% stacked area with axis visibility control +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=areaPercentStacked \ + --prop axisVisible=true --prop axisLine=999999:0.5:solid + +# 3D area with perspective +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=area3d \ + --prop view3d=20,25,15 +``` + +**Features:** `area`, `areaStacked`, `areaPercentStacked`, `area3d`, `transparency`, `gradient`, `plotFill`, `roundedCorners`, `axisVisible`, `axisLine` + +### Sheet: 5-Styling + +Demonstrates styling and formatting properties on various charts. + +```bash +# Fully styled chart: title effects, legend, axis fonts, series effects +officecli add data.xlsx /Sheet --type chart \ + --prop title.font=Georgia --prop title.size=18 \ + --prop title.color=1F4E79 --prop title.bold=true \ + --prop title.shadow=000000-3-315-2-30 \ + --prop legendfont=10:444444:Helvetica --prop legend=right \ + --prop axisfont=9:58626E:Arial \ + --prop series.outline=FFFFFF-0.5 \ + --prop series.shadow=000000-3-315-2-25 \ + --prop roundedCorners=true --prop referenceLine=160:FF0000:1:dash + +# Dual Y-axis (secondary axis) +officecli add data.xlsx /Sheet --type chart \ + --prop secondaryAxis=2 + +# Per-point coloring and negative value inversion +officecli add data.xlsx /Sheet --type chart \ + --prop point1.color=70AD47 --prop point3.color=FF0000 \ + --prop invertIfNeg=true + +# Gradient plot fill and custom data label text +officecli add data.xlsx /Sheet --type chart \ + --prop plotFill=E8F0FE-FFFFFF:90 \ + --prop marker=diamond:8:4472C4 \ + --prop dataLabels.numFmt=#,##0 \ + --prop dataLabel3.text=Peak! +``` + +**Features:** `title.shadow`, `secondaryAxis`, `point{N}.color`, `invertIfNeg`, `plotFill` gradient, `dataLabels.numFmt`, `dataLabel{N}.text` + +### Sheet: 6-Layout + +Manual positioning and axis control properties. + +```bash +# Manual layout of plot area, title, legend +officecli add data.xlsx /Sheet --type chart \ + --prop plotArea.x=0.15 --prop plotArea.y=0.15 \ + --prop plotArea.w=0.7 --prop plotArea.h=0.7 \ + --prop title.x=0.3 --prop title.y=0.01 \ + --prop legend.x=0.02 --prop legend.y=0.4 \ + --prop legend.overlay=true + +# Logarithmic scale, reversed axis, display units +officecli add data.xlsx /Sheet --type chart \ + --prop logBase=10 \ + --prop axisOrientation=maxMin \ + --prop dispUnits=thousands + +# Label font, separator, per-label hide +officecli add data.xlsx /Sheet --type chart \ + --prop labelFont=11:2E75B6:true \ + --prop "dataLabels.separator=: " \ + --prop dataLabel2.text=Best! \ + --prop dataLabel3.delete=true + +# Error bars, minor ticks, opacity +officecli add data.xlsx /Sheet --type chart \ + --prop errBars=percentage \ + --prop majorTickMark=outside --prop minorTickMark=inside \ + --prop opacity=80 +``` + +**Features:** `plotArea.x/y/w/h`, `title.x/y`, `legend.x/y`, `legend.overlay`, `logBase`, `axisOrientation`, `dispUnits`, `labelFont`, `dataLabels.separator`, `dataLabel{N}.delete`, `errBars`, `minorTickMark`, `opacity` + +### Sheet: 7-Effects + +Visual effects: gradients, conditional colors, glow, presets. + +```bash +# Per-series gradients +officecli add data.xlsx /Sheet --type chart \ + --prop 'gradients=4472C4-BDD7EE:90;ED7D31-FBE5D6:90' + +# Area fill gradient and title glow +officecli add data.xlsx /Sheet --type chart \ + --prop areafill=4472C4-BDD7EE:90 \ + --prop title.glow=4472C4-8-60 + +# Conditional coloring (below/above threshold) +officecli add data.xlsx /Sheet --type chart \ + --prop colorRule=60:FF0000:70AD47 + +# Preset style and leader lines +officecli add data.xlsx /Sheet --type chart \ + --prop style=26 \ + --prop dataLabels.showLeaderLines=true +``` + +**Features:** `gradients`, `areafill`, `title.glow`, `colorRule`, `style`, `dataLabels.showLeaderLines` + +## Inspect the Generated File + +```bash +officecli query charts-basic.xlsx chart +officecli get charts-basic.xlsx "/1-Column Charts/chart[1]" +``` diff --git a/examples/excel/charts-basic.py b/examples/excel/charts-basic.py new file mode 100644 index 000000000..4a8112b53 --- /dev/null +++ b/examples/excel/charts-basic.py @@ -0,0 +1,828 @@ +#!/usr/bin/env python3 +""" +Basic Charts Showcase — column, bar, line, and area charts with all variations. + +Generates: charts-basic.xlsx + +Each sheet demonstrates one chart family with all its variants and key properties. +See charts-basic.md for a guide to each sheet. + +Usage: + python3 charts-basic.py +""" + +import subprocess, sys, os, json, atexit + +FILE = "charts-basic.xlsx" + +def cli(cmd): + """Run: officecli """ + r = subprocess.run(f"officecli {cmd}", shell=True, capture_output=True, text=True) + out = (r.stdout or "").strip() + if out: + for line in out.split("\n"): + if line.strip(): + print(f" {line.strip()}") + if r.returncode != 0: + err = (r.stderr or "").strip() + if err and "UNSUPPORTED" not in err and "process cannot access" not in err: + print(f" ERROR: {err}") + +if os.path.exists(FILE): + os.remove(FILE) + +cli(f'create "{FILE}"') +cli(f'open "{FILE}"') +atexit.register(lambda: cli(f'close "{FILE}"')) + +# ========================================================================== +# Source data — shared across all charts +# ========================================================================== +print("\n--- Populating source data ---") + +data_cmds = [] +for j, h in enumerate(["Month", "East", "South", "North", "West"]): + data_cmds.append({"command": "set", "path": f"/Sheet1/{'ABCDE'[j]}1", "props": {"text": h, "bold": "true"}}) + +months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] +east = [120, 135, 148, 162, 155, 178, 195, 210, 188, 172, 165, 198] +south = [95, 108, 115, 128, 142, 155, 168, 175, 160, 148, 135, 158] +north = [88, 92, 105, 118, 125, 138, 145, 152, 140, 130, 122, 142] +west = [110, 118, 130, 145, 138, 162, 175, 190, 170, 155, 148, 180] + +for i in range(12): + r = i + 2 + for j, val in enumerate([months[i], east[i], south[i], north[i], west[i]]): + data_cmds.append({"command": "set", "path": f"/Sheet1/{'ABCDE'[j]}{r}", "props": {"text": str(val)}}) + +cli(f'batch "{FILE}" --force --commands \'{json.dumps(data_cmds)}\'') + +# ========================================================================== +# Sheet: 1-Column Charts +# ========================================================================== +print("\n--- 1-Column Charts ---") +cli(f'add "{FILE}" / --type sheet --prop name="1-Column Charts"') + +# -------------------------------------------------------------------------- +# Chart 1: Basic clustered column from cell range with axis titles +# +# officecli add charts-basic.xlsx "/1-Column Charts" --type chart \ +# --prop chartType=column \ +# --prop title="Regional Sales by Month" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop catTitle=Month --prop axisTitle=Sales \ +# --prop axisfont=9:58626E:Arial \ +# --prop gridlines=D9D9D9:0.5:dot +# +# Features: chartType=column, dataRange, catTitle, axisTitle, axisfont, gridlines +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Column Charts" --type chart' + f' --prop chartType=column' + f' --prop title="Regional Sales by Month"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop catTitle=Month --prop axisTitle=Sales' + f' --prop axisfont=9:58626E:Arial' + f' --prop gridlines=D9D9D9:0.5:dot') + +# -------------------------------------------------------------------------- +# Chart 2: Stacked column with custom colors, data labels, and gap control +# +# officecli add charts-basic.xlsx "/1-Column Charts" --type chart \ +# --prop chartType=columnStacked \ +# --prop title="Stacked Regional Sales" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop colors=2E75B6,70AD47,FFC000,C00000 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop dataLabels=true --prop labelPos=center \ +# --prop gapwidth=60 \ +# --prop series.outline=FFFFFF-0.5 +# +# Features: columnStacked, colors, dataLabels, labelPos, gapwidth, series.outline +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Column Charts" --type chart' + f' --prop chartType=columnStacked' + f' --prop title="Stacked Regional Sales"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop colors=2E75B6,70AD47,FFC000,C00000' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop dataLabels=true --prop labelPos=center' + f' --prop gapwidth=60' + f' --prop series.outline=FFFFFF-0.5') + +# -------------------------------------------------------------------------- +# Chart 3: 100% stacked column with legend position and plotFill +# +# officecli add charts-basic.xlsx "/1-Column Charts" --type chart \ +# --prop chartType=columnPercentStacked \ +# --prop title="Market Share by Month" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop legend=bottom \ +# --prop legendfont=9:8B949E \ +# --prop plotFill=F5F5F5 +# +# Features: columnPercentStacked, legend=bottom, legendfont, plotFill +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Column Charts" --type chart' + f' --prop chartType=columnPercentStacked' + f' --prop title="Market Share by Month"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop legend=bottom' + f' --prop legendfont=9:8B949E' + f' --prop plotFill=F5F5F5') + +# -------------------------------------------------------------------------- +# Chart 4: 3D column with perspective and title styling +# +# officecli add charts-basic.xlsx "/1-Column Charts" --type chart \ +# --prop chartType=column3d \ +# --prop title="3D Regional Sales" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop view3d=15,20,30 \ +# --prop title.font=Calibri --prop title.size=16 \ +# --prop title.color=1F4E79 --prop title.bold=true +# +# Features: column3d, view3d (rotX,rotY,perspective), title.font/size/color/bold +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Column Charts" --type chart' + f' --prop chartType=column3d' + f' --prop title="3D Regional Sales"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop view3d=15,20,30' + f' --prop title.font=Calibri --prop title.size=16' + f' --prop title.color=1F4E79 --prop title.bold=true') + +# ========================================================================== +# Sheet: 2-Bar Charts +# ========================================================================== +print("\n--- 2-Bar Charts ---") +cli(f'add "{FILE}" / --type sheet --prop name="2-Bar Charts"') + +# -------------------------------------------------------------------------- +# Chart 1: Horizontal bar with inline data and gapwidth +# +# officecli add charts-basic.xlsx "/2-Bar Charts" --type chart \ +# --prop chartType=bar \ +# --prop title="Q4 Sales by Region" \ +# --prop 'data=East:198;South:158;North:142;West:180' \ +# --prop categories=East,South,North,West \ +# --prop colors=2E75B6,70AD47,FFC000,C00000 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop gapwidth=80 \ +# --prop dataLabels=true --prop labelPos=outsideEnd +# +# Features: bar, inline data (Name:v1;Name2:v2), gapwidth, labelPos=outsideEnd +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Bar Charts" --type chart' + f' --prop chartType=bar' + f' --prop title="Q4 Sales by Region"' + f' --prop "data=East:198;South:158;North:142;West:180"' + f' --prop categories=East,South,North,West' + f' --prop colors=2E75B6,70AD47,FFC000,C00000' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop gapwidth=80' + f' --prop dataLabels=true --prop labelPos=outsideEnd') + +# -------------------------------------------------------------------------- +# Chart 2: Stacked bar with named series and overlap +# +# officecli add charts-basic.xlsx "/2-Bar Charts" --type chart \ +# --prop chartType=barStacked \ +# --prop title="H1 vs H2 Sales" \ +# --prop series1=H1:663,598,528,661 \ +# --prop series2=H2:833,718,669,868 \ +# --prop categories=East,South,North,West \ +# --prop colors=4472C4,ED7D31 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop dataLabels=true --prop labelPos=center \ +# --prop gapwidth=50 --prop overlap=0 +# +# Features: barStacked, named series (series1=Name:v1,v2), overlap +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Bar Charts" --type chart' + f' --prop chartType=barStacked' + f' --prop title="H1 vs H2 Sales"' + f' --prop series1=H1:663,598,528,661' + f' --prop series2=H2:833,718,669,868' + f' --prop categories=East,South,North,West' + f' --prop colors=4472C4,ED7D31' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop dataLabels=true --prop labelPos=center' + f' --prop gapwidth=50 --prop overlap=0') + +# -------------------------------------------------------------------------- +# Chart 3: 100% stacked bar with reference line +# +# officecli add charts-basic.xlsx "/2-Bar Charts" --type chart \ +# --prop chartType=barPercentStacked \ +# --prop title="Regional Contribution %" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop referenceLine=0.5:FF0000:Target:dash \ +# --prop axisLine=333333:1:solid \ +# --prop catAxisLine=333333:1:solid +# +# Note: on a barPercentStacked chart, the value axis is 0-1 (displayed as 0%-100%), +# so a 50% reference line must be written as 0.5 — not 50. +# referenceLine supports: value | value:color | value:color:label | value:color:width:dash +# | value:color:label:dash (legacy) | value:color:width:dash:label (canonical). +# Width is in points; default 1.5pt. +# +# Features: barPercentStacked, referenceLine, axisLine, catAxisLine +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Bar Charts" --type chart' + f' --prop chartType=barPercentStacked' + f' --prop title="Regional Contribution %"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop referenceLine=0.5:FF0000:Target:dash' + f' --prop axisLine=333333:1:solid' + f' --prop catAxisLine=333333:1:solid') + +# -------------------------------------------------------------------------- +# Chart 4: 3D bar with chart area fill and display units +# +# officecli add charts-basic.xlsx "/2-Bar Charts" --type chart \ +# --prop chartType=bar3d \ +# --prop title="3D Regional Comparison" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop view3d=10,30,20 \ +# --prop chartFill=F2F2F2 \ +# --prop style=3 +# +# Features: bar3d, chartFill (chart area background), style/styleId (preset 1-48) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Bar Charts" --type chart' + f' --prop chartType=bar3d' + f' --prop title="3D Regional Comparison"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop view3d=10,30,20' + f' --prop chartFill=F2F2F2' + f' --prop style=3') + +# ========================================================================== +# Sheet: 3-Line Charts +# ========================================================================== +print("\n--- 3-Line Charts ---") +cli(f'add "{FILE}" / --type sheet --prop name="3-Line Charts"') + +# -------------------------------------------------------------------------- +# Chart 1: Line with markers and cell-range series (dotted syntax) +# +# officecli add charts-basic.xlsx "/3-Line Charts" --type chart \ +# --prop chartType=line \ +# --prop title="East Region Trend" \ +# --prop series1.name=East \ +# --prop series1.values=Sheet1!B2:B13 \ +# --prop series1.categories=Sheet1!A2:A13 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop showMarkers=true --prop marker=circle:6:2E75B6 \ +# --prop gridlines=D9D9D9:0.5:dot \ +# --prop minorGridlines=EEEEEE:0.3:dot +# +# Features: series.name/values/categories (cell range), marker (style:size:color), +# gridlines, minorGridlines +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Line Charts" --type chart' + f' --prop chartType=line' + f' --prop title="East Region Trend"' + f' --prop series1.name=East' + f' --prop series1.values=Sheet1!B2:B13' + f' --prop series1.categories=Sheet1!A2:A13' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop showMarkers=true --prop marker=circle:6:2E75B6' + f' --prop gridlines=D9D9D9:0.5:dot' + f' --prop minorGridlines=EEEEEE:0.3:dot') + +# -------------------------------------------------------------------------- +# Chart 2: Smooth line with custom width and no gridlines +# +# officecli add charts-basic.xlsx "/3-Line Charts" --type chart \ +# --prop chartType=line \ +# --prop title="Smoothed Sales Trend" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop smooth=true --prop lineWidth=2.5 \ +# --prop colors=0070C0,00B050,FFC000,FF0000 \ +# --prop gridlines=none \ +# --prop series.shadow=000000-4-315-2-40 +# +# Features: smooth, lineWidth, gridlines=none, series.shadow (color-blur-angle-dist-opacity) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Line Charts" --type chart' + f' --prop chartType=line' + f' --prop title="Smoothed Sales Trend"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop smooth=true --prop lineWidth=2.5' + f' --prop colors=0070C0,00B050,FFC000,FF0000' + f' --prop gridlines=none' + f' --prop series.shadow=000000-4-315-2-40') + +# -------------------------------------------------------------------------- +# Chart 3: Stacked line +# +# officecli add charts-basic.xlsx "/3-Line Charts" --type chart \ +# --prop chartType=lineStacked \ +# --prop title="Cumulative Sales" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop catTitle=Month --prop axisTitle=Cumulative \ +# --prop majorTickMark=outside --prop tickLabelPos=low +# +# Features: lineStacked, majorTickMark, tickLabelPos +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Line Charts" --type chart' + f' --prop chartType=lineStacked' + f' --prop title="Cumulative Sales"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop catTitle=Month --prop axisTitle=Cumulative' + f' --prop majorTickMark=outside --prop tickLabelPos=low') + +# -------------------------------------------------------------------------- +# Chart 4: Line with dashed lines, data table, and hidden legend +# +# officecli add charts-basic.xlsx "/3-Line Charts" --type chart \ +# --prop chartType=line \ +# --prop title="Trend with Data Table" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop lineDash=dash --prop lineWidth=1.5 \ +# --prop dataTable=true \ +# --prop legend=none +# +# Features: lineDash (solid/dot/dash/dashdot/longdash), dataTable, legend=none +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Line Charts" --type chart' + f' --prop chartType=line' + f' --prop title="Trend with Data Table"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop lineDash=dash --prop lineWidth=1.5' + f' --prop dataTable=true' + f' --prop legend=none') + +# ========================================================================== +# Sheet: 4-Area Charts +# ========================================================================== +print("\n--- 4-Area Charts ---") +cli(f'add "{FILE}" / --type sheet --prop name="4-Area Charts"') + +# -------------------------------------------------------------------------- +# Chart 1: Area with transparency and gradient fill +# +# officecli add charts-basic.xlsx "/4-Area Charts" --type chart \ +# --prop chartType=area \ +# --prop title="Sales Volume" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop transparency=40 \ +# --prop gradient=4472C4-BDD7EE:90 +# +# Features: area, transparency (0-100%), gradient (color1-color2:angle) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Area Charts" --type chart' + f' --prop chartType=area' + f' --prop title="Sales Volume"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop transparency=40' + f' --prop gradient=4472C4-BDD7EE:90') + +# -------------------------------------------------------------------------- +# Chart 2: Stacked area with plotFill and rounded corners +# +# officecli add charts-basic.xlsx "/4-Area Charts" --type chart \ +# --prop chartType=areaStacked \ +# --prop title="Stacked Volume" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop plotFill=F5F5F5 \ +# --prop roundedCorners=true \ +# --prop transparency=30 +# +# Features: areaStacked, plotFill, roundedCorners +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Area Charts" --type chart' + f' --prop chartType=areaStacked' + f' --prop title="Stacked Volume"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop plotFill=F5F5F5' + f' --prop roundedCorners=true' + f' --prop transparency=30') + +# -------------------------------------------------------------------------- +# Chart 3: 100% stacked area with axis control +# +# officecli add charts-basic.xlsx "/4-Area Charts" --type chart \ +# --prop chartType=areaPercentStacked \ +# --prop title="Regional Mix %" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop transparency=20 \ +# --prop axisVisible=true \ +# --prop axisLine=999999:0.5:solid +# +# Features: areaPercentStacked, axisVisible, axisLine +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Area Charts" --type chart' + f' --prop chartType=areaPercentStacked' + f' --prop title="Regional Mix %"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop transparency=20' + f' --prop axisVisible=true' + f' --prop axisLine=999999:0.5:solid') + +# -------------------------------------------------------------------------- +# Chart 4: 3D area with perspective +# +# officecli add charts-basic.xlsx "/4-Area Charts" --type chart \ +# --prop chartType=area3d \ +# --prop title="3D Sales Volume" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop view3d=20,25,15 \ +# --prop colors=5B9BD5,A5D5A5,FFD966,F4B183 +# +# Features: area3d, view3d +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Area Charts" --type chart' + f' --prop chartType=area3d' + f' --prop title="3D Sales Volume"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop view3d=20,25,15' + f' --prop colors=5B9BD5,A5D5A5,FFD966,F4B183') + +# ========================================================================== +# Sheet: 5-Styling +# Demonstrates all styling/layout properties on a single column chart +# ========================================================================== +print("\n--- 5-Styling ---") +cli(f'add "{FILE}" / --type sheet --prop name="5-Styling"') + +# -------------------------------------------------------------------------- +# Chart 1: Fully styled column chart — title, legend, axis, series effects +# +# officecli add charts-basic.xlsx "/5-Styling" --type chart \ +# --prop chartType=column \ +# --prop title="Fully Styled Chart" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=0 --prop y=0 --prop width=14 --prop height=20 \ +# --prop title.font=Georgia --prop title.size=18 \ +# --prop title.color=1F4E79 --prop title.bold=true \ +# --prop title.shadow=000000-3-315-2-30 \ +# --prop legendfont=10:444444:Helvetica \ +# --prop legend=right \ +# --prop axisfont=9:58626E:Arial \ +# --prop catTitle=Month --prop axisTitle=Revenue \ +# --prop gridlines=CCCCCC:0.5:dot \ +# --prop plotFill=FAFAFA \ +# --prop chartFill=FFFFFF \ +# --prop series.outline=FFFFFF-0.5 \ +# --prop series.shadow=000000-3-315-2-25 \ +# --prop gapwidth=100 \ +# --prop roundedCorners=true \ +# --prop referenceLine=160:FF0000:1:dash \ +# --prop colors=4472C4,ED7D31,70AD47,FFC000 +# +# Features: title.font/size/color/bold/shadow, legendfont, axisfont, +# series.outline, series.shadow, roundedCorners, referenceLine +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Styling" --type chart' + f' --prop chartType=column' + f' --prop title="Fully Styled Chart"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=0 --prop y=0 --prop width=14 --prop height=20' + f' --prop title.font=Georgia --prop title.size=18' + f' --prop title.color=1F4E79 --prop title.bold=true' + f' --prop title.shadow=000000-3-315-2-30' + f' --prop legendfont=10:444444:Helvetica' + f' --prop legend=right' + f' --prop axisfont=9:58626E:Arial' + f' --prop catTitle=Month --prop axisTitle=Revenue' + f' --prop gridlines=CCCCCC:0.5:dot' + f' --prop plotFill=FAFAFA' + f' --prop chartFill=FFFFFF' + f' --prop series.outline=FFFFFF-0.5' + f' --prop series.shadow=000000-3-315-2-25' + f' --prop gapwidth=100' + f' --prop roundedCorners=true' + f' --prop referenceLine=160:FF0000:1:dash' + f' --prop colors=4472C4,ED7D31,70AD47,FFC000') + +# -------------------------------------------------------------------------- +# Chart 2: Column with secondary axis (dual Y-axis) +# +# officecli add charts-basic.xlsx "/5-Styling" --type chart \ +# --prop chartType=column \ +# --prop title="Sales vs Growth Rate" \ +# --prop series1=Sales:120,135,148,162 \ +# --prop series2=Growth:5.2,8.1,12.3,15.6 \ +# --prop categories=Q1,Q2,Q3,Q4 \ +# --prop x=15 --prop y=0 --prop width=10 --prop height=20 \ +# --prop secondaryAxis=2 \ +# --prop colors=4472C4,FF0000 +# +# Features: secondaryAxis (comma-separated 1-based series indices for second Y-axis) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Styling" --type chart' + f' --prop chartType=column' + f' --prop title="Sales vs Growth Rate"' + f' --prop series1=Sales:120,135,148,162' + f' --prop series2=Growth:5.2,8.1,12.3,15.6' + f' --prop categories=Q1,Q2,Q3,Q4' + f' --prop x=15 --prop y=0 --prop width=10 --prop height=20' + f' --prop secondaryAxis=2' + f' --prop colors=4472C4,FF0000') + +# -------------------------------------------------------------------------- +# Chart 3: Column with individual point colors and inverted negatives +# +# officecli add charts-basic.xlsx "/5-Styling" --type chart \ +# --prop chartType=column \ +# --prop title="Quarterly P&L" \ +# --prop series1=P&L:500,300,-200,800 \ +# --prop categories=Q1,Q2,Q3,Q4 \ +# --prop x=0 --prop y=21 --prop width=10 --prop height=18 \ +# --prop point1.color=70AD47 --prop point2.color=70AD47 \ +# --prop point3.color=FF0000 --prop point4.color=70AD47 \ +# --prop invertIfNeg=true \ +# --prop dataLabels=true --prop labelPos=outsideEnd +# +# Features: point{N}.color (per-point coloring), invertIfNeg +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Styling" --type chart' + f' --prop chartType=column' + f' --prop title="Quarterly P&L"' + f' --prop "series1=P&L:500,300,-200,800"' + f' --prop categories=Q1,Q2,Q3,Q4' + f' --prop x=0 --prop y=21 --prop width=10 --prop height=18' + f' --prop point1.color=70AD47 --prop point2.color=70AD47' + f' --prop point3.color=FF0000 --prop point4.color=70AD47' + f' --prop invertIfNeg=true' + f' --prop dataLabels=true --prop labelPos=outsideEnd') + +# -------------------------------------------------------------------------- +# Chart 4: Line with gradient plot area and custom data labels +# +# officecli add charts-basic.xlsx "/5-Styling" --type chart \ +# --prop chartType=line \ +# --prop title="Custom Labels Demo" \ +# --prop series1=Revenue:100,200,300,250 \ +# --prop categories=Q1,Q2,Q3,Q4 \ +# --prop x=11 --prop y=21 --prop width=14 --prop height=18 \ +# --prop plotFill=E8F0FE-FFFFFF:90 \ +# --prop showMarkers=true --prop marker=diamond:8:4472C4 \ +# --prop lineWidth=2 \ +# --prop dataLabels=true --prop labelPos=top \ +# --prop dataLabels.numFmt=#,##0 \ +# --prop dataLabel3.text=Peak! +# +# Features: plotFill gradient (color1-color2:angle), marker styles (diamond), +# dataLabels.numFmt, dataLabel{N}.text (custom text for one label) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Styling" --type chart' + f' --prop chartType=line' + f' --prop title="Custom Labels Demo"' + f' --prop series1=Revenue:100,200,300,250' + f' --prop categories=Q1,Q2,Q3,Q4' + f' --prop x=11 --prop y=21 --prop width=14 --prop height=18' + f' --prop plotFill=E8F0FE-FFFFFF:90' + f' --prop showMarkers=true --prop marker=diamond:8:4472C4' + f' --prop lineWidth=2' + f' --prop dataLabels=true --prop labelPos=top' + f' --prop dataLabels.numFmt=#,##0' + f' --prop dataLabel3.text=Peak!') + +# ========================================================================== +# Sheet: 6-Layout +# Manual layout of plot area, title, legend; axis orientation; log scale; +# display units; label font and separator; error bars +# ========================================================================== +print("\n--- 6-Layout ---") +cli(f'add "{FILE}" / --type sheet --prop name="6-Layout"') + +# -------------------------------------------------------------------------- +# Chart 1: Manual layout positioning of plot area, title, legend +# +# officecli add charts-basic.xlsx "/6-Layout" --type chart \ +# --prop chartType=column \ +# --prop title="Manual Layout" \ +# --prop dataRange=Sheet1!A1:C13 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop plotArea.x=0.15 --prop plotArea.y=0.15 \ +# --prop plotArea.w=0.7 --prop plotArea.h=0.7 \ +# --prop title.x=0.3 --prop title.y=0.01 \ +# --prop legend.x=0.02 --prop legend.y=0.4 \ +# --prop legend.overlay=true +# +# Features: plotArea.x/y/w/h (0-1 fraction), title.x/y, legend.x/y, legend.overlay +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/6-Layout" --type chart' + f' --prop chartType=column' + f' --prop title="Manual Layout"' + f' --prop dataRange=Sheet1!A1:C13' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop plotArea.x=0.15 --prop plotArea.y=0.15' + f' --prop plotArea.w=0.7 --prop plotArea.h=0.7' + f' --prop title.x=0.3 --prop title.y=0.01' + f' --prop legend.x=0.02 --prop legend.y=0.4' + f' --prop legend.overlay=true') + +# -------------------------------------------------------------------------- +# Chart 2: Reversed axis, log scale, display units +# +# officecli add charts-basic.xlsx "/6-Layout" --type chart \ +# --prop chartType=bar \ +# --prop title="Log Scale + Reversed Axis" \ +# --prop series1=Revenue:10,100,1000,10000 \ +# --prop categories=Startup,Small,Medium,Enterprise \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop logBase=10 \ +# --prop axisOrientation=maxMin \ +# --prop dispUnits=thousands +# +# Features: logBase (logarithmic scale), axisOrientation=maxMin (reversed), +# dispUnits (thousands/millions) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/6-Layout" --type chart' + f' --prop chartType=bar' + f' --prop title="Log Scale + Reversed Axis"' + f' --prop series1=Revenue:10,100,1000,10000' + f' --prop categories=Startup,Small,Medium,Enterprise' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop logBase=10' + f' --prop axisOrientation=maxMin' + f' --prop dispUnits=thousands') + +# -------------------------------------------------------------------------- +# Chart 3: Label font, separator, leader lines, and per-label layout +# +# officecli add charts-basic.xlsx "/6-Layout" --type chart \ +# --prop chartType=column \ +# --prop title="Label Formatting" \ +# --prop series1=Sales:120,200,150,180 \ +# --prop categories=Q1,Q2,Q3,Q4 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop dataLabels=true --prop labelPos=outsideEnd \ +# --prop labelFont=11:2E75B6:true \ +# --prop dataLabels.separator=": " \ +# --prop dataLabel2.text=Best! \ +# --prop dataLabel3.delete=true +# +# Features: labelFont (size:color:bold), dataLabels.separator, +# dataLabel{N}.text (custom), dataLabel{N}.delete (hide one label) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/6-Layout" --type chart' + f' --prop chartType=column' + f' --prop title="Label Formatting"' + f' --prop series1=Sales:120,200,150,180' + f' --prop categories=Q1,Q2,Q3,Q4' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop dataLabels=true --prop labelPos=outsideEnd' + f' --prop labelFont=11:2E75B6:true' + f' --prop "dataLabels.separator=: "' + f' --prop dataLabel2.text=Best!' + f' --prop dataLabel3.delete=true') + +# -------------------------------------------------------------------------- +# Chart 4: Error bars, minor ticks, opacity +# +# officecli add charts-basic.xlsx "/6-Layout" --type chart \ +# --prop chartType=line \ +# --prop title="Error Bars + Ticks" \ +# --prop series1=Measurement:50,55,48,62,58 \ +# --prop categories=Mon,Tue,Wed,Thu,Fri \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop showMarkers=true --prop marker=square:7:4472C4 \ +# --prop errBars=percentage \ +# --prop majorTickMark=outside --prop minorTickMark=inside \ +# --prop opacity=80 +# +# Features: errBars (percentage/stdDev/fixed), minorTickMark, opacity (0-100%) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/6-Layout" --type chart' + f' --prop chartType=line' + f' --prop title="Error Bars + Ticks"' + f' --prop series1=Measurement:50,55,48,62,58' + f' --prop categories=Mon,Tue,Wed,Thu,Fri' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop showMarkers=true --prop marker=square:7:4472C4' + f' --prop errBars=percentage' + f' --prop majorTickMark=outside --prop minorTickMark=inside' + f' --prop opacity=80') + +# ========================================================================== +# Sheet: 7-Effects +# Gradients, conditional color, area fill, title glow, preset themes +# ========================================================================== +print("\n--- 7-Effects ---") +cli(f'add "{FILE}" / --type sheet --prop name="7-Effects"') + +# -------------------------------------------------------------------------- +# Chart 1: Per-series gradients +# +# officecli add charts-basic.xlsx "/7-Effects" --type chart \ +# --prop chartType=column \ +# --prop title="Per-Series Gradients" \ +# --prop series1=East:120,135,148 \ +# --prop series2=West:110,118,130 \ +# --prop categories=Q1,Q2,Q3 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop 'gradients=4472C4-BDD7EE:90;ED7D31-FBE5D6:90' +# +# Features: gradients (per-series, semicolon-separated "C1-C2:angle") +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/7-Effects" --type chart' + f' --prop chartType=column' + f' --prop title="Per-Series Gradients"' + f' --prop series1=East:120,135,148' + f' --prop series2=West:110,118,130' + f' --prop categories=Q1,Q2,Q3' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop "gradients=4472C4-BDD7EE:90;ED7D31-FBE5D6:90"') + +# -------------------------------------------------------------------------- +# Chart 2: Area fill gradient and title glow effect +# +# officecli add charts-basic.xlsx "/7-Effects" --type chart \ +# --prop chartType=area \ +# --prop title="Glow Title + Area Fill" \ +# --prop dataRange=Sheet1!A1:C13 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop areafill=4472C4-BDD7EE:90 \ +# --prop transparency=30 \ +# --prop title.glow=4472C4-8-60 \ +# --prop title.size=16 +# +# Features: areafill (area gradient), title.glow (color-radius-opacity) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/7-Effects" --type chart' + f' --prop chartType=area' + f' --prop title="Glow Title + Area Fill"' + f' --prop dataRange=Sheet1!A1:C13' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop areafill=4472C4-BDD7EE:90' + f' --prop transparency=30' + f' --prop title.glow=4472C4-8-60' + f' --prop title.size=16') + +# -------------------------------------------------------------------------- +# Chart 3: Conditional coloring rule +# +# officecli add charts-basic.xlsx "/7-Effects" --type chart \ +# --prop chartType=column \ +# --prop title="Conditional Colors" \ +# --prop series1=Score:85,42,91,38,76,55 \ +# --prop categories=A,B,C,D,E,F \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop colorRule=60:FF0000:70AD47 \ +# --prop dataLabels=true --prop labelPos=outsideEnd +# +# Features: colorRule (threshold:belowColor:aboveColor — values below 60 red, above green) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/7-Effects" --type chart' + f' --prop chartType=column' + f' --prop title="Conditional Colors"' + f' --prop series1=Score:85,42,91,38,76,55' + f' --prop categories=A,B,C,D,E,F' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop colorRule=60:FF0000:70AD47' + f' --prop dataLabels=true --prop labelPos=outsideEnd') + +# -------------------------------------------------------------------------- +# Chart 4: Preset style/theme and leader lines +# +# officecli add charts-basic.xlsx "/7-Effects" --type chart \ +# --prop chartType=column \ +# --prop title="Preset Style 26" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop style=26 \ +# --prop dataLabels=true \ +# --prop dataLabels.showLeaderLines=true +# +# Features: style (preset 1-48), dataLabels.showLeaderLines +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/7-Effects" --type chart' + f' --prop chartType=column' + f' --prop title="Preset Style 26"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop style=26' + f' --prop dataLabels=true' + f' --prop dataLabels.showLeaderLines=true') + +print(f"\nDone! Generated: {FILE}") +print(" 8 sheets (Sheet1 data + 7 chart sheets, 28 charts total)") diff --git a/examples/excel/charts-basic.xlsx b/examples/excel/charts-basic.xlsx new file mode 100644 index 000000000..195487c4b Binary files /dev/null and b/examples/excel/charts-basic.xlsx differ diff --git a/examples/excel/charts-boxwhisker.md b/examples/excel/charts-boxwhisker.md new file mode 100644 index 000000000..064f702fe --- /dev/null +++ b/examples/excel/charts-boxwhisker.md @@ -0,0 +1,181 @@ +# Box-Whisker Chart Showcase + +This demo consists of three files that work together: + +- **charts-boxwhisker.py** — Python script that calls `officecli` commands to generate the workbook. Each chart command is shown as a copyable shell command in the comments. +- **charts-boxwhisker.xlsx** — The generated workbook with 2 sheets (8 box-whisker charts total). +- **charts-boxwhisker.md** — This file. Maps each sheet to the features it demonstrates. + +## Regenerate + +```bash +cd examples/excel +python3 charts-boxwhisker.py +# → charts-boxwhisker.xlsx +``` + +## Chart Sheets + +### Sheet: 1-Basics & Quartile + +Four box-whisker charts covering basic usage, quartile methods, title styling, and series colors. + +```bash +# Chart 1: Single series, exclusive quartile, data labels +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=boxWhisker \ + --prop title="Test Score Distribution" \ + --prop series1="Scores:45,52,58,61,63,65,67,68,70,72,75,78,82,85,90,95,99" \ + --prop quartileMethod=exclusive \ + --prop dataLabels=true + +# Chart 2: Three-series comparison, inclusive quartile, legend +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=boxWhisker \ + --prop title="Salary by Department ($k)" \ + --prop series1="Engineering:85,92,95,98,102,105,108,112,118,125,135,150,180" \ + --prop series2="Marketing:60,65,68,72,75,78,80,83,88,92,98,110" \ + --prop series3="Sales:55,62,68,75,82,90,98,105,115,125,140,160,190" \ + --prop quartileMethod=inclusive \ + --prop legend=bottom + +# Chart 3: Title styling — color, size, bold, font, shadow +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=boxWhisker \ + --prop title="Styled Title Demo" \ + --prop title.color=1B2838 --prop title.size=20 \ + --prop title.bold=true --prop title.font=Georgia \ + --prop title.shadow=000000-6-45-3-50 \ + --prop series1="Data:18,22,25,28,30,32,35,38,40,42,45,55,62,78" + +# Chart 4: Per-series colors and drop shadow +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=boxWhisker \ + --prop title="Custom Series Colors" \ + --prop series1="GroupA:30,38,45,52,58,62,65,68,71,74,78,85,92" \ + --prop series2="GroupB:20,28,35,40,48,55,60,66,70,80,88,95,110" \ + --prop colors=5B9BD5,ED7D31 \ + --prop series.shadow=000000-6-45-3-35 +``` + +**Features:** `quartileMethod=exclusive`, `quartileMethod=inclusive`, `dataLabels`, `legend=bottom`, multi-series (3), `title.color`, `title.size`, `title.bold`, `title.font`, `title.shadow`, `colors` (per-series), `series.shadow` + +### Sheet: 2-Axes & Styling + +Four box-whisker charts covering axis control, gridlines, area fills, and a full presentation-grade chart. + +```bash +# Chart 5: Axis scaling, axis titles, axis title styling, axis font +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=boxWhisker \ + --prop title="Response Time (ms)" \ + --prop series1="API:12,18,22,25,28,30,32,35,38,40,42,45,55,62,78,95,120" \ + --prop series2="DB:5,8,10,12,14,16,18,20,22,25,28,32,38,45,60" \ + --prop axismin=0 --prop axismax=130 \ + --prop majorunit=10 --prop minorunit=5 \ + --prop xAxisTitle="Service" --prop yAxisTitle="Latency (ms)" \ + --prop axisTitle.color=4A5568 --prop axisTitle.size=12 \ + --prop axisTitle.bold=true --prop axisTitle.font="Helvetica Neue" \ + --prop "axisfont=10:6B7280:Consolas" + +# Chart 6: Axis visibility, axis lines, gridlines, cross-axis gridlines +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=boxWhisker \ + --prop title="Axis & Gridline Control" \ + --prop series1="Temp:15,18,20,22,24,26,28,30,32,35,38,40,42" \ + --prop cataxis.visible=false \ + --prop "valaxis.line=334155:1.5" \ + --prop gridlines=true --prop gridlineColor=E2E8F0 \ + --prop xGridlines=true --prop xGridlineColor=F1F5F9 + +# Chart 7: Card style — area fills/borders, gapWidth, no tick labels +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=boxWhisker \ + --prop title="Card Style" \ + --prop series1="Weight:50,55,58,60,62,64,66,68,70,72,75,78,82,88,95" \ + --prop fill=6366F1 \ + --prop gapWidth=200 \ + --prop tickLabels=false --prop gridlines=false \ + --prop plotareafill=F8FAFC --prop "plotarea.border=E2E8F0:1" \ + --prop chartareafill=FFFFFF --prop "chartarea.border=CBD5E1:0.75" + +# Chart 8: Full presentation-grade — all properties combined +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=boxWhisker \ + --prop title="Server Latency Dashboard" \ + --prop title.color=0F172A --prop title.size=18 \ + --prop title.bold=true --prop title.font="Helvetica Neue" \ + --prop title.shadow=000000-4-45-2-40 \ + --prop series1="US-East:8,12,15,18,20,22,24,26,28,30,35,42,55,70,95" \ + --prop series2="EU-West:10,14,18,22,25,28,30,33,36,40,45,50,60,80" \ + --prop series3="AP-South:15,20,25,30,35,38,42,45,48,52,58,65,75,90,120" \ + --prop quartileMethod=exclusive \ + --prop colors=3B82F6,10B981,F59E0B \ + --prop series.shadow=000000-4-45-2-30 \ + --prop axismin=0 --prop axismax=130 --prop majorunit=10 \ + --prop xAxisTitle="Region" --prop yAxisTitle="Latency (ms)" \ + --prop axisTitle.color=475569 --prop axisTitle.size=11 \ + --prop axisTitle.bold=true --prop axisTitle.font="Helvetica Neue" \ + --prop "axisfont=9:64748B:Helvetica Neue" \ + --prop "axisline=CBD5E1:1" \ + --prop gridlineColor=E2E8F0 \ + --prop dataLabels=true --prop "datalabels.numfmt=0" \ + --prop legend=top --prop legend.overlay=false \ + --prop "legendfont=10:475569:Helvetica Neue" \ + --prop plotareafill=F8FAFC --prop "plotarea.border=E2E8F0:0.75" \ + --prop chartareafill=FFFFFF --prop "chartarea.border=CBD5E1:0.75" +``` + +**Features:** `axismin`, `axismax`, `majorunit`, `minorunit`, `xAxisTitle`, `yAxisTitle`, `axisTitle.color`, `axisTitle.size`, `axisTitle.bold`, `axisTitle.font`, `axisfont`, `cataxis.visible`, `valaxis.line`, `gridlines`, `gridlineColor`, `xGridlines`, `xGridlineColor`, `fill` (single color), `gapWidth`, `tickLabels`, `plotareafill`, `plotarea.border`, `chartareafill`, `chartarea.border`, `axisline`, `datalabels.numfmt`, `legend.overlay`, `legendfont` + +## Property Coverage + +| Property | Chart | +|---|---| +| `chartType=boxWhisker` | 1-8 | +| `quartileMethod=exclusive` | 1, 8 | +| `quartileMethod=inclusive` | 2 | +| `dataLabels` | 1, 8 | +| `datalabels.numfmt` | 8 | +| `legend=bottom` | 2 | +| `legend=top` | 8 | +| `legend.overlay` | 8 | +| `legendfont` | 8 | +| `title.color` | 3, 8 | +| `title.size` | 3, 8 | +| `title.bold` | 3, 8 | +| `title.font` | 3, 8 | +| `title.shadow` | 3, 8 | +| `fill` (single color) | 7 | +| `colors` (per-series) | 4, 8 | +| `series.shadow` | 4, 8 | +| `axismin` / `axismax` | 5, 8 | +| `majorunit` | 5, 8 | +| `minorunit` | 5 | +| `xAxisTitle` | 5, 8 | +| `yAxisTitle` | 5, 8 | +| `axisTitle.color` | 5, 8 | +| `axisTitle.size` | 5, 8 | +| `axisTitle.bold` | 5, 8 | +| `axisTitle.font` | 5, 8 | +| `axisfont` | 5, 8 | +| `cataxis.visible` | 6 | +| `valaxis.line` | 6 | +| `axisline` | 8 | +| `gridlines` | 6, 7 | +| `gridlineColor` | 6, 8 | +| `xGridlines` | 6 | +| `xGridlineColor` | 6 | +| `tickLabels` | 7 | +| `gapWidth` | 7 | +| `plotareafill` | 7, 8 | +| `plotarea.border` | 7, 8 | +| `chartareafill` | 7, 8 | +| `chartarea.border` | 7, 8 | + +## Inspect the Generated File + +```bash +officecli query charts-boxwhisker.xlsx chart +officecli get charts-boxwhisker.xlsx "/1-Basics & Quartile/chart[1]" +``` diff --git a/examples/excel/charts-boxwhisker.py b/examples/excel/charts-boxwhisker.py new file mode 100644 index 000000000..59c7f7ffa --- /dev/null +++ b/examples/excel/charts-boxwhisker.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +""" +Box-Whisker Chart Showcase — all boxWhisker properties. + +Generates: charts-boxwhisker.xlsx + +Usage: + python3 charts-boxwhisker.py +""" + +import subprocess, sys, os, atexit + +FILE = "charts-boxwhisker.xlsx" + +def cli(cmd): + """Run: officecli """ + r = subprocess.run(f"officecli {cmd}", shell=True, capture_output=True, text=True) + out = (r.stdout or "").strip() + if out: + for line in out.split("\n"): + if line.strip(): + print(f" {line.strip()}") + if r.returncode != 0: + err = (r.stderr or "").strip() + if err and "UNSUPPORTED" not in err and "process cannot access" not in err: + print(f" ERROR: {err}") + +if os.path.exists(FILE): + os.remove(FILE) + +cli(f'create "{FILE}"') +cli(f'open "{FILE}"') +atexit.register(lambda: cli(f'close "{FILE}"')) + +# ========================================================================== +# Sheet 1: Basics & Quartile Methods +# ========================================================================== +print("\n--- 1-Basics & Quartile ---") +cli(f'add "{FILE}" / --type sheet --prop name="1-Basics & Quartile"') + +# -------------------------------------------------------------------------- +# Chart 1: Basic single-series with exclusive quartile and data labels +# +# officecli add charts-boxwhisker.xlsx "/1-Basics & Quartile" --type chart \ +# --prop chartType=boxWhisker \ +# --prop title="Test Score Distribution" \ +# --prop series1="Scores:45,52,58,61,63,65,67,68,70,72,75,78,82,85,90,95,99" \ +# --prop quartileMethod=exclusive \ +# --prop dataLabels=true \ +# --prop x=0 --prop y=0 --prop width=13 --prop height=18 +# +# Features: single series, quartileMethod=exclusive, dataLabels +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Basics & Quartile" --type chart' + f' --prop chartType=boxWhisker' + f' --prop title="Test Score Distribution"' + f' --prop "series1=Scores:45,52,58,61,63,65,67,68,70,72,75,78,82,85,90,95,99"' + f' --prop quartileMethod=exclusive' + f' --prop dataLabels=true' + f' --prop x=0 --prop y=0 --prop width=13 --prop height=18') + +# -------------------------------------------------------------------------- +# Chart 2: Multi-series with inclusive quartile, legend at bottom +# +# officecli add charts-boxwhisker.xlsx "/1-Basics & Quartile" --type chart \ +# --prop chartType=boxWhisker \ +# --prop title="Salary by Department ($k)" \ +# --prop series1="Engineering:85,92,95,98,102,105,108,112,118,125,135,150,180" \ +# --prop series2="Marketing:60,65,68,72,75,78,80,83,88,92,98,110" \ +# --prop series3="Sales:55,62,68,75,82,90,98,105,115,125,140,160,190" \ +# --prop quartileMethod=inclusive \ +# --prop legend=bottom \ +# --prop x=14 --prop y=0 --prop width=13 --prop height=18 +# +# Features: 3 series, quartileMethod=inclusive, legend=bottom +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Basics & Quartile" --type chart' + f' --prop chartType=boxWhisker' + f' --prop title="Salary by Department (\\$k)"' + f' --prop "series1=Engineering:85,92,95,98,102,105,108,112,118,125,135,150,180"' + f' --prop "series2=Marketing:60,65,68,72,75,78,80,83,88,92,98,110"' + f' --prop "series3=Sales:55,62,68,75,82,90,98,105,115,125,140,160,190"' + f' --prop quartileMethod=inclusive' + f' --prop legend=bottom' + f' --prop x=14 --prop y=0 --prop width=13 --prop height=18') + +# -------------------------------------------------------------------------- +# Chart 3: Title styling — color, size, bold, font, shadow +# +# officecli add charts-boxwhisker.xlsx "/1-Basics & Quartile" --type chart \ +# --prop chartType=boxWhisker \ +# --prop title="Styled Title Demo" \ +# --prop title.color=1B2838 \ +# --prop title.size=20 \ +# --prop title.bold=true \ +# --prop title.font="Georgia" \ +# --prop title.shadow=000000-6-45-3-50 \ +# --prop series1="Data:18,22,25,28,30,32,35,38,40,42,45,55,62,78" \ +# --prop x=0 --prop y=19 --prop width=13 --prop height=18 +# +# Features: title.color, title.size, title.bold, title.font, title.shadow +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Basics & Quartile" --type chart' + f' --prop chartType=boxWhisker' + f' --prop title="Styled Title Demo"' + f' --prop title.color=1B2838' + f' --prop title.size=20' + f' --prop title.bold=true' + f' --prop title.font=Georgia' + f' --prop title.shadow=000000-6-45-3-50' + f' --prop "series1=Data:18,22,25,28,30,32,35,38,40,42,45,55,62,78"' + f' --prop x=0 --prop y=19 --prop width=13 --prop height=18') + +# -------------------------------------------------------------------------- +# Chart 4: Series colors — fill, colors (per-series), series.shadow +# +# officecli add charts-boxwhisker.xlsx "/1-Basics & Quartile" --type chart \ +# --prop chartType=boxWhisker \ +# --prop title="Custom Series Colors" \ +# --prop series1="GroupA:30,38,45,52,58,62,65,68,71,74,78,85,92" \ +# --prop series2="GroupB:20,28,35,40,48,55,60,66,70,80,88,95,110" \ +# --prop colors=5B9BD5,ED7D31 \ +# --prop series.shadow=000000-6-45-3-35 \ +# --prop x=14 --prop y=19 --prop width=13 --prop height=18 +# +# Features: colors (per-series hex), series.shadow +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Basics & Quartile" --type chart' + f' --prop chartType=boxWhisker' + f' --prop title="Custom Series Colors"' + f' --prop "series1=GroupA:30,38,45,52,58,62,65,68,71,74,78,85,92"' + f' --prop "series2=GroupB:20,28,35,40,48,55,60,66,70,80,88,95,110"' + f' --prop colors=5B9BD5,ED7D31' + f' --prop series.shadow=000000-6-45-3-35' + f' --prop x=14 --prop y=19 --prop width=13 --prop height=18') + +# ========================================================================== +# Sheet 2: Axes & Styling +# ========================================================================== +print("\n--- 2-Axes & Styling ---") +cli(f'add "{FILE}" / --type sheet --prop name="2-Axes & Styling"') + +# -------------------------------------------------------------------------- +# Chart 5: Axis scaling + axis titles + axis title styling + axis font +# +# officecli add charts-boxwhisker.xlsx "/2-Axes & Styling" --type chart \ +# --prop chartType=boxWhisker \ +# --prop title="Response Time (ms)" \ +# --prop series1="API:12,18,22,25,28,30,32,35,38,40,42,45,55,62,78,95,120" \ +# --prop series2="DB:5,8,10,12,14,16,18,20,22,25,28,32,38,45,60" \ +# --prop axismin=0 --prop axismax=130 --prop majorunit=10 --prop minorunit=5 \ +# --prop xAxisTitle="Service" \ +# --prop yAxisTitle="Latency (ms)" \ +# --prop axisTitle.color=4A5568 \ +# --prop axisTitle.size=12 \ +# --prop axisTitle.bold=true \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop axisfont=10:6B7280:Consolas \ +# --prop x=0 --prop y=0 --prop width=13 --prop height=18 +# +# Features: axismin, axismax, majorunit, minorunit, xAxisTitle, yAxisTitle, +# axisTitle.color/.size/.bold/.font, axisfont +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Axes & Styling" --type chart' + f' --prop chartType=boxWhisker' + f' --prop title="Response Time (ms)"' + f' --prop "series1=API:12,18,22,25,28,30,32,35,38,40,42,45,55,62,78,95,120"' + f' --prop "series2=DB:5,8,10,12,14,16,18,20,22,25,28,32,38,45,60"' + f' --prop axismin=0 --prop axismax=130 --prop majorunit=10 --prop minorunit=5' + f' --prop xAxisTitle=Service' + f' --prop yAxisTitle="Latency (ms)"' + f' --prop axisTitle.color=4A5568' + f' --prop axisTitle.size=12' + f' --prop axisTitle.bold=true' + f' --prop axisTitle.font="Helvetica Neue"' + f' --prop "axisfont=10:6B7280:Consolas"' + f' --prop x=0 --prop y=0 --prop width=13 --prop height=18') + +# -------------------------------------------------------------------------- +# Chart 6: Axis visibility + axis lines + gridlines + xGridlines +# +# officecli add charts-boxwhisker.xlsx "/2-Axes & Styling" --type chart \ +# --prop chartType=boxWhisker \ +# --prop title="Axis & Gridline Control" \ +# --prop series1="Temp:15,18,20,22,24,26,28,30,32,35,38,40,42" \ +# --prop cataxis.visible=false \ +# --prop valaxis.line=334155:1.5 \ +# --prop gridlines=true \ +# --prop gridlineColor=E2E8F0 \ +# --prop xGridlines=true \ +# --prop xGridlineColor=F1F5F9 \ +# --prop x=14 --prop y=0 --prop width=13 --prop height=18 +# +# Features: cataxis.visible=false, valaxis.line, gridlines, gridlineColor, +# xGridlines, xGridlineColor +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Axes & Styling" --type chart' + f' --prop chartType=boxWhisker' + f' --prop title="Axis & Gridline Control"' + f' --prop "series1=Temp:15,18,20,22,24,26,28,30,32,35,38,40,42"' + f' --prop cataxis.visible=false' + f' --prop "valaxis.line=334155:1.5"' + f' --prop gridlines=true' + f' --prop gridlineColor=E2E8F0' + f' --prop xGridlines=true' + f' --prop xGridlineColor=F1F5F9' + f' --prop x=14 --prop y=0 --prop width=13 --prop height=18') + +# -------------------------------------------------------------------------- +# Chart 7: Plot/chart area fills, borders, gapWidth, tickLabels=false +# +# officecli add charts-boxwhisker.xlsx "/2-Axes & Styling" --type chart \ +# --prop chartType=boxWhisker \ +# --prop title="Card Style" \ +# --prop series1="Weight:50,55,58,60,62,64,66,68,70,72,75,78,82,88,95" \ +# --prop fill=6366F1 \ +# --prop gapWidth=200 \ +# --prop tickLabels=false \ +# --prop gridlines=false \ +# --prop plotareafill=F8FAFC \ +# --prop plotarea.border=E2E8F0:1 \ +# --prop chartareafill=FFFFFF \ +# --prop chartarea.border=CBD5E1:0.75 \ +# --prop x=0 --prop y=19 --prop width=13 --prop height=18 +# +# Features: fill (single color), gapWidth, tickLabels=false, gridlines=false, +# plotareafill, plotarea.border, chartareafill, chartarea.border +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Axes & Styling" --type chart' + f' --prop chartType=boxWhisker' + f' --prop title="Card Style"' + f' --prop "series1=Weight:50,55,58,60,62,64,66,68,70,72,75,78,82,88,95"' + f' --prop fill=6366F1' + f' --prop gapWidth=200' + f' --prop tickLabels=false' + f' --prop gridlines=false' + f' --prop plotareafill=F8FAFC' + f' --prop "plotarea.border=E2E8F0:1"' + f' --prop chartareafill=FFFFFF' + f' --prop "chartarea.border=CBD5E1:0.75"' + f' --prop x=0 --prop y=19 --prop width=13 --prop height=18') + +# -------------------------------------------------------------------------- +# Chart 8: Full presentation-grade — everything combined +# +# officecli add charts-boxwhisker.xlsx "/2-Axes & Styling" --type chart \ +# --prop chartType=boxWhisker \ +# --prop title="Server Latency Dashboard" \ +# --prop title.color=0F172A \ +# --prop title.size=18 \ +# --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop title.shadow=000000-4-45-2-40 \ +# --prop series1="US-East:8,12,15,18,20,22,24,26,28,30,35,42,55,70,95" \ +# --prop series2="EU-West:10,14,18,22,25,28,30,33,36,40,45,50,60,80" \ +# --prop series3="AP-South:15,20,25,30,35,38,42,45,48,52,58,65,75,90,120" \ +# --prop quartileMethod=exclusive \ +# --prop colors=3B82F6,10B981,F59E0B \ +# --prop series.shadow=000000-4-45-2-30 \ +# --prop axismin=0 --prop axismax=130 --prop majorunit=10 \ +# --prop xAxisTitle="Region" \ +# --prop yAxisTitle="Latency (ms)" \ +# --prop axisTitle.color=475569 \ +# --prop axisTitle.size=11 \ +# --prop axisTitle.bold=true \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop axisfont=9:64748B:Helvetica\ Neue \ +# --prop axisline=CBD5E1:1 \ +# --prop gridlineColor=E2E8F0 \ +# --prop dataLabels=true \ +# --prop datalabels.numfmt=0 \ +# --prop legend=top \ +# --prop legend.overlay=false \ +# --prop legendfont=10:475569:Helvetica\ Neue \ +# --prop plotareafill=F8FAFC \ +# --prop plotarea.border=E2E8F0:0.75 \ +# --prop chartareafill=FFFFFF \ +# --prop chartarea.border=CBD5E1:0.75 \ +# --prop x=14 --prop y=19 --prop width=16 --prop height=22 +# +# Features: ALL properties combined — title styling, multi-series colors, +# series.shadow, axis scaling, axis titles + styling, axisfont, axisline, +# gridlineColor, dataLabels + numfmt, legend + overlay + legendfont, +# plot/chart area fill + border +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Axes & Styling" --type chart' + f' --prop chartType=boxWhisker' + f' --prop title="Server Latency Dashboard"' + f' --prop title.color=0F172A' + f' --prop title.size=18' + f' --prop title.bold=true' + f' --prop title.font="Helvetica Neue"' + f' --prop title.shadow=000000-4-45-2-40' + f' --prop "series1=US-East:8,12,15,18,20,22,24,26,28,30,35,42,55,70,95"' + f' --prop "series2=EU-West:10,14,18,22,25,28,30,33,36,40,45,50,60,80"' + f' --prop "series3=AP-South:15,20,25,30,35,38,42,45,48,52,58,65,75,90,120"' + f' --prop quartileMethod=exclusive' + f' --prop colors=3B82F6,10B981,F59E0B' + f' --prop series.shadow=000000-4-45-2-30' + f' --prop axismin=0 --prop axismax=130 --prop majorunit=10' + f' --prop xAxisTitle=Region' + f' --prop yAxisTitle="Latency (ms)"' + f' --prop axisTitle.color=475569' + f' --prop axisTitle.size=11' + f' --prop axisTitle.bold=true' + f' --prop axisTitle.font="Helvetica Neue"' + f' --prop "axisfont=9:64748B:Helvetica Neue"' + f' --prop "axisline=CBD5E1:1"' + f' --prop gridlineColor=E2E8F0' + f' --prop dataLabels=true' + f' --prop "datalabels.numfmt=0"' + f' --prop legend=top' + f' --prop legend.overlay=false' + f' --prop "legendfont=10:475569:Helvetica Neue"' + f' --prop plotareafill=F8FAFC' + f' --prop "plotarea.border=E2E8F0:0.75"' + f' --prop chartareafill=FFFFFF' + f' --prop "chartarea.border=CBD5E1:0.75"' + f' --prop x=14 --prop y=19 --prop width=16 --prop height=22') + +# Remove blank default Sheet1 +cli(f'remove "{FILE}" /Sheet1') + +print(f"\nDone! Generated: {FILE}") +print(" 2 sheets (8 charts total)") +print(" Sheet 1: Basics & Quartile Methods (4 charts)") +print(" Sheet 2: Axes & Styling (4 charts)") diff --git a/examples/excel/charts-boxwhisker.xlsx b/examples/excel/charts-boxwhisker.xlsx new file mode 100644 index 000000000..3340de35d Binary files /dev/null and b/examples/excel/charts-boxwhisker.xlsx differ diff --git a/examples/excel/charts-bubble.md b/examples/excel/charts-bubble.md new file mode 100644 index 000000000..83fd30750 --- /dev/null +++ b/examples/excel/charts-bubble.md @@ -0,0 +1,119 @@ +# Bubble Charts Showcase + +This demo consists of three files that work together: + +- **charts-bubble.py** — Python script that calls `officecli` commands to generate the workbook. Each chart command is shown as a copyable shell command in the comments. +- **charts-bubble.xlsx** — The generated workbook with 4 sheets (1 default + 3 chart sheets, 12 charts total). +- **charts-bubble.md** — This file. Maps each sheet to the features it demonstrates. + +## Regenerate + +```bash +cd examples/excel +python3 charts-bubble.py +# -> charts-bubble.xlsx +``` + +## Chart Sheets + +### Sheet: 1-Bubble Fundamentals + +Four bubble charts covering basic rendering, bubble scale, size representation, and data labels. + +```bash +# Basic bubble with 2 series (X,Y,Size triplets separated by semicolons) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bubble \ + --prop series1="Enterprise:50,12,80;120,8,45;200,15,60" \ + --prop series2="Consumer:30,25,50;80,18,35;150,22,70" \ + --prop catTitle=Market Size ($M) --prop axisTitle=Growth Rate (%) + +# bubbleScale=100 with center data labels +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bubble \ + --prop bubbleScale=100 \ + --prop dataLabels=true --prop labelPos=center + +# Small bubbles with bubbleScale=50 +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bubble \ + --prop bubbleScale=50 + +# Size proportional to diameter (width) instead of area +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bubble \ + --prop sizeRepresents=width +``` + +**Features:** `bubble`, X;Y;Size triplet format, `catTitle`, `axisTitle`, `bubbleScale`, `dataLabels`, `labelPos=center`, `labelFont`, `sizeRepresents=width` + +### Sheet: 2-Bubble Styling + +Four styled bubble charts with title fonts, transparency, grid styling, and shadow effects. + +```bash +# Title and legend styling +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bubble \ + --prop title.font=Georgia --prop title.size=16 \ + --prop title.color=1F4E79 --prop title.bold=true \ + --prop legend=right --prop legendfont=10:333333:Calibri + +# Transparent overlapping bubbles (ARGB with alpha) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bubble \ + --prop colors=804472C4,80ED7D31 \ + --prop bubbleScale=120 + +# Grid and axis line styling +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bubble \ + --prop gridlines=D9D9D9:0.5 --prop axisfont=9:666666 \ + --prop axisLine=333333-1 + +# Shadow and fill effects +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bubble \ + --prop plotFill=F0F4F8 --prop chartFill=FAFAFA \ + --prop series.shadow=000000-4-315-2-30 +``` + +**Features:** `title.font/size/color/bold`, `legend=right`, `legendfont`, ARGB transparency (`80RRGGBB`), `bubbleScale`, `gridlines`, `axisfont`, `axisLine`, `plotFill`, `chartFill`, `series.shadow` + +### Sheet: 3-Bubble Advanced + +Four advanced bubble charts with secondary axis, reference lines, log scale, and trendlines. + +```bash +# Secondary axis for second series +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bubble \ + --prop secondaryAxis=2 + +# Reference line (growth threshold) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bubble \ + --prop referenceLine=18:Target Growth:C00000 + +# Logarithmic scale with axis range +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bubble \ + --prop axisMin=1 --prop axisMax=50 \ + --prop logBase=10 + +# Borders and trendline +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=bubble \ + --prop chartArea.border=333333-1.5 \ + --prop plotArea.border=999999-0.75 \ + --prop trendline=linear +``` + +**Features:** `secondaryAxis`, `referenceLine`, `axisMin/Max`, `logBase`, `chartArea.border`, `plotArea.border`, `trendline=linear` + +## Inspect the Generated File + +```bash +officecli query charts-bubble.xlsx chart +officecli get charts-bubble.xlsx "/1-Bubble Fundamentals/chart[1]" +``` diff --git a/examples/excel/charts-bubble.py b/examples/excel/charts-bubble.py new file mode 100644 index 000000000..4e431dc1b --- /dev/null +++ b/examples/excel/charts-bubble.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +""" +Bubble Charts Showcase — bubble scale, size representation, and styling. + +Generates: charts-bubble.xlsx + +Usage: + python3 charts-bubble.py +""" + +import subprocess, sys, os, json, atexit + +FILE = "charts-bubble.xlsx" + +def cli(cmd): + """Run: officecli """ + r = subprocess.run(f"officecli {cmd}", shell=True, capture_output=True, text=True) + out = (r.stdout or "").strip() + if out: + for line in out.split("\n"): + if line.strip(): + print(f" {line.strip()}") + if r.returncode != 0: + err = (r.stderr or "").strip() + if err and "UNSUPPORTED" not in err and "process cannot access" not in err: + print(f" ERROR: {err}") + +if os.path.exists(FILE): + os.remove(FILE) + +cli(f'create "{FILE}"') +cli(f'open "{FILE}"') +atexit.register(lambda: cli(f'close "{FILE}"')) + +# ========================================================================== +# Sheet: 1-Bubble Fundamentals +# ========================================================================== +print("\n--- 1-Bubble Fundamentals ---") +cli(f'add "{FILE}" / --type sheet --prop name="1-Bubble Fundamentals"') + +# -------------------------------------------------------------------------- +# Chart 1: Basic bubble chart with 2 series +# +# officecli add charts-bubble.xlsx "/1-Bubble Fundamentals" --type chart \ +# --prop chartType=bubble \ +# --prop title="Market Analysis" \ +# --prop series1="Enterprise:50,12,80;120,8,45;200,15,60" \ +# --prop series2="Consumer:30,25,50;80,18,35;150,22,70" \ +# --prop colors=4472C4,ED7D31 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop catTitle=Market Size --prop axisTitle=Growth Rate \ +# --prop legend=bottom +# +# Features: chartType=bubble, X;Y;Size triplets, catTitle, axisTitle +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Bubble Fundamentals" --type chart' + f' --prop chartType=bubble' + f' --prop title="Market Analysis"' + f' --prop series1=Enterprise:80,45,60' + f' --prop series2=Consumer:50,35,70' + f' --prop colors=4472C4,ED7D31' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop "catTitle=Market Size" --prop "axisTitle=Growth Rate"' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 2: bubbleScale=100 with dataLabels +# +# officecli add charts-bubble.xlsx "/1-Bubble Fundamentals" --type chart \ +# --prop chartType=bubble \ +# --prop title="Product Portfolio" \ +# --prop series1="Products:20,30,90;60,20,50;100,10,70;140,25,40" \ +# --prop colors=2E75B6 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop bubbleScale=100 \ +# --prop dataLabels=true --prop labelPos=center \ +# --prop labelFont=9:FFFFFF:true \ +# --prop legend=bottom +# +# Features: bubbleScale=100, dataLabels with center positioning +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Bubble Fundamentals" --type chart' + f' --prop chartType=bubble' + f' --prop title="Product Portfolio"' + f' --prop series1=Products:90,50,70,40' + f' --prop colors=2E75B6' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop bubbleScale=100' + f' --prop dataLabels=true --prop labelPos=center' + f' --prop labelFont=9:FFFFFF:true' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: bubbleScale=50 vs bubbleScale=200 comparison (small scale) +# +# officecli add charts-bubble.xlsx "/1-Bubble Fundamentals" --type chart \ +# --prop chartType=bubble \ +# --prop title="Small Bubbles (Scale 50)" \ +# --prop series1="Tech:40,15,60;90,22,80;160,10,45" \ +# --prop series2="Finance:70,18,55;130,12,70;180,20,35" \ +# --prop colors=70AD47,FFC000 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop bubbleScale=50 \ +# --prop legend=bottom +# +# Features: bubbleScale=50 (smaller bubbles) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Bubble Fundamentals" --type chart' + f' --prop chartType=bubble' + f' --prop title="Small Bubbles (Scale 50)"' + f' --prop series1=Tech:60,80,45' + f' --prop series2=Finance:55,70,35' + f' --prop colors=70AD47,FFC000' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop bubbleScale=50' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 4: sizeRepresents=width +# +# officecli add charts-bubble.xlsx "/1-Bubble Fundamentals" --type chart \ +# --prop chartType=bubble \ +# --prop title="Size by Width" \ +# --prop series1="Regions:35,28,70;85,15,40;140,20,55;190,30,85" \ +# --prop colors=5B9BD5 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop sizeRepresents=width \ +# --prop bubbleScale=100 \ +# --prop legend=bottom +# +# Features: sizeRepresents=width (bubble diameter proportional to value) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Bubble Fundamentals" --type chart' + f' --prop chartType=bubble' + f' --prop title="Size by Width"' + f' --prop series1=Regions:70,40,55,85' + f' --prop colors=5B9BD5' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop sizeRepresents=width' + f' --prop bubbleScale=100' + f' --prop legend=bottom') + +# ========================================================================== +# Sheet: 2-Bubble Styling +# ========================================================================== +print("\n--- 2-Bubble Styling ---") +cli(f'add "{FILE}" / --type sheet --prop name="2-Bubble Styling"') + +# -------------------------------------------------------------------------- +# Chart 1: Title styling, legend positioning +# +# officecli add charts-bubble.xlsx "/2-Bubble Styling" --type chart \ +# --prop chartType=bubble \ +# --prop title="Styled Bubble Chart" \ +# --prop series1="Segment A:45,20,65;100,15,50;160,25,80" \ +# --prop series2="Segment B:60,30,45;120,10,60;175,18,40" \ +# --prop colors=1F4E79,C55A11 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop title.font=Georgia --prop title.size=16 \ +# --prop title.color=1F4E79 --prop title.bold=true \ +# --prop legend=right --prop legendfont=10:333333:Calibri +# +# Features: title.font/size/color/bold, legend=right, legendfont +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Bubble Styling" --type chart' + f' --prop chartType=bubble' + f' --prop title="Styled Bubble Chart"' + f' --prop series1=SegmentA:65,50,80' + f' --prop series2=SegmentB:45,60,40' + f' --prop colors=1F4E79,C55A11' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop title.font=Georgia --prop title.size=16' + f' --prop title.color=1F4E79 --prop title.bold=true' + f' --prop legend=right --prop legendfont=10:333333:Calibri') + +# -------------------------------------------------------------------------- +# Chart 2: Series colors, transparency +# +# officecli add charts-bubble.xlsx "/2-Bubble Styling" --type chart \ +# --prop chartType=bubble \ +# --prop title="Transparent Overlapping Bubbles" \ +# --prop series1="Group X:30,25,75;70,30,60;110,15,90;150,22,50" \ +# --prop series2="Group Y:50,20,65;90,28,55;130,18,80;170,12,45" \ +# --prop colors=804472C4,80ED7D31 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop bubbleScale=120 \ +# --prop legend=bottom +# +# Features: ARGB colors with alpha (80=50% transparency) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Bubble Styling" --type chart' + f' --prop chartType=bubble' + f' --prop title="Transparent Overlapping Bubbles"' + f' --prop series1=GroupX:75,60,90,50' + f' --prop series2=GroupY:65,55,80,45' + f' --prop colors=804472C4,80ED7D31' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop bubbleScale=120' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: gridlines, axisfont, axisLine +# +# officecli add charts-bubble.xlsx "/2-Bubble Styling" --type chart \ +# --prop chartType=bubble \ +# --prop title="Grid & Axis Styling" \ +# --prop series1="Division 1:25,35,55;65,20,70;115,28,45" \ +# --prop series2="Division 2:40,15,60;80,25,40;130,30,75" \ +# --prop colors=2E75B6,548235 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop gridlines=D9D9D9:0.5 \ +# --prop axisfont=9:666666 \ +# --prop axisLine=333333-1 \ +# --prop legend=bottom +# +# Features: gridlines, axisfont, axisLine +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Bubble Styling" --type chart' + f' --prop chartType=bubble' + f' --prop title="Grid & Axis Styling"' + f' --prop series1=Div1:55,70,45' + f' --prop series2=Div2:60,40,75' + f' --prop colors=2E75B6,548235' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop gridlines=D9D9D9:0.5' + f' --prop axisfont=9:666666' + f' --prop axisLine=333333:1' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 4: plotFill, chartFill, series.shadow +# +# officecli add charts-bubble.xlsx "/2-Bubble Styling" --type chart \ +# --prop chartType=bubble \ +# --prop title="Shadow & Fill Effects" \ +# --prop series1="Portfolio:35,22,80;75,28,55;120,16,65;165,32,45" \ +# --prop colors=4472C4 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop plotFill=F0F4F8 --prop chartFill=FAFAFA \ +# --prop series.shadow=000000-4-315-2-30 \ +# --prop legend=bottom +# +# Features: plotFill, chartFill, series.shadow +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Bubble Styling" --type chart' + f' --prop chartType=bubble' + f' --prop title="Shadow & Fill Effects"' + f' --prop series1=Portfolio:80,55,65,45' + f' --prop colors=4472C4' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop plotFill=F0F4F8 --prop chartFill=FAFAFA' + f' --prop series.shadow=000000-4-315-2-30' + f' --prop legend=bottom') + +# ========================================================================== +# Sheet: 3-Bubble Advanced +# ========================================================================== +print("\n--- 3-Bubble Advanced ---") +cli(f'add "{FILE}" / --type sheet --prop name="3-Bubble Advanced"') + +# -------------------------------------------------------------------------- +# Chart 1: secondaryAxis +# +# officecli add charts-bubble.xlsx "/3-Bubble Advanced" --type chart \ +# --prop chartType=bubble \ +# --prop title="Dual-Axis Bubble" \ +# --prop series1="Domestic:70,85,60,90" \ +# --prop series2="International:45,55,80,65" \ +# --prop categories=1,2,3,4 \ +# --prop colors=4472C4,ED7D31 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop secondaryAxis=2 \ +# --prop legend=bottom +# +# Features: secondaryAxis on bubble chart +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Bubble Advanced" --type chart' + f' --prop chartType=bubble' + f' --prop title="Dual-Axis Bubble"' + f' --prop series1=Domestic:70,85,60,90' + f' --prop series2=International:45,55,80,65' + f' --prop categories=1,2,3,4' + f' --prop colors=4472C4,ED7D31' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop secondaryAxis=2' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 2: referenceLine +# +# officecli add charts-bubble.xlsx "/3-Bubble Advanced" --type chart \ +# --prop chartType=bubble \ +# --prop title="Growth Threshold" \ +# --prop series1="Products:60,80,45,55" \ +# --prop categories=1,2,3,4 \ +# --prop colors=70AD47 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop referenceLine=50:C00000:Target \ +# --prop bubbleScale=80 \ +# --prop legend=bottom +# +# Features: referenceLine on bubble chart +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Bubble Advanced" --type chart' + f' --prop chartType=bubble' + f' --prop title="Growth Threshold"' + f' --prop series1=Products:60,80,45,55' + f' --prop categories=1,2,3,4' + f' --prop colors=70AD47' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop "referenceLine=50:C00000:Target"' + f' --prop bubbleScale=80' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: axisMin/Max, logBase +# +# officecli add charts-bubble.xlsx "/3-Bubble Advanced" --type chart \ +# --prop chartType=bubble \ +# --prop title="Log Scale Analysis" \ +# --prop series1="Markets:5,15,50,120" \ +# --prop categories=1,2,3,4 \ +# --prop colors=2E75B6 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop axisMin=1 --prop axisMax=200 \ +# --prop logBase=10 \ +# --prop bubbleScale=80 \ +# --prop legend=bottom +# +# Features: axisMin/Max, logBase=10 (logarithmic scale) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Bubble Advanced" --type chart' + f' --prop chartType=bubble' + f' --prop title="Log Scale Analysis"' + f' --prop series1=Markets:5,15,50,120' + f' --prop categories=1,2,3,4' + f' --prop colors=2E75B6' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop axisMin=1 --prop axisMax=200' + f' --prop logBase=10' + f' --prop bubbleScale=80' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 4: chartArea.border, plotArea.border, trendline +# +# officecli add charts-bubble.xlsx "/3-Bubble Advanced" --type chart \ +# --prop chartType=bubble \ +# --prop title="Trend & Borders" \ +# --prop series1="Investments:20,55,95,140,180" \ +# --prop categories=1,2,3,4,5 \ +# --prop colors=4472C4 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop chartArea.border=333333:1.5 \ +# --prop plotArea.border=999999:0.75 \ +# --prop trendline=linear \ +# --prop legend=bottom +# +# Features: chartArea.border, plotArea.border, trendline=linear +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Bubble Advanced" --type chart' + f' --prop chartType=bubble' + f' --prop title="Trend & Borders"' + f' --prop series1=Investments:20,55,95,140,180' + f' --prop categories=1,2,3,4,5' + f' --prop colors=4472C4' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop chartArea.border=333333:1.5' + f' --prop plotArea.border=999999:0.75' + f' --prop trendline=linear' + f' --prop legend=bottom') + +# Remove blank default Sheet1 (all data is inline) +cli(f'remove "{FILE}" /Sheet1') + +print(f"\nDone! Generated: {FILE}") +print(" 4 sheets (3 chart sheets, 12 charts total)") diff --git a/examples/excel/charts-bubble.xlsx b/examples/excel/charts-bubble.xlsx new file mode 100644 index 000000000..ad402425c Binary files /dev/null and b/examples/excel/charts-bubble.xlsx differ diff --git a/examples/excel/charts-column.md b/examples/excel/charts-column.md new file mode 100644 index 000000000..626728335 --- /dev/null +++ b/examples/excel/charts-column.md @@ -0,0 +1,283 @@ +# Column Charts Showcase + +This demo consists of three files that work together: + +- **charts-column.py** — Python script that calls `officecli` commands to generate the workbook. Each chart command is shown as a copyable shell command in the comments. +- **charts-column.xlsx** — The generated workbook with 8 sheets (1 data + 7 chart sheets, 28 charts total). +- **charts-column.md** — This file. Maps each sheet to the features it demonstrates. + +## Regenerate + +```bash +cd examples/excel +python3 charts-column.py +# → charts-column.xlsx +``` + +## Chart Sheets + +### Sheet: 1-Column Fundamentals + +Four basic column charts covering every data input method. + +```bash +# dataRange with axis titles and axis font +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop dataRange=Sheet1!A1:E13 \ + --prop catTitle=Month --prop axisTitle=Revenue \ + --prop axisfont=9:58626E:Arial --prop gridlines=D9D9D9:0.5:dot + +# Inline named series with gap width +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop series1="Laptops:320,280,350,310" \ + --prop series2="Phones:450,420,480,460" \ + --prop categories=Jan,Feb,Mar,Apr \ + --prop colors=2E75B6,C00000,70AD47 \ + --prop gapwidth=80 + +# Cell-range series (dotted syntax) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop series1.name=East \ + --prop series1.values=Sheet1!B2:B13 \ + --prop series1.categories=Sheet1!A2:A13 \ + --prop minorGridlines=EEEEEE:0.3:dot + +# Inline data shorthand +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop 'data=Team A:85,92,78;Team B:70,80,85' \ + --prop categories=Mon,Tue,Wed \ + --prop legend=right +``` + +**Features:** `series1=Name:v1,v2`, `series1.name`/`.values`/`.categories` (cell range), `dataRange`, `data` (shorthand), `categories`, `colors`, `catTitle`, `axisTitle`, `axisfont`, `gridlines`, `minorGridlines`, `gapwidth`, `legend` (bottom, right) + +### Sheet: 2-Column Variants + +Four charts covering all column chart type variants. + +```bash +# Stacked column with center labels and series outline +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=columnStacked \ + --prop dataLabels=center \ + --prop series.outline=FFFFFF-0.5 + +# 100% stacked column — proportional +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=columnPercentStacked \ + --prop axisNumFmt=0% + +# 3D column with perspective +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column3d \ + --prop view3d=15,20,30 --prop style=3 + +# 3D column with gap depth +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column3d \ + --prop gapDepth=200 +``` + +**Features:** `columnStacked`, `columnPercentStacked`, `column3d`, `dataLabels=center`, `series.outline`, `axisNumFmt`, `view3d` (rotX,rotY,perspective), `style` (preset 1-48), `gapDepth` + +### Sheet: 3-Column Styling + +Four charts demonstrating visual styling — title formatting, shadows, gradients, and transparency. + +```bash +# Styled title +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop title.font=Georgia --prop title.size=16 \ + --prop title.color=1F4E79 --prop title.bold=true + +# Series shadow and outline effects +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop series.shadow=000000-4-315-2-40 \ + --prop series.outline=FFFFFF-0.5 + +# Per-series gradient fills +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop 'gradients=4472C4-BDD7EE:90;ED7D31-FBE5D6:90;70AD47-C5E0B4:90' + +# Transparent columns on gradient background +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop transparency=30 \ + --prop plotFill=F0F4F8-D6E4F0:90 --prop chartFill=FFFFFF \ + --prop roundedCorners=true +``` + +**Features:** `title.font`/`.size`/`.color`/`.bold`, `series.shadow` (color-blur-angle-dist-opacity), `series.outline`, `gradients` (per-series), `transparency`, `plotFill` (gradient), `chartFill`, `roundedCorners` + +### Sheet: 4-Axis & Gridlines + +Four charts demonstrating every axis and gridline configuration. + +```bash +# Custom axis scaling with axis lines +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop axisMin=50 --prop axisMax=250 \ + --prop majorUnit=50 --prop minorUnit=25 \ + --prop axisLine=C00000:1.5:solid --prop catAxisLine=2E75B6:1.5:solid + +# Logarithmic scale with reversed axis +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop logBase=10 --prop axisReverse=true + +# Display units with tick marks +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop dispUnits=thousands --prop axisNumFmt=#,##0 \ + --prop majorTickMark=outside --prop minorTickMark=inside + +# Hidden axes with data table +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop gridlines=none --prop axisVisible=false \ + --prop dataTable=true --prop legend=none +``` + +**Features:** `axisMin`, `axisMax`, `majorUnit`, `minorUnit`, `axisLine`, `catAxisLine`, `logBase` (logarithmic scale), `axisReverse` (flip direction), `dispUnits` (thousands/millions), `axisNumFmt`, `majorTickMark`, `minorTickMark`, `axisVisible`, `dataTable`, `gridlines=none`, `legend=none` + +### Sheet: 5-Labels & Legend + +Four charts demonstrating data label and legend customization. + +```bash +# Data labels with number format +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop dataLabels=true --prop labelPos=outsideEnd \ + --prop labelFont=9:333333:true \ + --prop dataLabels.numFmt=#,##0 + +# Custom individual labels (hide some, highlight peak) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop dataLabels=true \ + --prop dataLabel1.delete=true --prop dataLabel2.delete=true \ + --prop point4.color=C00000 --prop dataLabel4.text=Peak! + +# Legend overlay with styled font +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop legend=right --prop legend.overlay=true \ + --prop legendfont=10:333333:Calibri --prop plotFill=F5F5F5 + +# Manual layout — plotArea, title, legend positioning +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop plotArea.x=0.12 --prop plotArea.y=0.18 \ + --prop plotArea.w=0.82 --prop plotArea.h=0.55 \ + --prop title.x=0.25 --prop title.y=0.02 \ + --prop legend.x=0.15 --prop legend.y=0.82 \ + --prop legend.w=0.7 --prop legend.h=0.12 +``` + +**Features:** `dataLabels`, `labelPos` (outsideEnd/center/insideEnd/insideBase), `labelFont`, `dataLabels.numFmt`, `dataLabel{N}.delete`, `dataLabel{N}.text`, `point{N}.color`, `legend` (right), `legend.overlay`, `legendfont`, `plotFill`, `plotArea.x/y/w/h`, `title.x/y`, `legend.x/y/w/h` + +### Sheet: 6-Effects & Advanced + +Four charts demonstrating advanced features — secondary axis, reference lines, effects, and conditional coloring. + +```bash +# Secondary axis (dual scale) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop secondaryAxis=2 \ + --prop series1="Revenue:120,180,250,310" \ + --prop series2="Growth %:50,33,39,24" + +# Reference line (target threshold) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop referenceLine=150:FF0000:1.5:dash + +# Title glow/shadow effects +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop title.glow=4472C4-8-60 \ + --prop title.shadow=000000-3-315-2-40 \ + --prop series.shadow=000000-3-315-1-30 + +# Conditional coloring with chart/plot borders +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop colorRule=0:C00000:70AD47 \ + --prop referenceLine=0:888888:1:solid \ + --prop chartArea.border=D0D0D0:1:solid \ + --prop plotArea.border=E0E0E0:0.5:dot +``` + +**Features:** `secondaryAxis` (1-based series indices), `referenceLine` (value:color:width:dash), `title.glow` (color-radius-opacity), `title.shadow` (color-blur-angle-dist-opacity), `series.shadow`, `colorRule` (threshold:belowColor:aboveColor), `chartArea.border`, `plotArea.border` + +### Sheet: 7-Bar Shape & Gap + +Four charts demonstrating column gap width, overlap, and 3D bar shapes. + +```bash +# Narrow gap (bars close together) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop gapwidth=30 + +# Wide gap with negative overlap (separated bars within group) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column \ + --prop gapwidth=200 --prop overlap=-50 + +# Cylinder shape (3D) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column3d \ + --prop shape=cylinder --prop view3d=15,20,30 + +# Cone shape (3D) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=column3d \ + --prop shape=cone --prop view3d=15,20,30 +``` + +**Features:** `gapwidth` (0-500), `overlap` (-100 to 100, negative = separated), `shape` (cylinder, cone, pyramid — 3D column shapes) + +## Complete Feature Coverage + +| Feature | Sheet | +|---------|-------| +| **Chart types:** column, columnStacked, columnPercentStacked, column3d | 1, 2 | +| **Data input:** series, dataRange, data, series.name/values/categories | 1 | +| **Colors:** colors, gradients | 1, 3 | +| **Gap & overlap:** gapwidth, overlap | 1, 7 | +| **Axis scaling:** axisMin/Max, majorUnit, minorUnit | 4 | +| **Axis features:** logBase, axisReverse, dispUnits, axisNumFmt | 2, 4 | +| **Axis lines:** axisLine, catAxisLine | 4 | +| **Axis visibility:** axisVisible | 4 | +| **Tick marks:** majorTickMark, minorTickMark | 4 | +| **Gridlines:** gridlines, minorGridlines, gridlines=none | 1, 4 | +| **Data labels:** dataLabels, labelPos, labelFont, numFmt | 2, 5 | +| **Custom labels:** dataLabel{N}.text, dataLabel{N}.delete | 5 | +| **Point color:** point{N}.color | 5 | +| **Legend:** position, legendfont, legend.overlay, legend=none | 1, 4, 5 | +| **Layout:** plotArea.x/y/w/h, title.x/y, legend.x/y/w/h | 5 | +| **Effects:** series.shadow, series.outline, transparency | 2, 3 | +| **Title styling:** font, size, color, bold, glow, shadow | 3, 6 | +| **Fills:** plotFill, chartFill (solid + gradient) | 3, 5 | +| **Borders:** chartArea.border, plotArea.border | 6 | +| **Advanced:** secondaryAxis, referenceLine, colorRule | 6 | +| **3D:** view3d, gapDepth, style, shape (cylinder/cone/pyramid) | 2, 7 | +| **Other:** dataTable, roundedCorners, catTitle, axisTitle, axisfont | 1, 3, 4 | + +## Inspect the Generated File + +```bash +officecli query charts-column.xlsx chart +officecli get charts-column.xlsx "/1-Column Fundamentals/chart[1]" +``` diff --git a/examples/excel/charts-column.py b/examples/excel/charts-column.py new file mode 100755 index 000000000..7d6e08e1a --- /dev/null +++ b/examples/excel/charts-column.py @@ -0,0 +1,928 @@ +#!/usr/bin/env python3 +""" +Column & Bar Charts Showcase — column, columnStacked, columnPercentStacked, and column3d with all variations. + +Generates: charts-column.xlsx + +Every column chart feature officecli supports is demonstrated at least once: +gap width, overlap, bar shapes, axis scaling, gridlines, data labels, +legend positioning, reference lines, secondary axis, gradients, +transparency, shadows, manual layout, and 3D rotation. + +7 sheets, 28 charts total. + + 1-Column Fundamentals 4 charts — data input variants, axis titles, inline/cell-range/data + 2-Column Variants 4 charts — columnStacked, columnPercentStacked, column3d + 3-Column Styling 4 charts — title styling, series effects, gradients, transparency + 4-Axis & Gridlines 4 charts — axis scaling, log scale, reverse, display units + 5-Labels & Legend 4 charts — data labels, custom labels, legend layout + 6-Effects & Advanced 4 charts — secondary axis, reference line, glow/shadow, colorRule + 7-Bar Shape & Gap 4 charts — gapwidth, overlap, 3D shapes (cylinder, cone, pyramid) + +Usage: + python3 charts-column.py +""" + +import subprocess, sys, os, json, atexit + +FILE = "charts-column.xlsx" + +def cli(cmd): + """Run: officecli """ + r = subprocess.run(f"officecli {cmd}", shell=True, capture_output=True, text=True) + out = (r.stdout or "").strip() + if out: + for line in out.split("\n"): + if line.strip(): + print(f" {line.strip()}") + if r.returncode != 0: + err = (r.stderr or "").strip() + if err and "UNSUPPORTED" not in err and "process cannot access" not in err: + print(f" ERROR: {err}") + +if os.path.exists(FILE): + os.remove(FILE) + +cli(f'create "{FILE}"') +cli(f'open "{FILE}"') +atexit.register(lambda: cli(f'close "{FILE}"')) + +# ========================================================================== +# Source data — shared across all charts +# ========================================================================== +print("\n--- Populating source data ---") + +data_cmds = [] +for j, h in enumerate(["Month", "East", "South", "North", "West"]): + data_cmds.append({"command": "set", "path": f"/Sheet1/{'ABCDE'[j]}1", "props": {"text": h, "bold": "true"}}) + +months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] +east = [120, 135, 148, 162, 155, 178, 195, 210, 188, 172, 165, 198] +south = [95, 108, 115, 128, 142, 155, 168, 175, 160, 148, 135, 158] +north = [88, 92, 105, 118, 125, 138, 145, 152, 140, 130, 122, 142] +west = [110, 118, 130, 145, 138, 162, 175, 190, 170, 155, 148, 180] + +for i in range(12): + r = i + 2 + for j, val in enumerate([months[i], east[i], south[i], north[i], west[i]]): + data_cmds.append({"command": "set", "path": f"/Sheet1/{'ABCDE'[j]}{r}", "props": {"text": str(val)}}) + +cli(f'batch "{FILE}" --force --commands \'{json.dumps(data_cmds)}\'') + +# ========================================================================== +# Sheet: 1-Column Fundamentals +# ========================================================================== +print("\n--- 1-Column Fundamentals ---") +cli(f'add "{FILE}" / --type sheet --prop name="1-Column Fundamentals"') + +# -------------------------------------------------------------------------- +# Chart 1: Basic column with dataRange and axis titles +# +# officecli add charts-column.xlsx "/1-Column Fundamentals" --type chart \ +# --prop chartType=column \ +# --prop title="Monthly Sales by Region" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop catTitle=Month --prop axisTitle=Revenue \ +# --prop axisfont=9:58626E:Arial \ +# --prop gridlines=D9D9D9:0.5:dot \ +# --prop colors=4472C4,ED7D31,70AD47,FFC000 +# +# Features: chartType=column, dataRange, catTitle, axisTitle, axisfont, +# gridlines, colors +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Column Fundamentals" --type chart' + f' --prop chartType=column' + f' --prop title="Monthly Sales by Region"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop catTitle=Month --prop axisTitle=Revenue' + f' --prop axisfont=9:58626E:Arial' + f' --prop gridlines=D9D9D9:0.5:dot' + f' --prop colors=4472C4,ED7D31,70AD47,FFC000') + +# -------------------------------------------------------------------------- +# Chart 2: Inline series with custom colors and gap width +# +# officecli add charts-column.xlsx "/1-Column Fundamentals" --type chart \ +# --prop chartType=column \ +# --prop title="Q1 Product Sales" \ +# --prop series1="Laptops:320,280,350,310" \ +# --prop series2="Phones:450,420,480,460" \ +# --prop series3="Tablets:180,160,200,190" \ +# --prop categories=Jan,Feb,Mar,Apr \ +# --prop colors=2E75B6,C00000,70AD47 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop gapwidth=80 \ +# --prop legend=bottom +# +# Features: inline series (series1=Name:v1,v2,...), colors, gapwidth, +# legend=bottom +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Column Fundamentals" --type chart' + f' --prop chartType=column' + f' --prop title="Q1 Product Sales"' + f' --prop series1="Laptops:320,280,350,310"' + f' --prop series2="Phones:450,420,480,460"' + f' --prop series3="Tablets:180,160,200,190"' + f' --prop categories=Jan,Feb,Mar,Apr' + f' --prop colors=2E75B6,C00000,70AD47' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop gapwidth=80' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: Dotted syntax with cell ranges +# +# officecli add charts-column.xlsx "/1-Column Fundamentals" --type chart \ +# --prop chartType=column \ +# --prop title="East vs South (Cell Range)" \ +# --prop series1.name=East \ +# --prop series1.values=Sheet1!B2:B13 \ +# --prop series1.categories=Sheet1!A2:A13 \ +# --prop series2.name=South \ +# --prop series2.values=Sheet1!C2:C13 \ +# --prop series2.categories=Sheet1!A2:A13 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop colors=4472C4,ED7D31 \ +# --prop gridlines=D9D9D9:0.5:dot \ +# --prop minorGridlines=EEEEEE:0.3:dot +# +# Features: series.name/values/categories (cell range via dotted syntax), +# minorGridlines +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Column Fundamentals" --type chart' + f' --prop chartType=column' + f' --prop title="East vs South (Cell Range)"' + f' --prop series1.name=East' + f' --prop series1.values=Sheet1!B2:B13' + f' --prop series1.categories=Sheet1!A2:A13' + f' --prop series2.name=South' + f' --prop series2.values=Sheet1!C2:C13' + f' --prop series2.categories=Sheet1!A2:A13' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop colors=4472C4,ED7D31' + f' --prop gridlines=D9D9D9:0.5:dot' + f' --prop minorGridlines=EEEEEE:0.3:dot') + +# -------------------------------------------------------------------------- +# Chart 4: data= shorthand format +# +# officecli add charts-column.xlsx "/1-Column Fundamentals" --type chart \ +# --prop chartType=column \ +# --prop title="Weekly Output" \ +# --prop 'data=Team A:85,92,78,95,88;Team B:70,80,85,90,75' \ +# --prop categories=Mon,Tue,Wed,Thu,Fri \ +# --prop colors=0070C0,FF6600 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop legend=right +# +# Features: data (inline shorthand Name:v1;Name2:v2), legend=right +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Column Fundamentals" --type chart' + f' --prop chartType=column' + f' --prop title="Weekly Output"' + f' --prop "data=Team A:85,92,78,95,88;Team B:70,80,85,90,75"' + f' --prop categories=Mon,Tue,Wed,Thu,Fri' + f' --prop colors=0070C0,FF6600' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop legend=right') + +# ========================================================================== +# Sheet: 2-Column Variants +# ========================================================================== +print("\n--- 2-Column Variants ---") +cli(f'add "{FILE}" / --type sheet --prop name="2-Column Variants"') + +# -------------------------------------------------------------------------- +# Chart 1: Stacked column with center data labels and series outline +# +# officecli add charts-column.xlsx "/2-Column Variants" --type chart \ +# --prop chartType=columnStacked \ +# --prop title="Stacked Sales by Region" \ +# --prop dataRange=Sheet1!A1:E7 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop colors=4472C4,ED7D31,70AD47,FFC000 \ +# --prop dataLabels=center \ +# --prop series.outline=FFFFFF-0.5 \ +# --prop legend=bottom +# +# Features: columnStacked, dataLabels=center, series.outline +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Column Variants" --type chart' + f' --prop chartType=columnStacked' + f' --prop title="Stacked Sales by Region"' + f' --prop dataRange=Sheet1!A1:E7' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop colors=4472C4,ED7D31,70AD47,FFC000' + f' --prop dataLabels=center' + f' --prop series.outline=FFFFFF-0.5' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 2: 100% stacked column with axis number format +# +# officecli add charts-column.xlsx "/2-Column Variants" --type chart \ +# --prop chartType=columnPercentStacked \ +# --prop title="Regional Contribution %" \ +# --prop dataRange=Sheet1!A1:E7 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop colors=1F4E79,2E75B6,9DC3E6,BDD7EE \ +# --prop axisNumFmt=0% \ +# --prop legend=bottom \ +# --prop gridlines=E0E0E0:0.5:solid +# +# Features: columnPercentStacked, axisNumFmt=0%, legend=bottom +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Column Variants" --type chart' + f' --prop chartType=columnPercentStacked' + f' --prop title="Regional Contribution %"' + f' --prop dataRange=Sheet1!A1:E7' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop colors=1F4E79,2E75B6,9DC3E6,BDD7EE' + f' --prop axisNumFmt=0%' + f' --prop legend=bottom' + f' --prop gridlines=E0E0E0:0.5:solid') + +# -------------------------------------------------------------------------- +# Chart 3: 3D column with perspective and style +# +# officecli add charts-column.xlsx "/2-Column Variants" --type chart \ +# --prop chartType=column3d \ +# --prop title="3D Regional Trends" \ +# --prop dataRange=Sheet1!A1:E7 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop view3d=15,20,30 \ +# --prop colors=4472C4,ED7D31,70AD47,FFC000 \ +# --prop chartFill=F8F8F8 \ +# --prop style=3 +# +# Features: column3d, view3d (rotX,rotY,perspective), style (preset 1-48) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Column Variants" --type chart' + f' --prop chartType=column3d' + f' --prop title="3D Regional Trends"' + f' --prop dataRange=Sheet1!A1:E7' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop view3d=15,20,30' + f' --prop colors=4472C4,ED7D31,70AD47,FFC000' + f' --prop chartFill=F8F8F8' + f' --prop style=3') + +# -------------------------------------------------------------------------- +# Chart 4: 3D stacked column with gap depth +# +# officecli add charts-column.xlsx "/2-Column Variants" --type chart \ +# --prop chartType=column3d \ +# --prop title="3D Stacked with Gap Depth" \ +# --prop series1="East:120,135,148,162,155,178" \ +# --prop series2="South:95,108,115,128,142,155" \ +# --prop series3="North:88,92,105,118,125,138" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop view3d=15,20,30 \ +# --prop gapDepth=200 \ +# --prop colors=2E75B6,ED7D31,70AD47 \ +# --prop legend=right +# +# Features: column3d stacked, gapDepth=200 (3D depth spacing) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Column Variants" --type chart' + f' --prop chartType=column3d' + f' --prop title="3D Stacked with Gap Depth"' + f' --prop "series1=East:120,135,148,162,155,178"' + f' --prop "series2=South:95,108,115,128,142,155"' + f' --prop "series3=North:88,92,105,118,125,138"' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop view3d=15,20,30' + f' --prop gapDepth=200' + f' --prop colors=2E75B6,ED7D31,70AD47' + f' --prop legend=right') + +# ========================================================================== +# Sheet: 3-Column Styling +# ========================================================================== +print("\n--- 3-Column Styling ---") +cli(f'add "{FILE}" / --type sheet --prop name="3-Column Styling"') + +# -------------------------------------------------------------------------- +# Chart 1: Title styling — font, size, color, bold +# +# officecli add charts-column.xlsx "/3-Column Styling" --type chart \ +# --prop chartType=column \ +# --prop title="Styled Title Demo" \ +# --prop dataRange=Sheet1!A1:E7 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop title.font=Georgia --prop title.size=16 \ +# --prop title.color=1F4E79 --prop title.bold=true \ +# --prop colors=4472C4,ED7D31,70AD47,FFC000 \ +# --prop legend=bottom +# +# Features: title.font=Georgia, title.size=16, title.color=1F4E79, +# title.bold=true +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Column Styling" --type chart' + f' --prop chartType=column' + f' --prop title="Styled Title Demo"' + f' --prop dataRange=Sheet1!A1:E7' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop title.font=Georgia --prop title.size=16' + f' --prop title.color=1F4E79 --prop title.bold=true' + f' --prop colors=4472C4,ED7D31,70AD47,FFC000' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 2: Series shadow and outline effects +# +# officecli add charts-column.xlsx "/3-Column Styling" --type chart \ +# --prop chartType=column \ +# --prop title="Shadow & Outline Effects" \ +# --prop series1="Revenue:320,280,350,310,340" \ +# --prop series2="Cost:210,195,230,220,215" \ +# --prop categories=Q1,Q2,Q3,Q4,Q5 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop colors=4472C4,C00000 \ +# --prop series.shadow=000000-4-315-2-40 \ +# --prop series.outline=FFFFFF-0.5 \ +# --prop gapwidth=100 \ +# --prop legend=bottom +# +# Features: series.shadow (color-blur-angle-dist-opacity), +# series.outline (color-width) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Column Styling" --type chart' + f' --prop chartType=column' + f' --prop title="Shadow & Outline Effects"' + f' --prop "series1=Revenue:320,280,350,310,340"' + f' --prop "series2=Cost:210,195,230,220,215"' + f' --prop categories=Q1,Q2,Q3,Q4,Q5' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop colors=4472C4,C00000' + f' --prop series.shadow=000000-4-315-2-40' + f' --prop series.outline=FFFFFF-0.5' + f' --prop gapwidth=100' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: Per-series gradient fills +# +# officecli add charts-column.xlsx "/3-Column Styling" --type chart \ +# --prop chartType=column \ +# --prop title="Gradient Columns" \ +# --prop series1="East:120,135,148,162" \ +# --prop series2="South:95,108,115,128" \ +# --prop series3="North:88,92,105,118" \ +# --prop categories=Q1,Q2,Q3,Q4 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop 'gradients=4472C4-BDD7EE:90;ED7D31-FBE5D6:90;70AD47-C5E0B4:90' \ +# --prop legend=bottom +# +# Features: gradients (per-series gradient fills, start-end:angle) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Column Styling" --type chart' + f' --prop chartType=column' + f' --prop title="Gradient Columns"' + f' --prop "series1=East:120,135,148,162"' + f' --prop "series2=South:95,108,115,128"' + f' --prop "series3=North:88,92,105,118"' + f' --prop categories=Q1,Q2,Q3,Q4' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop "gradients=4472C4-BDD7EE:90;ED7D31-FBE5D6:90;70AD47-C5E0B4:90"' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 4: Transparency + plotFill gradient + chartFill + roundedCorners +# +# officecli add charts-column.xlsx "/3-Column Styling" --type chart \ +# --prop chartType=column \ +# --prop title="Transparent Columns on Gradient" \ +# --prop dataRange=Sheet1!A1:E7 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop transparency=30 \ +# --prop plotFill=F0F4F8-D6E4F0:90 \ +# --prop chartFill=FFFFFF \ +# --prop colors=1F4E79,2E75B6,5B9BD5,9DC3E6 \ +# --prop roundedCorners=true \ +# --prop legend=bottom +# +# Features: transparency=30, plotFill gradient, chartFill, roundedCorners +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Column Styling" --type chart' + f' --prop chartType=column' + f' --prop title="Transparent Columns on Gradient"' + f' --prop dataRange=Sheet1!A1:E7' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop transparency=30' + f' --prop plotFill=F0F4F8-D6E4F0:90' + f' --prop chartFill=FFFFFF' + f' --prop colors=1F4E79,2E75B6,5B9BD5,9DC3E6' + f' --prop roundedCorners=true' + f' --prop legend=bottom') + +# ========================================================================== +# Sheet: 4-Axis & Gridlines +# ========================================================================== +print("\n--- 4-Axis & Gridlines ---") +cli(f'add "{FILE}" / --type sheet --prop name="4-Axis & Gridlines"') + +# -------------------------------------------------------------------------- +# Chart 1: Custom axis scaling — min, max, majorUnit, minorUnit +# +# officecli add charts-column.xlsx "/4-Axis & Gridlines" --type chart \ +# --prop chartType=column \ +# --prop title="Custom Axis Scale (50–250)" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop axisMin=50 --prop axisMax=250 --prop majorUnit=50 \ +# --prop minorUnit=25 \ +# --prop gridlines=D0D0D0:0.5:solid \ +# --prop minorGridlines=EEEEEE:0.3:dot \ +# --prop axisLine=C00000:1.5:solid \ +# --prop catAxisLine=2E75B6:1.5:solid \ +# --prop colors=4472C4,ED7D31,70AD47,FFC000 +# +# Features: axisMin, axisMax, majorUnit, minorUnit, +# axisLine (value axis line styling), catAxisLine (category axis line) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Axis & Gridlines" --type chart' + f' --prop chartType=column' + f' --prop title="Custom Axis Scale (50-250)"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop axisMin=50 --prop axisMax=250 --prop majorUnit=50' + f' --prop minorUnit=25' + f' --prop gridlines=D0D0D0:0.5:solid' + f' --prop minorGridlines=EEEEEE:0.3:dot' + f' --prop axisLine=C00000:1.5:solid' + f' --prop catAxisLine=2E75B6:1.5:solid' + f' --prop colors=4472C4,ED7D31,70AD47,FFC000') + +# -------------------------------------------------------------------------- +# Chart 2: Logarithmic scale with reversed axis +# +# officecli add charts-column.xlsx "/4-Axis & Gridlines" --type chart \ +# --prop chartType=column \ +# --prop title="Log Scale (Base 10)" \ +# --prop series1="Growth:1,10,100,1000,5000" \ +# --prop categories=Year 1,Year 2,Year 3,Year 4,Year 5 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop logBase=10 \ +# --prop axisReverse=true \ +# --prop colors=C00000 \ +# --prop axisTitle="Value (log)" \ +# --prop catTitle=Year \ +# --prop gridlines=E0E0E0:0.5:dash +# +# Features: logBase=10 (logarithmic scale), axisReverse=true +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Axis & Gridlines" --type chart' + f' --prop chartType=column' + f' --prop title="Log Scale (Base 10)"' + f' --prop "series1=Growth:1,10,100,1000,5000"' + f' --prop "categories=Year 1,Year 2,Year 3,Year 4,Year 5"' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop logBase=10' + f' --prop axisReverse=true' + f' --prop colors=C00000' + f' --prop axisTitle="Value (log)"' + f' --prop catTitle=Year' + f' --prop gridlines=E0E0E0:0.5:dash') + +# -------------------------------------------------------------------------- +# Chart 3: Display units and axis number format +# +# officecli add charts-column.xlsx "/4-Axis & Gridlines" --type chart \ +# --prop chartType=column \ +# --prop title="Revenue (in Thousands)" \ +# --prop series1="Revenue:12000,18500,22000,31000,45000,52000" \ +# --prop series2="Cost:8000,11000,14000,19500,28000,33000" \ +# --prop categories=2020,2021,2022,2023,2024,2025 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop dispUnits=thousands \ +# --prop axisNumFmt=#,##0 \ +# --prop colors=2E75B6,C00000 \ +# --prop catTitle=Year --prop axisTitle=Amount (K) \ +# --prop majorTickMark=outside --prop minorTickMark=inside \ +# --prop legend=bottom +# +# Features: dispUnits=thousands, axisNumFmt=#,##0, +# majorTickMark=outside, minorTickMark=inside +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Axis & Gridlines" --type chart' + f' --prop chartType=column' + f' --prop title="Revenue (in Thousands)"' + f' --prop "series1=Revenue:12000,18500,22000,31000,45000,52000"' + f' --prop "series2=Cost:8000,11000,14000,19500,28000,33000"' + f' --prop categories=2020,2021,2022,2023,2024,2025' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop dispUnits=thousands' + f' --prop axisNumFmt=#,##0' + f' --prop colors=2E75B6,C00000' + f' --prop catTitle=Year --prop axisTitle="Amount (K)"' + f' --prop majorTickMark=outside --prop minorTickMark=inside' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 4: Hidden axes with data table +# +# officecli add charts-column.xlsx "/4-Axis & Gridlines" --type chart \ +# --prop chartType=column \ +# --prop title="Minimal Chart with Data Table" \ +# --prop dataRange=Sheet1!A1:E7 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop gridlines=none \ +# --prop axisVisible=false \ +# --prop dataTable=true \ +# --prop legend=none \ +# --prop colors=4472C4,ED7D31,70AD47,FFC000 +# +# Features: gridlines=none, axisVisible=false, dataTable=true, legend=none +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Axis & Gridlines" --type chart' + f' --prop chartType=column' + f' --prop title="Minimal Chart with Data Table"' + f' --prop dataRange=Sheet1!A1:E7' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop gridlines=none' + f' --prop axisVisible=false' + f' --prop dataTable=true' + f' --prop legend=none' + f' --prop colors=4472C4,ED7D31,70AD47,FFC000') + +# ========================================================================== +# Sheet: 5-Labels & Legend +# ========================================================================== +print("\n--- 5-Labels & Legend ---") +cli(f'add "{FILE}" / --type sheet --prop name="5-Labels & Legend"') + +# -------------------------------------------------------------------------- +# Chart 1: Data labels with number format and styled label font +# +# officecli add charts-column.xlsx "/5-Labels & Legend" --type chart \ +# --prop chartType=column \ +# --prop title="Sales with Labels" \ +# --prop series1="Revenue:120,180,210,250,280" \ +# --prop categories=Jan,Feb,Mar,Apr,May \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop colors=4472C4 \ +# --prop dataLabels=true --prop labelPos=outsideEnd \ +# --prop labelFont=9:333333:true \ +# --prop dataLabels.numFmt=#,##0 +# +# Features: dataLabels=true, labelPos=outsideEnd, labelFont (size:color:bold), +# dataLabels.numFmt +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Labels & Legend" --type chart' + f' --prop chartType=column' + f' --prop title="Sales with Labels"' + f' --prop "series1=Revenue:120,180,210,250,280"' + f' --prop categories=Jan,Feb,Mar,Apr,May' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop colors=4472C4' + f' --prop dataLabels=true --prop labelPos=outsideEnd' + f' --prop labelFont=9:333333:true' + f' --prop dataLabels.numFmt=#,##0') + +# -------------------------------------------------------------------------- +# Chart 2: Custom individual labels — delete some, highlight peak +# +# officecli add charts-column.xlsx "/5-Labels & Legend" --type chart \ +# --prop chartType=column \ +# --prop title="Peak Highlight" \ +# --prop series1="Sales:88,120,165,210,195,178" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop colors=2E75B6 \ +# --prop dataLabels=true --prop labelPos=outsideEnd \ +# --prop dataLabel1.delete=true --prop dataLabel2.delete=true \ +# --prop dataLabel3.delete=true \ +# --prop point4.color=C00000 \ +# --prop dataLabel4.text=Peak! \ +# --prop dataLabel5.delete=true --prop dataLabel6.delete=true +# +# Features: dataLabel{N}.delete, dataLabel{N}.text, point{N}.color +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Labels & Legend" --type chart' + f' --prop chartType=column' + f' --prop title="Peak Highlight"' + f' --prop "series1=Sales:88,120,165,210,195,178"' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop colors=2E75B6' + f' --prop dataLabels=true --prop labelPos=outsideEnd' + f' --prop dataLabel1.delete=true --prop dataLabel2.delete=true' + f' --prop dataLabel3.delete=true' + f' --prop point4.color=C00000' + f' --prop dataLabel4.text=Peak!' + f' --prop dataLabel5.delete=true --prop dataLabel6.delete=true') + +# -------------------------------------------------------------------------- +# Chart 3: Legend positioning and overlay with styled legend font +# +# officecli add charts-column.xlsx "/5-Labels & Legend" --type chart \ +# --prop chartType=column \ +# --prop title="Legend Overlay on Chart" \ +# --prop dataRange=Sheet1!A1:E7 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop colors=4472C4,ED7D31,70AD47,FFC000 \ +# --prop legend=right \ +# --prop legend.overlay=true \ +# --prop legendfont=10:333333:Calibri \ +# --prop plotFill=F5F5F5 +# +# Features: legend=right, legend.overlay=true, legendfont (size:color:fontname), +# plotFill +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Labels & Legend" --type chart' + f' --prop chartType=column' + f' --prop title="Legend Overlay on Chart"' + f' --prop dataRange=Sheet1!A1:E7' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop colors=4472C4,ED7D31,70AD47,FFC000' + f' --prop legend=right' + f' --prop legend.overlay=true' + f' --prop legendfont=10:333333:Calibri' + f' --prop plotFill=F5F5F5') + +# -------------------------------------------------------------------------- +# Chart 4: Manual layout — plotArea, title, and legend positioning +# +# officecli add charts-column.xlsx "/5-Labels & Legend" --type chart \ +# --prop chartType=column \ +# --prop title="Manual Layout Control" \ +# --prop dataRange=Sheet1!A1:E7 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop colors=2E75B6,ED7D31,70AD47,FFC000 \ +# --prop plotArea.x=0.12 --prop plotArea.y=0.18 \ +# --prop plotArea.w=0.82 --prop plotArea.h=0.55 \ +# --prop title.x=0.25 --prop title.y=0.02 \ +# --prop legend.x=0.15 --prop legend.y=0.82 \ +# --prop legend.w=0.7 --prop legend.h=0.12 \ +# --prop title.font=Arial --prop title.size=13 \ +# --prop title.bold=true +# +# Features: plotArea.x/y/w/h, title.x/y, legend.x/y/w/h (manual layout) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Labels & Legend" --type chart' + f' --prop chartType=column' + f' --prop title="Manual Layout Control"' + f' --prop dataRange=Sheet1!A1:E7' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop colors=2E75B6,ED7D31,70AD47,FFC000' + f' --prop plotArea.x=0.12 --prop plotArea.y=0.18' + f' --prop plotArea.w=0.82 --prop plotArea.h=0.55' + f' --prop title.x=0.25 --prop title.y=0.02' + f' --prop legend.x=0.15 --prop legend.y=0.82' + f' --prop legend.w=0.7 --prop legend.h=0.12' + f' --prop title.font=Arial --prop title.size=13' + f' --prop title.bold=true') + +# ========================================================================== +# Sheet: 6-Effects & Advanced +# ========================================================================== +print("\n--- 6-Effects & Advanced ---") +cli(f'add "{FILE}" / --type sheet --prop name="6-Effects & Advanced"') + +# -------------------------------------------------------------------------- +# Chart 1: Secondary axis — dual Y-axis +# +# officecli add charts-column.xlsx "/6-Effects & Advanced" --type chart \ +# --prop chartType=column \ +# --prop title="Revenue vs Growth Rate" \ +# --prop series1="Revenue:120,180,250,310,380,420" \ +# --prop series2="Growth %:50,33,39,24,23,11" \ +# --prop categories=2020,2021,2022,2023,2024,2025 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop secondaryAxis=2 \ +# --prop colors=2E75B6,C00000 \ +# --prop catTitle=Year --prop axisTitle=Revenue \ +# --prop dataLabels=true --prop labelPos=outsideEnd \ +# --prop legend=bottom +# +# Features: secondaryAxis=2 (series 2 on right-hand axis) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/6-Effects & Advanced" --type chart' + f' --prop chartType=column' + f' --prop title="Revenue vs Growth Rate"' + f' --prop "series1=Revenue:120,180,250,310,380,420"' + f' --prop "series2=Growth %:50,33,39,24,23,11"' + f' --prop categories=2020,2021,2022,2023,2024,2025' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop secondaryAxis=2' + f' --prop colors=2E75B6,C00000' + f' --prop catTitle=Year --prop axisTitle=Revenue' + f' --prop dataLabels=true --prop labelPos=outsideEnd' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 2: Reference line (target/threshold) +# +# officecli add charts-column.xlsx "/6-Effects & Advanced" --type chart \ +# --prop chartType=column \ +# --prop title="vs Target (150)" \ +# --prop dataRange=Sheet1!A1:C13 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop colors=4472C4,70AD47 \ +# --prop referenceLine=150:FF0000:1.5:dash \ +# --prop legend=bottom +# +# referenceLine format: value:color:width:dash +# +# Features: referenceLine (horizontal target line) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/6-Effects & Advanced" --type chart' + f' --prop chartType=column' + f' --prop title="vs Target (150)"' + f' --prop dataRange=Sheet1!A1:C13' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop colors=4472C4,70AD47' + f' --prop referenceLine=150:FF0000:1.5:dash' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: Title glow and shadow effects +# +# officecli add charts-column.xlsx "/6-Effects & Advanced" --type chart \ +# --prop chartType=column \ +# --prop title="Glow & Shadow Effects" \ +# --prop series1="East:120,135,148,162,155,178" \ +# --prop series2="West:110,118,130,145,138,162" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop colors=4472C4,ED7D31 \ +# --prop title.glow=4472C4-8-60 \ +# --prop title.shadow=000000-3-315-2-40 \ +# --prop title.font=Calibri --prop title.size=16 \ +# --prop title.bold=true --prop title.color=1F4E79 \ +# --prop series.shadow=000000-3-315-1-30 \ +# --prop plotFill=F0F4F8 --prop chartFill=FFFFFF +# +# Features: title.glow (color-radius-opacity), title.shadow, +# series.shadow on column charts +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/6-Effects & Advanced" --type chart' + f' --prop chartType=column' + f' --prop title="Glow & Shadow Effects"' + f' --prop "series1=East:120,135,148,162,155,178"' + f' --prop "series2=West:110,118,130,145,138,162"' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop colors=4472C4,ED7D31' + f' --prop title.glow=4472C4-8-60' + f' --prop title.shadow=000000-3-315-2-40' + f' --prop title.font=Calibri --prop title.size=16' + f' --prop title.bold=true --prop title.color=1F4E79' + f' --prop series.shadow=000000-3-315-1-30' + f' --prop plotFill=F0F4F8 --prop chartFill=FFFFFF') + +# -------------------------------------------------------------------------- +# Chart 4: Conditional coloring with chart/plot borders +# +# officecli add charts-column.xlsx "/6-Effects & Advanced" --type chart \ +# --prop chartType=column \ +# --prop title="Profit: Conditional Colors" \ +# --prop series1="Profit:80,120,-30,160,-50,200,140,-20,180,90" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop colors=2E75B6 \ +# --prop colorRule=0:C00000:70AD47 \ +# --prop referenceLine=0:888888:1:solid \ +# --prop chartArea.border=D0D0D0:1:solid \ +# --prop plotArea.border=E0E0E0:0.5:dot \ +# --prop dataLabels=true --prop labelPos=outsideEnd \ +# --prop labelFont=8:666666:false +# +# colorRule format: threshold:belowColor:aboveColor +# +# Features: colorRule (threshold-based conditional coloring), +# chartArea.border, plotArea.border +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/6-Effects & Advanced" --type chart' + f' --prop chartType=column' + f' --prop title="Profit: Conditional Colors"' + f' --prop "series1=Profit:80,120,-30,160,-50,200,140,-20,180,90"' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop colors=2E75B6' + f' --prop colorRule=0:C00000:70AD47' + f' --prop referenceLine=0:888888:1:solid' + f' --prop chartArea.border=D0D0D0:1:solid' + f' --prop plotArea.border=E0E0E0:0.5:dot' + f' --prop dataLabels=true --prop labelPos=outsideEnd' + f' --prop labelFont=8:666666:false') + +# ========================================================================== +# Sheet: 7-Bar Shape & Gap +# ========================================================================== +print("\n--- 7-Bar Shape & Gap ---") +cli(f'add "{FILE}" / --type sheet --prop name="7-Bar Shape & Gap"') + +# -------------------------------------------------------------------------- +# Chart 1: Narrow gap width (bars close together) +# +# officecli add charts-column.xlsx "/7-Bar Shape & Gap" --type chart \ +# --prop chartType=column \ +# --prop title="Narrow Gap (30%)" \ +# --prop dataRange=Sheet1!A1:E7 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop gapwidth=30 \ +# --prop colors=4472C4,ED7D31,70AD47,FFC000 \ +# --prop legend=bottom +# +# Features: gapwidth=30 (narrow gaps between column groups) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/7-Bar Shape & Gap" --type chart' + f' --prop chartType=column' + f' --prop title="Narrow Gap (30%)"' + f' --prop dataRange=Sheet1!A1:E7' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop gapwidth=30' + f' --prop colors=4472C4,ED7D31,70AD47,FFC000' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 2: Wide gap with negative overlap (separated bars within group) +# +# officecli add charts-column.xlsx "/7-Bar Shape & Gap" --type chart \ +# --prop chartType=column \ +# --prop title="Wide Gap + Negative Overlap" \ +# --prop dataRange=Sheet1!A1:E7 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop gapwidth=200 \ +# --prop overlap=-50 \ +# --prop colors=2E75B6,ED7D31,70AD47,FFC000 \ +# --prop legend=bottom +# +# Features: gapwidth=200 (wide gap), overlap=-50 (negative = bars separated) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/7-Bar Shape & Gap" --type chart' + f' --prop chartType=column' + f' --prop title="Wide Gap + Negative Overlap"' + f' --prop dataRange=Sheet1!A1:E7' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop gapwidth=200' + f' --prop overlap=-50' + f' --prop colors=2E75B6,ED7D31,70AD47,FFC000' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: 3D column with cylinder shape +# +# officecli add charts-column.xlsx "/7-Bar Shape & Gap" --type chart \ +# --prop chartType=column3d \ +# --prop title="Cylinder Shape" \ +# --prop series1="East:120,135,148,162,155,178" \ +# --prop series2="South:95,108,115,128,142,155" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop shape=cylinder \ +# --prop view3d=15,20,30 \ +# --prop colors=4472C4,ED7D31 \ +# --prop legend=bottom +# +# Features: shape=cylinder (3D column bar shape) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/7-Bar Shape & Gap" --type chart' + f' --prop chartType=column3d' + f' --prop title="Cylinder Shape"' + f' --prop "series1=East:120,135,148,162,155,178"' + f' --prop "series2=South:95,108,115,128,142,155"' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop shape=cylinder' + f' --prop view3d=15,20,30' + f' --prop colors=4472C4,ED7D31' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 4: 3D column with cone/pyramid shapes +# +# officecli add charts-column.xlsx "/7-Bar Shape & Gap" --type chart \ +# --prop chartType=column3d \ +# --prop title="Cone Shape" \ +# --prop series1="North:88,92,105,118,125,138" \ +# --prop series2="West:110,118,130,145,138,162" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop shape=cone \ +# --prop view3d=15,20,30 \ +# --prop colors=70AD47,FFC000 \ +# --prop legend=bottom +# +# Features: shape=cone (3D column bar shape — also supports pyramid) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/7-Bar Shape & Gap" --type chart' + f' --prop chartType=column3d' + f' --prop title="Cone Shape"' + f' --prop "series1=North:88,92,105,118,125,138"' + f' --prop "series2=West:110,118,130,145,138,162"' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop shape=cone' + f' --prop view3d=15,20,30' + f' --prop colors=70AD47,FFC000' + f' --prop legend=bottom') + +print(f"\nDone! Generated: {FILE}") +print(" 8 sheets (Sheet1 data + 7 chart sheets, 28 charts total)") diff --git a/examples/excel/charts-column.xlsx b/examples/excel/charts-column.xlsx new file mode 100644 index 000000000..c6c79362f Binary files /dev/null and b/examples/excel/charts-column.xlsx differ diff --git a/examples/excel/charts-combo.md b/examples/excel/charts-combo.md new file mode 100644 index 000000000..0b8e7bc26 --- /dev/null +++ b/examples/excel/charts-combo.md @@ -0,0 +1,152 @@ +# Combo Charts Showcase + +This demo consists of three files that work together: + +- **charts-combo.py** — Python script that calls `officecli` commands to generate the workbook. Each chart command is shown as a copyable shell command in the comments. +- **charts-combo.xlsx** — The generated workbook with 5 sheets (1 default + 4 chart sheets, 16 charts total). +- **charts-combo.md** — This file. Maps each sheet to the features it demonstrates. + +## Regenerate + +```bash +cd examples/excel +python3 charts-combo.py +# -> charts-combo.xlsx +``` + +## Chart Sheets + +### Sheet: 1-Combo Fundamentals + +Four combo charts covering comboSplit, secondaryAxis, combotypes, and combined usage. + +```bash +# Basic combo: 2 bar series + 1 line via comboSplit +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=combo \ + --prop series1="Revenue:120,145,160,180,195" \ + --prop series2="Expenses:90,100,110,115,125" \ + --prop series3="Margin %:25,31,31,36,36" \ + --prop comboSplit=2 --prop legend=bottom + +# Combo with secondary Y-axis for line series +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=combo \ + --prop comboSplit=1 --prop secondaryAxis=2 \ + --prop catTitle=Year --prop axisTitle=Sales ($K) + +# Per-series type control via combotypes +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=combo \ + --prop combotypes=column,column,line,area + +# combotypes + secondaryAxis together +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=combo \ + --prop combotypes=column,column,line \ + --prop secondaryAxis=3 +``` + +**Features:** `combo`, `comboSplit`, `secondaryAxis`, `combotypes=column,column,line,area`, `catTitle`, `axisTitle` + +### Sheet: 2-Combo Styling + +Four styled combo charts with title fonts, gradients, data labels, and chart fills. + +```bash +# Title, legend, axis font styling +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=combo \ + --prop title.font=Georgia --prop title.size=16 \ + --prop title.color=1F4E79 --prop title.bold=true \ + --prop legendfont=10:333333:Calibri --prop axisfont=9:666666 + +# Series shadow and gradients +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=combo \ + --prop 'gradients=1F4E79-5B9BD5:90;C55A11-F4B183:90' \ + --prop series.shadow=000000-4-315-2-30 + +# Data labels on combo series +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=combo \ + --prop dataLabels=true --prop labelPos=top \ + --prop labelFont=9:333333:true + +# Chart area styling +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=combo \ + --prop plotFill=F0F4F8 --prop chartFill=FAFAFA \ + --prop roundedCorners=true +``` + +**Features:** `title.font/size/color/bold`, `legendfont`, `axisfont`, `gradients`, `series.shadow`, `dataLabels`, `labelPos`, `labelFont`, `plotFill`, `chartFill`, `roundedCorners` + +### Sheet: 3-Combo Advanced + +Four advanced combo charts with reference lines, axis scaling, layout, and markers. + +```bash +# Reference line and gridlines +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=combo \ + --prop referenceLine=110:Target:C00000 \ + --prop gridlines=D9D9D9:0.5 + +# Axis scaling and display units +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=combo \ + --prop axisMin=1000000 --prop axisMax=2000000 \ + --prop dispUnits=thousands + +# Manual plot layout +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=combo \ + --prop plotLayout=0.1,0.15,0.85,0.75 + +# Multiple line series with markers +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=combo \ + --prop comboSplit=1 --prop secondaryAxis=2,3,4 \ + --prop markers=circle-6 +``` + +**Features:** `referenceLine`, `gridlines`, `axisMin/Max`, `dispUnits`, `plotLayout`, `markers`, multiple secondary axis series + +### Sheet: 4-Combo Effects + +Four effect-heavy combo charts with glow, borders, color rules, and complex multi-series. + +```bash +# Title glow and shadow effects +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=combo \ + --prop title.glow=4472C4-6 \ + --prop title.shadow=000000-3-315-2-30 + +# Chart and plot area borders +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=combo \ + --prop chartArea.border=333333-1.5 \ + --prop plotArea.border=999999-0.75 + +# Color rule (conditional bar coloring) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=combo \ + --prop colorRule=80:C00000:70AD47 + +# 5-series dashboard with mixed combotypes +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=combo \ + --prop combotypes=column,column,column,area,line \ + --prop secondaryAxis=5 +``` + +**Features:** `title.glow`, `title.shadow`, `chartArea.border`, `plotArea.border`, `colorRule`, 5-series `combotypes` + +## Inspect the Generated File + +```bash +officecli query charts-combo.xlsx chart +officecli get charts-combo.xlsx "/1-Combo Fundamentals/chart[1]" +``` diff --git a/examples/excel/charts-combo.py b/examples/excel/charts-combo.py new file mode 100644 index 000000000..be6dc10e1 --- /dev/null +++ b/examples/excel/charts-combo.py @@ -0,0 +1,583 @@ +#!/usr/bin/env python3 +""" +Combo Charts Showcase — column+line, column+area, secondary axes, and styling. + +Generates: charts-combo.xlsx + +Usage: + python3 charts-combo.py +""" + +import subprocess, sys, os, json, atexit + +FILE = "charts-combo.xlsx" + +def cli(cmd): + """Run: officecli """ + r = subprocess.run(f"officecli {cmd}", shell=True, capture_output=True, text=True) + out = (r.stdout or "").strip() + if out: + for line in out.split("\n"): + if line.strip(): + print(f" {line.strip()}") + if r.returncode != 0: + err = (r.stderr or "").strip() + if err and "UNSUPPORTED" not in err and "process cannot access" not in err: + print(f" ERROR: {err}") + +if os.path.exists(FILE): + os.remove(FILE) + +cli(f'create "{FILE}"') +cli(f'open "{FILE}"') +atexit.register(lambda: cli(f'close "{FILE}"')) + +# ========================================================================== +# Sheet: 1-Combo Fundamentals +# ========================================================================== +print("\n--- 1-Combo Fundamentals ---") +cli(f'add "{FILE}" / --type sheet --prop name="1-Combo Fundamentals"') + +# -------------------------------------------------------------------------- +# Chart 1: Basic combo with comboSplit (2 bar series + 1 line) +# +# officecli add charts-combo.xlsx "/1-Combo Fundamentals" --type chart \ +# --prop chartType=combo \ +# --prop title="Revenue vs Expenses vs Margin" \ +# --prop series1="Revenue:120,145,160,180,195" \ +# --prop series2="Expenses:90,100,110,115,125" \ +# --prop series3="Margin %:25,31,31,36,36" \ +# --prop categories=Q1,Q2,Q3,Q4,Q5 \ +# --prop comboSplit=2 \ +# --prop colors=4472C4,ED7D31,70AD47 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop legend=bottom +# +# Features: chartType=combo, comboSplit=2 (first 2 as bars, rest as lines) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Combo Fundamentals" --type chart' + f' --prop chartType=combo' + f' --prop title="Revenue vs Expenses vs Margin"' + f' --prop series1=Revenue:120,145,160,180,195' + f' --prop series2=Expenses:90,100,110,115,125' + f' --prop "series3=Margin %:25,31,31,36,36"' + f' --prop categories=Q1,Q2,Q3,Q4,Q5' + f' --prop comboSplit=2' + f' --prop colors=4472C4,ED7D31,70AD47' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 2: Combo with secondaryAxis (line on right Y-axis) +# +# officecli add charts-combo.xlsx "/1-Combo Fundamentals" --type chart \ +# --prop chartType=combo \ +# --prop title="Sales & Growth Rate" \ +# --prop series1="Sales ($K):320,380,420,510,560" \ +# --prop series2="Growth %:8,19,11,21,10" \ +# --prop categories=2021,2022,2023,2024,2025 \ +# --prop comboSplit=1 \ +# --prop secondaryAxis=2 \ +# --prop colors=2E75B6,C00000 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop legend=bottom \ +# --prop catTitle=Year --prop axisTitle=Sales ($K) +# +# Features: secondaryAxis=2 (series 2 on right Y-axis), catTitle, axisTitle +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Combo Fundamentals" --type chart' + f' --prop chartType=combo' + f' --prop title="Sales & Growth Rate"' + f' --prop "series1=Sales ($K):320,380,420,510,560"' + f' --prop "series2=Growth %:8,19,11,21,10"' + f' --prop categories=2021,2022,2023,2024,2025' + f' --prop comboSplit=1' + f' --prop secondaryAxis=2' + f' --prop colors=2E75B6,C00000' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop legend=bottom' + f' --prop catTitle=Year --prop "axisTitle=Sales ($K)"') + +# -------------------------------------------------------------------------- +# Chart 3: combotypes per-series type control +# +# officecli add charts-combo.xlsx "/1-Combo Fundamentals" --type chart \ +# --prop chartType=combo \ +# --prop title="Mixed Series Types" \ +# --prop series1="Product A:50,65,70,80,90" \ +# --prop series2="Product B:40,55,60,72,85" \ +# --prop series3="Trend:48,62,68,78,88" \ +# --prop series4="Forecast:30,40,50,55,65" \ +# --prop categories=Jan,Feb,Mar,Apr,May \ +# --prop combotypes=column,column,line,area \ +# --prop colors=4472C4,ED7D31,70AD47,BDD7EE \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop legend=bottom +# +# Features: combotypes=column,column,line,area (per-series type) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Combo Fundamentals" --type chart' + f' --prop chartType=combo' + f' --prop title="Mixed Series Types"' + f' --prop "series1=Product A:50,65,70,80,90"' + f' --prop "series2=Product B:40,55,60,72,85"' + f' --prop series3=Trend:48,62,68,78,88' + f' --prop series4=Forecast:30,40,50,55,65' + f' --prop categories=Jan,Feb,Mar,Apr,May' + f' --prop combotypes=column,column,line,area' + f' --prop colors=4472C4,ED7D31,70AD47,BDD7EE' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 4: combotypes with secondaryAxis +# +# officecli add charts-combo.xlsx "/1-Combo Fundamentals" --type chart \ +# --prop chartType=combo \ +# --prop title="Revenue Mix & Margin" \ +# --prop series1="Domestic:200,220,250,270,300" \ +# --prop series2="Export:80,95,110,130,150" \ +# --prop series3="Net Margin %:18,20,22,24,26" \ +# --prop categories=2021,2022,2023,2024,2025 \ +# --prop combotypes=column,column,line \ +# --prop secondaryAxis=3 \ +# --prop colors=4472C4,9DC3E6,C00000 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop legend=bottom \ +# --prop catTitle=Year +# +# Features: combotypes + secondaryAxis together +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Combo Fundamentals" --type chart' + f' --prop chartType=combo' + f' --prop title="Revenue Mix & Margin"' + f' --prop series1=Domestic:200,220,250,270,300' + f' --prop series2=Export:80,95,110,130,150' + f' --prop "series3=Net Margin %:18,20,22,24,26"' + f' --prop categories=2021,2022,2023,2024,2025' + f' --prop combotypes=column,column,line' + f' --prop secondaryAxis=3' + f' --prop colors=4472C4,9DC3E6,C00000' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop legend=bottom' + f' --prop catTitle=Year') + +# ========================================================================== +# Sheet: 2-Combo Styling +# ========================================================================== +print("\n--- 2-Combo Styling ---") +cli(f'add "{FILE}" / --type sheet --prop name="2-Combo Styling"') + +# -------------------------------------------------------------------------- +# Chart 1: Title, legend, axisfont styling +# +# officecli add charts-combo.xlsx "/2-Combo Styling" --type chart \ +# --prop chartType=combo \ +# --prop title="Styled Combo Chart" \ +# --prop series1="Revenue:150,175,200,220" \ +# --prop series2="COGS:100,110,130,140" \ +# --prop series3="Profit %:33,37,35,36" \ +# --prop categories=Q1,Q2,Q3,Q4 \ +# --prop comboSplit=2 \ +# --prop colors=1F4E79,5B9BD5,70AD47 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop title.font=Georgia --prop title.size=16 \ +# --prop title.color=1F4E79 --prop title.bold=true \ +# --prop legend=bottom --prop legendfont=10:333333:Calibri \ +# --prop axisfont=9:666666 +# +# Features: title.font/size/color/bold, legendfont, axisfont +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Combo Styling" --type chart' + f' --prop chartType=combo' + f' --prop title="Styled Combo Chart"' + f' --prop series1=Revenue:150,175,200,220' + f' --prop series2=COGS:100,110,130,140' + f' --prop "series3=Profit %:33,37,35,36"' + f' --prop categories=Q1,Q2,Q3,Q4' + f' --prop comboSplit=2' + f' --prop colors=1F4E79,5B9BD5,70AD47' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop title.font=Georgia --prop title.size=16' + f' --prop title.color=1F4E79 --prop title.bold=true' + f' --prop legend=bottom --prop legendfont=10:333333:Calibri' + f' --prop axisfont=9:666666') + +# -------------------------------------------------------------------------- +# Chart 2: Series shadow, gradients +# +# officecli add charts-combo.xlsx "/2-Combo Styling" --type chart \ +# --prop chartType=combo \ +# --prop title="Gradient & Shadow Effects" \ +# --prop series1="Actual:85,92,105,120,135" \ +# --prop series2="Budget:80,90,100,110,120" \ +# --prop series3="Variance:5,2,5,10,15" \ +# --prop categories=Jan,Feb,Mar,Apr,May \ +# --prop comboSplit=2 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop 'gradients=1F4E79-5B9BD5:90;C55A11-F4B183:90' \ +# --prop series.shadow=000000-4-315-2-30 \ +# --prop legend=bottom +# +# Features: gradients (per-bar-series), series.shadow +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Combo Styling" --type chart' + f' --prop chartType=combo' + f' --prop title="Gradient & Shadow Effects"' + f' --prop series1=Actual:85,92,105,120,135' + f' --prop series2=Budget:80,90,100,110,120' + f' --prop series3=Variance:5,2,5,10,15' + f' --prop categories=Jan,Feb,Mar,Apr,May' + f' --prop comboSplit=2' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop "gradients=1F4E79-5B9BD5:90;C55A11-F4B183:90"' + f' --prop series.shadow=000000-4-315-2-30' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: dataLabels on line series +# +# officecli add charts-combo.xlsx "/2-Combo Styling" --type chart \ +# --prop chartType=combo \ +# --prop title="Data Labels on Lines" \ +# --prop series1="Units:500,620,710,800" \ +# --prop series2="Avg Price:45,48,52,55" \ +# --prop categories=Q1,Q2,Q3,Q4 \ +# --prop comboSplit=1 \ +# --prop secondaryAxis=2 \ +# --prop colors=4472C4,ED7D31 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop dataLabels=true --prop labelPos=top \ +# --prop labelFont=9:333333:true \ +# --prop legend=bottom +# +# Features: dataLabels=true, labelPos=top, labelFont +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Combo Styling" --type chart' + f' --prop chartType=combo' + f' --prop title="Data Labels on Lines"' + f' --prop series1=Units:500,620,710,800' + f' --prop "series2=Avg Price:45,48,52,55"' + f' --prop categories=Q1,Q2,Q3,Q4' + f' --prop comboSplit=1' + f' --prop secondaryAxis=2' + f' --prop colors=4472C4,ED7D31' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop dataLabels=true --prop labelPos=top' + f' --prop labelFont=9:333333:true' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 4: plotFill, chartFill, roundedCorners +# +# officecli add charts-combo.xlsx "/2-Combo Styling" --type chart \ +# --prop chartType=combo \ +# --prop title="Chart Area Styling" \ +# --prop series1="Online:180,210,240,260,290" \ +# --prop series2="Retail:150,140,135,130,120" \ +# --prop series3="Growth %:5,12,15,10,12" \ +# --prop categories=2021,2022,2023,2024,2025 \ +# --prop comboSplit=2 \ +# --prop colors=2E75B6,ED7D31,70AD47 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop plotFill=F0F4F8 --prop chartFill=FAFAFA \ +# --prop roundedCorners=true \ +# --prop legend=bottom +# +# Features: plotFill, chartFill, roundedCorners +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Combo Styling" --type chart' + f' --prop chartType=combo' + f' --prop title="Chart Area Styling"' + f' --prop series1=Online:180,210,240,260,290' + f' --prop series2=Retail:150,140,135,130,120' + f' --prop "series3=Growth %:5,12,15,10,12"' + f' --prop categories=2021,2022,2023,2024,2025' + f' --prop comboSplit=2' + f' --prop colors=2E75B6,ED7D31,70AD47' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop plotFill=F0F4F8 --prop chartFill=FAFAFA' + f' --prop roundedCorners=true' + f' --prop legend=bottom') + +# ========================================================================== +# Sheet: 3-Combo Advanced +# ========================================================================== +print("\n--- 3-Combo Advanced ---") +cli(f'add "{FILE}" / --type sheet --prop name="3-Combo Advanced"') + +# -------------------------------------------------------------------------- +# Chart 1: referenceLine, gridlines +# +# officecli add charts-combo.xlsx "/3-Combo Advanced" --type chart \ +# --prop chartType=combo \ +# --prop title="Target Reference Line" \ +# --prop series1="Actual:95,105,115,125,130" \ +# --prop series2="Forecast:90,100,110,120,130" \ +# --prop categories=Jan,Feb,Mar,Apr,May \ +# --prop comboSplit=1 \ +# --prop colors=4472C4,BDD7EE \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop referenceLine=110:C00000:Target \ +# --prop gridlines=D9D9D9:0.5 \ +# --prop legend=bottom +# +# Features: referenceLine=value:label:color, gridlines +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Combo Advanced" --type chart' + f' --prop chartType=combo' + f' --prop title="Target Reference Line"' + f' --prop series1=Actual:95,105,115,125,130' + f' --prop series2=Forecast:90,100,110,120,130' + f' --prop categories=Jan,Feb,Mar,Apr,May' + f' --prop comboSplit=1' + f' --prop colors=4472C4,BDD7EE' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop referenceLine=110:C00000:Target' + f' --prop gridlines=D9D9D9:0.5' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 2: axisMin/Max, dispUnits +# +# officecli add charts-combo.xlsx "/3-Combo Advanced" --type chart \ +# --prop chartType=combo \ +# --prop title="Axis Scaling & Units" \ +# --prop series1="Revenue:1200000,1450000,1600000,1800000" \ +# --prop series2="Profit %:18,22,25,28" \ +# --prop categories=2022,2023,2024,2025 \ +# --prop comboSplit=1 \ +# --prop secondaryAxis=2 \ +# --prop colors=2E75B6,70AD47 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop axisMin=1000000 --prop axisMax=2000000 \ +# --prop dispUnits=thousands \ +# --prop legend=bottom +# +# Features: axisMin/Max, dispUnits=thousands +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Combo Advanced" --type chart' + f' --prop chartType=combo' + f' --prop title="Axis Scaling & Units"' + f' --prop series1=Revenue:1200000,1450000,1600000,1800000' + f' --prop "series2=Profit %:18,22,25,28"' + f' --prop categories=2022,2023,2024,2025' + f' --prop comboSplit=1' + f' --prop secondaryAxis=2' + f' --prop colors=2E75B6,70AD47' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop axisMin=1000000 --prop axisMax=2000000' + f' --prop dispUnits=thousands' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: Manual layout +# +# officecli add charts-combo.xlsx "/3-Combo Advanced" --type chart \ +# --prop chartType=combo \ +# --prop title="Manual Layout" \ +# --prop series1="Plan:100,120,140,160" \ +# --prop series2="Actual:95,125,135,170" \ +# --prop series3="Delta %:-5,4,-4,6" \ +# --prop categories=Q1,Q2,Q3,Q4 \ +# --prop comboSplit=2 \ +# --prop secondaryAxis=3 \ +# --prop colors=4472C4,ED7D31,70AD47 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop plotLayout=0.1,0.15,0.85,0.75 \ +# --prop legend=bottom +# +# Features: plotLayout=left,top,width,height (manual plot area) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Combo Advanced" --type chart' + f' --prop chartType=combo' + f' --prop title="Manual Layout"' + f' --prop series1=Plan:100,120,140,160' + f' --prop series2=Actual:95,125,135,170' + f' --prop "series3=Delta %:-5,4,-4,6"' + f' --prop categories=Q1,Q2,Q3,Q4' + f' --prop comboSplit=2' + f' --prop secondaryAxis=3' + f' --prop colors=4472C4,ED7D31,70AD47' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop plotLayout=0.1,0.15,0.85,0.75' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 4: Multiple line series with markers + bar series +# +# officecli add charts-combo.xlsx "/3-Combo Advanced" --type chart \ +# --prop chartType=combo \ +# --prop title="Multi-Line with Markers" \ +# --prop series1="Units Sold:800,920,1050,1200,1350" \ +# --prop series2="North:30,35,38,42,45" \ +# --prop series3="South:25,28,32,36,40" \ +# --prop series4="West:20,24,28,32,35" \ +# --prop categories=Q1,Q2,Q3,Q4,Q5 \ +# --prop comboSplit=1 \ +# --prop secondaryAxis=2,3,4 \ +# --prop colors=4472C4,C00000,70AD47,FFC000 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop markers=circle-6 \ +# --prop legend=bottom +# +# Features: multiple line series on secondary axis, markers +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Combo Advanced" --type chart' + f' --prop chartType=combo' + f' --prop title="Multi-Line with Markers"' + f' --prop "series1=Units Sold:800,920,1050,1200,1350"' + f' --prop series2=North:30,35,38,42,45' + f' --prop series3=South:25,28,32,36,40' + f' --prop series4=West:20,24,28,32,35' + f' --prop categories=Q1,Q2,Q3,Q4,Q5' + f' --prop comboSplit=1' + f' --prop secondaryAxis=2,3,4' + f' --prop colors=4472C4,C00000,70AD47,FFC000' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop markers=circle-6' + f' --prop legend=bottom') + +# ========================================================================== +# Sheet: 4-Combo Effects +# ========================================================================== +print("\n--- 4-Combo Effects ---") +cli(f'add "{FILE}" / --type sheet --prop name="4-Combo Effects"') + +# -------------------------------------------------------------------------- +# Chart 1: title.glow, title.shadow +# +# officecli add charts-combo.xlsx "/4-Combo Effects" --type chart \ +# --prop chartType=combo \ +# --prop title="Glowing Title" \ +# --prop series1="Metric A:60,72,85,90,100" \ +# --prop series2="Metric B:40,50,55,62,70" \ +# --prop series3="Ratio:67,69,65,69,70" \ +# --prop categories=W1,W2,W3,W4,W5 \ +# --prop comboSplit=2 \ +# --prop colors=4472C4,ED7D31,70AD47 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop title.glow=4472C4-6 \ +# --prop title.shadow=000000-3-315-2-30 \ +# --prop legend=bottom +# +# Features: title.glow=color-radius, title.shadow +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Combo Effects" --type chart' + f' --prop chartType=combo' + f' --prop title="Glowing Title"' + f' --prop "series1=Metric A:60,72,85,90,100"' + f' --prop "series2=Metric B:40,50,55,62,70"' + f' --prop series3=Ratio:67,69,65,69,70' + f' --prop categories=W1,W2,W3,W4,W5' + f' --prop comboSplit=2' + f' --prop colors=4472C4,ED7D31,70AD47' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop title.glow=4472C4-6' + f' --prop title.shadow=000000-3-315-2-30' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 2: chartArea.border, plotArea.border +# +# officecli add charts-combo.xlsx "/4-Combo Effects" --type chart \ +# --prop chartType=combo \ +# --prop title="Bordered Areas" \ +# --prop series1="Income:250,280,310,340" \ +# --prop series2="Costs:180,195,210,225" \ +# --prop series3="Margin %:28,30,32,34" \ +# --prop categories=Q1,Q2,Q3,Q4 \ +# --prop comboSplit=2 \ +# --prop colors=2E75B6,ED7D31,548235 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop chartArea.border=333333:1.5 \ +# --prop plotArea.border=999999:0.75 \ +# --prop legend=bottom +# +# Features: chartArea.border=color-width, plotArea.border +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Combo Effects" --type chart' + f' --prop chartType=combo' + f' --prop title="Bordered Areas"' + f' --prop series1=Income:250,280,310,340' + f' --prop series2=Costs:180,195,210,225' + f' --prop "series3=Margin %:28,30,32,34"' + f' --prop categories=Q1,Q2,Q3,Q4' + f' --prop comboSplit=2' + f' --prop colors=2E75B6,ED7D31,548235' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop chartArea.border=333333:1.5' + f' --prop plotArea.border=999999:0.75' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: colorRule +# +# officecli add charts-combo.xlsx "/4-Combo Effects" --type chart \ +# --prop chartType=combo \ +# --prop title="Color Rule Combo" \ +# --prop series1="Performance:72,85,65,90,78" \ +# --prop series2="Target:80,80,80,80,80" \ +# --prop categories=Team A,Team B,Team C,Team D,Team E \ +# --prop comboSplit=1 \ +# --prop colors=4472C4,C00000 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop colorRule=80:C00000:70AD47 \ +# --prop legend=bottom +# +# Features: colorRule=threshold:belowColor:aboveColor +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Combo Effects" --type chart' + f' --prop chartType=combo' + f' --prop title="Color Rule Combo"' + f' --prop series1=Performance:72,85,65,90,78' + f' --prop series2=Target:80,80,80,80,80' + f' --prop "categories=Team A,Team B,Team C,Team D,Team E"' + f' --prop comboSplit=1' + f' --prop colors=4472C4,C00000' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop colorRule=80:C00000:70AD47' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 4: Complex combo with 5+ series +# +# officecli add charts-combo.xlsx "/4-Combo Effects" --type chart \ +# --prop chartType=combo \ +# --prop title="Full Business Dashboard" \ +# --prop series1="Revenue:500,550,600,650,700" \ +# --prop series2="COGS:300,320,340,360,380" \ +# --prop series3="OpEx:100,105,110,115,120" \ +# --prop series4="Net Income:100,125,150,175,200" \ +# --prop series5="Margin %:20,23,25,27,29" \ +# --prop categories=2021,2022,2023,2024,2025 \ +# --prop combotypes=column,column,column,area,line \ +# --prop secondaryAxis=5 \ +# --prop colors=4472C4,ED7D31,A5A5A5,BDD7EE,C00000 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop legend=bottom \ +# --prop gridlines=E0E0E0:0.5 +# +# Features: 5 series, mixed combotypes, secondary axis +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Combo Effects" --type chart' + f' --prop chartType=combo' + f' --prop title="Full Business Dashboard"' + f' --prop series1=Revenue:500,550,600,650,700' + f' --prop series2=COGS:300,320,340,360,380' + f' --prop series3=OpEx:100,105,110,115,120' + f' --prop "series4=Net Income:100,125,150,175,200"' + f' --prop "series5=Margin %:20,23,25,27,29"' + f' --prop categories=2021,2022,2023,2024,2025' + f' --prop combotypes=column,column,column,area,line' + f' --prop secondaryAxis=5' + f' --prop colors=4472C4,ED7D31,A5A5A5,BDD7EE,C00000' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop legend=bottom' + f' --prop gridlines=E0E0E0:0.5') + +# Remove blank default Sheet1 (all data is inline) +cli(f'remove "{FILE}" /Sheet1') + +print(f"\nDone! Generated: {FILE}") +print(" 5 sheets (4 chart sheets, 16 charts total)") diff --git a/examples/excel/charts-combo.xlsx b/examples/excel/charts-combo.xlsx new file mode 100644 index 000000000..4f56e30b8 Binary files /dev/null and b/examples/excel/charts-combo.xlsx differ diff --git a/examples/excel/charts-demo.md b/examples/excel/charts-demo.md new file mode 100644 index 000000000..09fb5b1d2 --- /dev/null +++ b/examples/excel/charts-demo.md @@ -0,0 +1,6 @@ +# charts-demo + +TODO: rewrite script with high-level chart API, add annotated officecli commands. + +See [charts-demo.sh](charts-demo.sh) and [charts-demo.xlsx](charts-demo.xlsx). + diff --git a/examples/excel/gen-charts-demo.sh b/examples/excel/charts-demo.sh similarity index 100% rename from examples/excel/gen-charts-demo.sh rename to examples/excel/charts-demo.sh diff --git a/examples/excel/outputs/charts_demo.xlsx b/examples/excel/charts-demo.xlsx similarity index 100% rename from examples/excel/outputs/charts_demo.xlsx rename to examples/excel/charts-demo.xlsx diff --git a/examples/excel/charts-extended.md b/examples/excel/charts-extended.md new file mode 100644 index 000000000..f77bd91ff --- /dev/null +++ b/examples/excel/charts-extended.md @@ -0,0 +1,282 @@ +# Extended Chart Types Showcase + +This demo consists of three files that work together: + +- **charts-extended.py** — Python script that calls `officecli` commands to generate the workbook. Each chart command is shown as a copyable shell command in the comments. +- **charts-extended.xlsx** — The generated workbook: 3 sheets, 14 charts, covering every property supported by the cx:chart family (waterfall, funnel, treemap, sunburst, histogram, boxWhisker). +- **charts-extended.md** — This file. Maps each sheet to the features it demonstrates. + +## Regenerate + +```bash +cd examples/excel +python3 charts-extended.py +# → charts-extended.xlsx +``` + +## Feature Coverage Summary + +Every extended-chart-specific knob is exercised by at least one chart: + +| Chart type | Specific knobs | Covered by | +|---|---|---| +| waterfall | `increaseColor`, `decreaseColor`, `totalColor`, `chartFill`, `labelFont` | Sheet 1, Chart 1–2 | +| funnel | (generic styling only) | Sheet 1, Chart 3–4 | +| pareto | auto-sort desc, `ownerIdx` cumulative-% line, secondary % axis | Sheet 4, Chart 1–2 | +| treemap | `parentLabelLayout` = `overlapping` / `banner` / `none` | Sheet 2, Chart 1/2/3 | +| sunburst | (generic styling only) | Sheet 2, Chart 4 | +| histogram | `binCount`, `binSize`, `intervalClosed` = `r` / `l`, `underflowBin`, `overflowBin` | Sheet 3, Chart 1–4 | +| boxWhisker | `quartileMethod` = `exclusive` / `inclusive` | Sheet 3, Chart 5–6 | + +Generic cx styling exercised across the deck: `title.glow`, `title.shadow`, `title.bold`/`size`/`color`, `dataLabels`, `labelFont`, `legend` position, `legendfont`, `axisfont`, `colors` palette, `chartFill`, `plotFill`. + +> **Notes on cx:chart limitations:** +> +> - `chartFill` / `plotFill` only accept a **solid** hex color (or `none`). Unlike regular cChart, gradient `C1-C2:angle` is not supported. +> - `colors=` palette **does not work per-data-point** on single-series cx charts (funnel, treemap, sunburst). OfficeCli only applies the first palette color to the whole series, so every bar/tile/segment ends up the same color. Omit `colors=` on these charts and let Excel's theme drive the default rainbow. `colors=` still works normally on multi-series cx charts (boxWhisker) and on all regular cChart types. + +--- + +## Sheet: 1-Waterfall & Funnel + +Two waterfall charts (financial bridges) and two funnel charts (pipelines). + +```bash +# Chart 1 — waterfall with increase/decrease/total colors + data labels + title glow +officecli add charts-extended.xlsx "/1-Waterfall & Funnel" --type chart \ + --prop chartType=waterfall \ + --prop title="Cash Flow Bridge" \ + --prop data="Start:1000,Revenue:500,Costs:-300,Tax:-100,Net:1100" \ + --prop increaseColor=70AD47 --prop decreaseColor=FF0000 --prop totalColor=4472C4 \ + --prop dataLabels=true \ + --prop title.glow="00D2FF-6-60" + +# Chart 2 — waterfall with legend + chartFill (solid) + custom label font +officecli add charts-extended.xlsx "/1-Waterfall & Funnel" --type chart \ + --prop chartType=waterfall \ + --prop title="Budget vs Actual" \ + --prop data="Budget:5000,Sales:2000,Marketing:-800,Ops:-600,Net:5600" \ + --prop increaseColor=2E75B6 --prop decreaseColor=C00000 --prop totalColor=FFC000 \ + --prop legend=bottom \ + --prop chartFill=F0F4FA \ + --prop dataLabels=true \ + --prop labelFont="9:333333:true" + +# Chart 3 — funnel (sales pipeline) with title shadow +officecli add charts-extended.xlsx "/1-Waterfall & Funnel" --type chart \ + --prop chartType=funnel \ + --prop title="Sales Pipeline" \ + --prop series1="Pipeline:1200,850,600,300,120" \ + --prop categories=Leads,Qualified,Proposal,Negotiation,Won \ + --prop dataLabels=true \ + --prop title.shadow="000000-4-45-2-40" + +# Chart 4 — funnel (marketing) with custom colors palette, legend/axis fonts +officecli add charts-extended.xlsx "/1-Waterfall & Funnel" --type chart \ + --prop chartType=funnel \ + --prop title="Marketing Funnel" \ + --prop series1="Users:10000,6500,3200,1800,900,450" \ + --prop categories=Impressions,Clicks,Signups,Active,Paying,Retained \ + --prop dataLabels=true \ + --prop legendfont="9:8B949E:Helvetica Neue" \ + --prop axisfont="10:58626E:Helvetica Neue" +``` + +**Features:** `chartType=waterfall`, `increaseColor`, `decreaseColor`, `totalColor`, `chartType=funnel`, descending pipeline values, `dataLabels`, `title.glow`, `title.shadow`, `legend=bottom`, `chartFill` (solid hex), `labelFont`, `colors` palette, `legendfont`, `axisfont`. + +--- + +## Sheet: 2-Treemap & Sunburst + +Three treemaps (one per `parentLabelLayout` value) and one sunburst. + +```bash +# Chart 1 — treemap with parentLabelLayout=overlapping + dataLabels +officecli add charts-extended.xlsx "/2-Treemap & Sunburst" --type chart \ + --prop chartType=treemap \ + --prop title="Revenue by Product" \ + --prop series1="Revenue:450,380,310,280,210,180,150,120" \ + --prop categories=Laptops,Phones,Tablets,TVs,Cameras,Audio,Gaming,Wearables \ + --prop parentLabelLayout=overlapping \ + --prop dataLabels=true + +# Chart 2 — treemap with parentLabelLayout=banner + title styling +officecli add charts-extended.xlsx "/2-Treemap & Sunburst" --type chart \ + --prop chartType=treemap \ + --prop title="Department Budget" \ + --prop series1="Budget:900,750,600,500,420,350,280" \ + --prop categories=Engineering,Sales,Marketing,Support,Finance,HR,Legal \ + --prop parentLabelLayout=banner \ + --prop title.bold=true --prop title.size=14 --prop title.color=2E5090 + +# Chart 3 — treemap with parentLabelLayout=none (flat, no parent header strip) +officecli add charts-extended.xlsx "/2-Treemap & Sunburst" --type chart \ + --prop chartType=treemap \ + --prop title="Flat Treemap (no parent labels)" \ + --prop series1="Units:250,200,180,160,140,120,100,80,60,40" \ + --prop categories=A,B,C,D,E,F,G,H,I,J \ + --prop parentLabelLayout=none \ + --prop dataLabels=true + +# Chart 4 — sunburst with chartFill + plotFill (solid) + colors palette +officecli add charts-extended.xlsx "/2-Treemap & Sunburst" --type chart \ + --prop chartType=sunburst \ + --prop title="Market Share by Region" \ + --prop series1="Share:35,25,20,15,30,25,20,10,15" \ + --prop categories=North,South,East,West,Urban,Suburban,Rural,Online,Retail \ + --prop chartFill=F8FAFC --prop plotFill=FFFFFF \ + --prop dataLabels=true +``` + +**Features:** `chartType=treemap`, `parentLabelLayout=overlapping`, `parentLabelLayout=banner`, `parentLabelLayout=none`, `chartType=sunburst`, radial hierarchical layout, `colors` palette, `title.bold`/`size`/`color`, `dataLabels`, `chartFill` + `plotFill` (solid). + +--- + +## Sheet: 3-Histogram & BoxWhisker + +Four histograms covering every binning knob, and two box-and-whisker charts (one per quartile method). + +```bash +# Chart 1 — histogram with auto-binning (no binCount/binSize) +officecli add charts-extended.xlsx "/3-Histogram & BoxWhisker" --type chart \ + --prop chartType=histogram \ + --prop title="Test Scores (auto bins)" \ + --prop series1="Scores:45,52,58,61,63,...,95,97,99" + +# Chart 2 — histogram with explicit binCount=5 + title glow +officecli add charts-extended.xlsx "/3-Histogram & BoxWhisker" --type chart \ + --prop chartType=histogram \ + --prop title="Sales (binCount=5)" \ + --prop series1="Sales:120,135,...,620,700" \ + --prop binCount=5 \ + --prop title.glow="FFC000-6-50" + +# Chart 3 — histogram with explicit binSize=50 (fixed bin width) + label font +officecli add charts-extended.xlsx "/3-Histogram & BoxWhisker" --type chart \ + --prop chartType=histogram \ + --prop title="Sales (binSize=50)" \ + --prop series1="Sales:120,135,...,620,700" \ + --prop binSize=50 \ + --prop dataLabels=true --prop labelFont="9:FFFFFF:true" + +# Chart 4 — histogram with underflowBin + overflowBin + intervalClosed=l +officecli add charts-extended.xlsx "/3-Histogram & BoxWhisker" --type chart \ + --prop chartType=histogram \ + --prop title="Response Time (outlier bins)" \ + --prop series1="ms:40,55,68,75,...,220,280,350" \ + --prop underflowBin=60 \ + --prop overflowBin=200 \ + --prop intervalClosed=l \ + --prop dataLabels=true \ + --prop legend=none + +# Chart 5 — box & whisker, two teams, quartileMethod=exclusive +officecli add charts-extended.xlsx "/3-Histogram & BoxWhisker" --type chart \ + --prop chartType=boxWhisker \ + --prop title="Response Time by Team (ms)" \ + --prop series1="TeamA:42,55,...,105,120" \ + --prop series2="TeamB:30,38,...,92,110" \ + --prop quartileMethod=exclusive \ + --prop legend=bottom + +# Chart 6 — box & whisker, three departments, quartileMethod=inclusive + title glow +officecli add charts-extended.xlsx "/3-Histogram & BoxWhisker" --type chart \ + --prop chartType=boxWhisker \ + --prop title="Salary Distribution (\$k)" \ + --prop series1="Engineering:85,92,...,150,180" \ + --prop series2="Marketing:60,65,...,98,110" \ + --prop series3="Sales:55,62,...,160,190" \ + --prop quartileMethod=inclusive \ + --prop title.glow="00D2FF-6-60" \ + --prop legend=bottom +``` + +**Features:** `chartType=histogram`, auto-binning, `binCount` (explicit count), `binSize` (explicit width — mutually exclusive with `binCount`), `underflowBin` (cutoff for `N`), `intervalClosed=r` (default, `(a,b]`) vs `intervalClosed=l` (`[a,b)`), `chartType=boxWhisker`, `quartileMethod=exclusive`, `quartileMethod=inclusive`, multi-series grouping (2 or 3), `title.glow`, `legend=bottom`, `legend=none`, `labelFont`, `dataLabels`. + +--- + +## Sheet: 4-Pareto + +Two Pareto charts demonstrating automatic descending sort and cumulative-% overlay line. + +```bash +# Chart 1 — categorical Pareto (defect analysis), pre-sorted input +officecli add charts-extended.xlsx "/4-Pareto" --type chart \ + --prop chartType=pareto \ + --prop title="Defect Pareto" \ + --prop series1="Count:45,30,10,8,5,2" \ + --prop categories=Scratches,Dents,Cracks,Chips,Stains,Other \ + --prop dataLabels=true + +# Chart 2 — Pareto with out-of-order input (auto-sorted desc by officecli) +officecli add charts-extended.xlsx "/4-Pareto" --type chart \ + --prop chartType=pareto \ + --prop title="Root Cause Pareto" \ + --prop series1="Tickets:12,87,5,45,3,120,22,67,8,31" \ + --prop categories=Network,Auth,DB,Cache,UI,Config,Deploy,Monitor,Queue,Storage \ + --prop title.glow="FFC000-6-50" \ + --prop legend=bottom +``` + +**Features:** `chartType=pareto`, automatic descending sort of values + categories, cumulative-% overlay line on secondary 0-100% axis (auto-generated via `ownerIdx`), `dataLabels`, `title.glow`, `legend=bottom`. Input is a SINGLE user series; officecli synthesizes the 2-series structure internally (clusteredColumn bars + paretoLine with `ownerIdx="0"` + secondary percentage axis). + +--- + +## Property Reference + +| Property | Applies to | Example value | Sheet | +|---|---|---|---| +| `chartType=waterfall` | waterfall | `waterfall` | 1 | +| `chartType=funnel` | funnel | `funnel` | 1 | +| `chartType=treemap` | treemap | `treemap` | 2 | +| `chartType=sunburst` | sunburst | `sunburst` | 2 | +| `chartType=histogram` | histogram | `histogram` | 3 | +| `chartType=boxWhisker` | boxWhisker | `boxWhisker` | 3 | +| `chartType=pareto` | pareto | `pareto` | 4 | +| `data=` name:value pairs | waterfall | `Start:1000,Revenue:500,...` | 1 | +| `increaseColor` | waterfall | `70AD47` | 1 | +| `decreaseColor` | waterfall | `FF0000` | 1 | +| `totalColor` | waterfall | `4472C4` | 1 | +| `series1=Name:values`, `series2=...`, `series3=...` | all cx | `TeamA:42,55,...` | 1/2/3 | +| `categories` | all cx except histogram | `Leads,Qualified,...` | 1/2 | +| `parentLabelLayout` | treemap | `overlapping` \| `banner` \| `none` | 2 | +| `binCount` | histogram | `5` | 3 | +| `binSize` | histogram | `50` | 3 | +| `intervalClosed` | histogram | `r` (default) \| `l` | 3 | +| `underflowBin` | histogram | `60` | 3 | +| `overflowBin` | histogram | `200` | 3 | +| `quartileMethod` | boxWhisker | `exclusive` \| `inclusive` | 3 | +| `dataLabels` | all cx | `true` | 1/2/3 | +| `labelFont` | all cx | `"9:FFFFFF:true"` | 1/3 | +| `title.glow` | all cx | `"00D2FF-6-60"` | 1/3 | +| `title.shadow` | all cx | `"000000-4-45-2-40"` | 1 | +| `title.bold`/`size`/`color` | all cx | `true` / `14` / `2E5090` | 2 | +| `legend` | all cx | `bottom` \| `none` | 1/3 | +| `legendfont` | all cx | `"9:8B949E:Helvetica Neue"` | 1 | +| `axisfont` | all cx | `"10:58626E:Helvetica Neue"` | 1 | +| `colors` | multi-series cx only (not useful on funnel/treemap/sunburst — see limitations note) | `4472C4,5B9BD5,...` | — | +| `chartFill` (solid only) | all cx | `F8FAFC` | 1/2 | +| `plotFill` (solid only) | all cx | `FFFFFF` | 2 | + +--- + +## Known Validation Warning + +`officecli validate charts-extended.xlsx` reports schema warnings on histogram charts' `binCount` / `binSize` elements: + +``` +[Schema] The element '...:binCount' has invalid value ''. The text value cannot be empty. +[Schema] The 'val' attribute is not declared. +``` + +This is expected. The Open XML SDK's generated schema models `cx:binCount` as a text-valued leaf (`5`), but **real Excel writes and requires** the attribute form (``). OfficeCli writes the Excel-compatible form via a raw unknown element; the SDK validator then complains. See `ChartExBuilder.cs:793–801` for the rationale. Files open and render correctly in Excel. + +--- + +## Inspect the Generated File + +```bash +officecli query charts-extended.xlsx chart +officecli get charts-extended.xlsx "/1-Waterfall & Funnel/chart[1]" +officecli view charts-extended.xlsx outline +``` diff --git a/examples/excel/charts-extended.py b/examples/excel/charts-extended.py new file mode 100644 index 000000000..73f133adf --- /dev/null +++ b/examples/excel/charts-extended.py @@ -0,0 +1,474 @@ +#!/usr/bin/env python3 +""" +Extended Chart Types Showcase — full feature coverage for waterfall, funnel, +treemap, sunburst, histogram, boxWhisker (cx:chart family). + +Covers every extended-chart-specific property plus representative generic +cx styling knobs (title.glow, chartFill gradient, legendfont, dataLabels...). + +Generates: charts-extended.xlsx + +Usage: + python3 charts-extended.py +""" + +import subprocess, sys, os, atexit + +FILE = "charts-extended.xlsx" + +def cli(cmd): + """Run: officecli """ + r = subprocess.run(f"officecli {cmd}", shell=True, capture_output=True, text=True) + out = (r.stdout or "").strip() + if out: + for line in out.split("\n"): + if line.strip(): + print(f" {line.strip()}") + if r.returncode != 0: + err = (r.stderr or "").strip() + if err and "UNSUPPORTED" not in err and "process cannot access" not in err: + print(f" ERROR: {err}") + +if os.path.exists(FILE): + os.remove(FILE) + +cli(f'create "{FILE}"') +cli(f'open "{FILE}"') +atexit.register(lambda: cli(f'close "{FILE}"')) + +# ========================================================================== +# Sheet 1: Waterfall & Funnel +# ========================================================================== +print("\n--- 1-Waterfall & Funnel ---") +cli(f'add "{FILE}" / --type sheet --prop name="1-Waterfall & Funnel"') + +# -------------------------------------------------------------------------- +# Chart 1: Waterfall — increase/decrease/total colors + data labels + title glow +# +# officecli add charts-extended.xlsx "/1-Waterfall & Funnel" --type chart \ +# --prop chartType=waterfall \ +# --prop title="Cash Flow Bridge" \ +# --prop data="Start:1000,Revenue:500,Costs:-300,Tax:-100,Net:1100" \ +# --prop increaseColor=70AD47 \ +# --prop decreaseColor=FF0000 \ +# --prop totalColor=4472C4 \ +# --prop dataLabels=true \ +# --prop title.glow="00D2FF-6-60" \ +# --prop x=0 --prop y=0 --prop width=13 --prop height=18 +# +# Features: chartType=waterfall, increaseColor, decreaseColor, totalColor, +# dataLabels, title.glow +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Waterfall & Funnel" --type chart' + f' --prop chartType=waterfall' + f' --prop title="Cash Flow Bridge"' + f' --prop data=Start:1000,Revenue:500,Costs:-300,Tax:-100,Net:1100' + f' --prop increaseColor=70AD47' + f' --prop decreaseColor=FF0000' + f' --prop totalColor=4472C4' + f' --prop dataLabels=true' + f' --prop title.glow=00D2FF-6-60' + f' --prop x=0 --prop y=0 --prop width=13 --prop height=18') + +# -------------------------------------------------------------------------- +# Chart 2: Waterfall — chart-area gradient fill + legend + custom label font +# +# officecli add charts-extended.xlsx "/1-Waterfall & Funnel" --type chart \ +# --prop chartType=waterfall \ +# --prop title="Budget vs Actual" \ +# --prop data="Budget:5000,Sales:2000,Marketing:-800,Ops:-600,Net:5600" \ +# --prop increaseColor=2E75B6 \ +# --prop decreaseColor=C00000 \ +# --prop totalColor=FFC000 \ +# --prop legend=bottom \ +# --prop chartFill=F0F4FA \ +# --prop dataLabels=true \ +# --prop labelFont="9:333333:true" +# +# Features: waterfall with legend=bottom, chartFill (solid hex — cx charts +# don't support gradient fills, use plain RGB), labelFont "size:color:bold" +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Waterfall & Funnel" --type chart' + f' --prop chartType=waterfall' + f' --prop title="Budget vs Actual"' + f' --prop data=Budget:5000,Sales:2000,Marketing:-800,Ops:-600,Net:5600' + f' --prop increaseColor=2E75B6' + f' --prop decreaseColor=C00000' + f' --prop totalColor=FFC000' + f' --prop legend=bottom' + f' --prop chartFill=F0F4FA' + f' --prop dataLabels=true' + f' --prop labelFont=9:333333:true' + f' --prop x=14 --prop y=0 --prop width=13 --prop height=18') + +# -------------------------------------------------------------------------- +# Chart 3: Funnel — sales pipeline with title shadow +# +# officecli add charts-extended.xlsx "/1-Waterfall & Funnel" --type chart \ +# --prop chartType=funnel \ +# --prop title="Sales Pipeline" \ +# --prop series1="Pipeline:1200,850,600,300,120" \ +# --prop categories=Leads,Qualified,Proposal,Negotiation,Won \ +# --prop dataLabels=true \ +# --prop title.shadow="000000-4-45-2-40" +# +# Features: chartType=funnel, descending pipeline values, dataLabels, +# title.shadow "COLOR-BLUR-ANGLE-DIST-OPACITY" +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Waterfall & Funnel" --type chart' + f' --prop chartType=funnel' + f' --prop title="Sales Pipeline"' + f' --prop series1=Pipeline:1200,850,600,300,120' + f' --prop categories=Leads,Qualified,Proposal,Negotiation,Won' + f' --prop dataLabels=true' + f' --prop title.shadow=000000-4-45-2-40' + f' --prop x=0 --prop y=19 --prop width=13 --prop height=18') + +# -------------------------------------------------------------------------- +# Chart 4: Funnel — marketing conversion + legend/axis fonts + axis titles +# +# officecli add charts-extended.xlsx "/1-Waterfall & Funnel" --type chart \ +# --prop chartType=funnel \ +# --prop title="Marketing Funnel" \ +# --prop series1="Users:10000,6500,3200,1800,900,450" \ +# --prop categories=Impressions,Clicks,Signups,Active,Paying,Retained \ +# --prop dataLabels=true \ +# --prop legendfont="9:8B949E:Helvetica Neue" \ +# --prop axisfont="10:58626E:Helvetica Neue" +# +# Features: funnel, legendfont "size:color:fontname", axisfont, +# 6-stage pipeline, dataLabels +# +# NOTE: `colors=` palette is intentionally omitted here. On cx:chart single- +# series types (funnel/treemap/sunburst) the CLI only applies the first +# palette color to the whole series, so all bars would render the same +# color. Let Excel's theme pick the default accent color. +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Waterfall & Funnel" --type chart' + f' --prop chartType=funnel' + f' --prop title="Marketing Funnel"' + f' --prop series1=Users:10000,6500,3200,1800,900,450' + f' --prop categories=Impressions,Clicks,Signups,Active,Paying,Retained' + f' --prop dataLabels=true' + f' --prop "legendfont=9:8B949E:Helvetica Neue"' + f' --prop "axisfont=10:58626E:Helvetica Neue"' + f' --prop x=14 --prop y=19 --prop width=13 --prop height=18') + +# ========================================================================== +# Sheet 2: Treemap & Sunburst +# ========================================================================== +print("\n--- 2-Treemap & Sunburst ---") +cli(f'add "{FILE}" / --type sheet --prop name="2-Treemap & Sunburst"') + +# -------------------------------------------------------------------------- +# Chart 1: Treemap — parentLabelLayout=overlapping + dataLabels +# +# officecli add charts-extended.xlsx "/2-Treemap & Sunburst" --type chart \ +# --prop chartType=treemap \ +# --prop title="Revenue by Product" \ +# --prop series1="Revenue:450,380,310,280,210,180,150,120" \ +# --prop categories=Laptops,Phones,Tablets,TVs,Cameras,Audio,Gaming,Wearables \ +# --prop parentLabelLayout=overlapping \ +# --prop dataLabels=true +# +# Features: chartType=treemap, parentLabelLayout=overlapping, dataLabels. +# NOTE: `colors=` is omitted — see Funnel Chart 4 note: cx single-series +# charts only pick up the first palette color. Excel's theme will auto- +# rainbow the tiles instead. +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Treemap & Sunburst" --type chart' + f' --prop chartType=treemap' + f' --prop title="Revenue by Product"' + f' --prop series1=Revenue:450,380,310,280,210,180,150,120' + f' --prop categories=Laptops,Phones,Tablets,TVs,Cameras,Audio,Gaming,Wearables' + f' --prop parentLabelLayout=overlapping' + f' --prop dataLabels=true' + f' --prop x=0 --prop y=0 --prop width=13 --prop height=18') + +# -------------------------------------------------------------------------- +# Chart 2: Treemap — parentLabelLayout=banner + bold title +# +# officecli add charts-extended.xlsx "/2-Treemap & Sunburst" --type chart \ +# --prop chartType=treemap \ +# --prop title="Department Budget" \ +# --prop series1="Budget:900,750,600,500,420,350,280" \ +# --prop categories=Engineering,Sales,Marketing,Support,Finance,HR,Legal \ +# --prop parentLabelLayout=banner \ +# --prop title.bold=true \ +# --prop title.size=14 \ +# --prop title.color=2E5090 +# +# Features: treemap parentLabelLayout=banner, title.bold/size/color +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Treemap & Sunburst" --type chart' + f' --prop chartType=treemap' + f' --prop title="Department Budget"' + f' --prop series1=Budget:900,750,600,500,420,350,280' + f' --prop categories=Engineering,Sales,Marketing,Support,Finance,HR,Legal' + f' --prop parentLabelLayout=banner' + f' --prop title.bold=true' + f' --prop title.size=14' + f' --prop title.color=2E5090' + f' --prop x=14 --prop y=0 --prop width=13 --prop height=18') + +# -------------------------------------------------------------------------- +# Chart 3: Treemap — parentLabelLayout=none (no parent label strip) +# +# officecli add charts-extended.xlsx "/2-Treemap & Sunburst" --type chart \ +# --prop chartType=treemap \ +# --prop title="Flat Treemap (no parent labels)" \ +# --prop series1="Units:250,200,180,160,140,120,100,80,60,40" \ +# --prop categories=A,B,C,D,E,F,G,H,I,J \ +# --prop parentLabelLayout=none \ +# --prop dataLabels=true +# +# Features: treemap parentLabelLayout=none (all labels inline, no header strip), +# dataLabels on leaf tiles +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Treemap & Sunburst" --type chart' + f' --prop chartType=treemap' + f' --prop title="Flat Treemap (no parent labels)"' + f' --prop series1=Units:250,200,180,160,140,120,100,80,60,40' + f' --prop categories=A,B,C,D,E,F,G,H,I,J' + f' --prop parentLabelLayout=none' + f' --prop dataLabels=true' + f' --prop x=0 --prop y=19 --prop width=13 --prop height=18') + +# -------------------------------------------------------------------------- +# Chart 4: Sunburst — radial hierarchy + chartFill (solid) + plotFill +# +# officecli add charts-extended.xlsx "/2-Treemap & Sunburst" --type chart \ +# --prop chartType=sunburst \ +# --prop title="Market Share by Region" \ +# --prop series1="Share:35,25,20,15,30,25,20,10,15" \ +# --prop categories=North,South,East,West,Urban,Suburban,Rural,Online,Retail \ +# --prop chartFill=F8FAFC \ +# --prop plotFill=FFFFFF \ +# --prop dataLabels=true +# +# Features: chartType=sunburst, radial hierarchical layout, chartFill (solid hex), +# plotFill (solid hex), dataLabels. +# NOTE 1: cx:chart's chart/plot fill only accepts solid color — not gradient +# (unlike regular cChart). Use a single hex like "F8FAFC" or "none". +# NOTE 2: `colors=` palette is omitted for the same reason as the funnel/ +# treemap examples — cx single-series charts paint only the first palette +# entry. Let Excel's theme drive per-segment coloring. +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Treemap & Sunburst" --type chart' + f' --prop chartType=sunburst' + f' --prop title="Market Share by Region"' + f' --prop series1=Share:35,25,20,15,30,25,20,10,15' + f' --prop categories=North,South,East,West,Urban,Suburban,Rural,Online,Retail' + f' --prop chartFill=F8FAFC' + f' --prop plotFill=FFFFFF' + f' --prop dataLabels=true' + f' --prop x=14 --prop y=19 --prop width=13 --prop height=18') + +# ========================================================================== +# Sheet 3: Histogram & Box Whisker +# ========================================================================== +print("\n--- 3-Histogram & BoxWhisker ---") +cli(f'add "{FILE}" / --type sheet --prop name="3-Histogram & BoxWhisker"') + +# -------------------------------------------------------------------------- +# Chart 1: Histogram — auto-binning (Excel picks bin count) +# +# officecli add charts-extended.xlsx "/3-Histogram & BoxWhisker" --type chart \ +# --prop chartType=histogram \ +# --prop title="Test Scores (auto bins)" \ +# --prop series1="Scores:45,52,58,61,63,65,67,68,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,97,99" +# +# Features: chartType=histogram, no binning knobs → Excel auto-selects bins +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Histogram & BoxWhisker" --type chart' + f' --prop chartType=histogram' + f' --prop title="Test Scores (auto bins)"' + f' --prop series1=Scores:45,52,58,61,63,65,67,68,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,97,99' + f' --prop x=0 --prop y=0 --prop width=13 --prop height=18') + +# -------------------------------------------------------------------------- +# Chart 2: Histogram — explicit binCount=5 with title glow +# +# officecli add charts-extended.xlsx "/3-Histogram & BoxWhisker" --type chart \ +# --prop chartType=histogram \ +# --prop title="Sales (binCount=5)" \ +# --prop series1="Sales:120,135,148,155,162,170,175,183,191,200,210,220,235,250,265,280,295,310,340,380,420,480,550,620,700" \ +# --prop binCount=5 \ +# --prop title.glow="FFC000-6-50" +# +# Features: histogram binCount (explicit bin count), title.glow +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Histogram & BoxWhisker" --type chart' + f' --prop chartType=histogram' + f' --prop title="Sales (binCount=5)"' + f' --prop series1=Sales:120,135,148,155,162,170,175,183,191,200,210,220,235,250,265,280,295,310,340,380,420,480,550,620,700' + f' --prop binCount=5' + f' --prop title.glow=FFC000-6-50' + f' --prop x=14 --prop y=0 --prop width=13 --prop height=18') + +# -------------------------------------------------------------------------- +# Chart 3: Histogram — explicit binSize=50 (fixed bin width) + label font +# +# officecli add charts-extended.xlsx "/3-Histogram & BoxWhisker" --type chart \ +# --prop chartType=histogram \ +# --prop title="Sales (binSize=50)" \ +# --prop series1="Sales:120,135,148,155,162,170,175,183,191,200,210,220,235,250,265,280,295,310,340,380,420,480,550,620,700" \ +# --prop binSize=50 \ +# --prop dataLabels=true \ +# --prop labelFont="9:FFFFFF:true" +# +# Features: histogram binSize (explicit bin width — mutually exclusive with +# binCount), dataLabels, labelFont +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Histogram & BoxWhisker" --type chart' + f' --prop chartType=histogram' + f' --prop title="Sales (binSize=50)"' + f' --prop series1=Sales:120,135,148,155,162,170,175,183,191,200,210,220,235,250,265,280,295,310,340,380,420,480,550,620,700' + f' --prop binSize=50' + f' --prop dataLabels=true' + f' --prop labelFont=9:FFFFFF:true' + f' --prop x=28 --prop y=0 --prop width=13 --prop height=18') + +# -------------------------------------------------------------------------- +# Chart 4: Histogram — overflow/underflow bins + intervalClosed=l +# +# officecli add charts-extended.xlsx "/3-Histogram & BoxWhisker" --type chart \ +# --prop chartType=histogram \ +# --prop title="Response Time (outlier bins)" \ +# --prop series1="ms:40,55,68,75,82,88,95,102,110,118,125,135,150,175,220,280,350" \ +# --prop underflowBin=60 \ +# --prop overflowBin=200 \ +# --prop intervalClosed=l \ +# --prop dataLabels=true \ +# --prop legend=none +# +# Features: histogram underflowBin (cutoff for N), +# intervalClosed=l (bins are [a,b) — left-closed; default "r" is (a,b]), +# legend=none +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Histogram & BoxWhisker" --type chart' + f' --prop chartType=histogram' + f' --prop title="Response Time (outlier bins)"' + f' --prop series1=ms:40,55,68,75,82,88,95,102,110,118,125,135,150,175,220,280,350' + f' --prop underflowBin=60' + f' --prop overflowBin=200' + f' --prop intervalClosed=l' + f' --prop dataLabels=true' + f' --prop legend=none' + f' --prop x=0 --prop y=19 --prop width=13 --prop height=18') + +# -------------------------------------------------------------------------- +# Chart 5: Box & Whisker — two teams, quartileMethod=exclusive +# +# officecli add charts-extended.xlsx "/3-Histogram & BoxWhisker" --type chart \ +# --prop chartType=boxWhisker \ +# --prop title="Response Time by Team (ms)" \ +# --prop series1="TeamA:42,55,61,68,72,75,78,81,85,88,92,97,105,120" \ +# --prop series2="TeamB:30,38,45,52,58,62,65,68,71,74,78,85,92,110" \ +# --prop quartileMethod=exclusive \ +# --prop legend=bottom +# +# Features: chartType=boxWhisker, two-series comparison, +# quartileMethod=exclusive, legend=bottom, outlier detection (built-in) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Histogram & BoxWhisker" --type chart' + f' --prop chartType=boxWhisker' + f' --prop title="Response Time by Team (ms)"' + f' --prop "series1=TeamA:42,55,61,68,72,75,78,81,85,88,92,97,105,120"' + f' --prop "series2=TeamB:30,38,45,52,58,62,65,68,71,74,78,85,92,110"' + f' --prop quartileMethod=exclusive' + f' --prop legend=bottom' + f' --prop x=14 --prop y=19 --prop width=13 --prop height=18') + +# -------------------------------------------------------------------------- +# Chart 6: Box & Whisker — three departments, quartileMethod=inclusive + title glow +# +# officecli add charts-extended.xlsx "/3-Histogram & BoxWhisker" --type chart \ +# --prop chartType=boxWhisker \ +# --prop title="Salary Distribution ($k)" \ +# --prop series1="Engineering:85,92,95,98,102,105,108,112,118,125,135,150,180" \ +# --prop series2="Marketing:60,65,68,72,75,78,80,83,88,92,98,110" \ +# --prop series3="Sales:55,62,68,75,82,90,98,105,115,125,140,160,190" \ +# --prop quartileMethod=inclusive \ +# --prop title.glow="00D2FF-6-60" \ +# --prop legend=bottom +# +# Features: boxWhisker three-series, quartileMethod=inclusive (different +# quartile formula from exclusive), title.glow, mean markers (default on) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Histogram & BoxWhisker" --type chart' + f' --prop chartType=boxWhisker' + f' --prop title="Salary Distribution (\\$k)"' + f' --prop "series1=Engineering:85,92,95,98,102,105,108,112,118,125,135,150,180"' + f' --prop "series2=Marketing:60,65,68,72,75,78,80,83,88,92,98,110"' + f' --prop "series3=Sales:55,62,68,75,82,90,98,105,115,125,140,160,190"' + f' --prop quartileMethod=inclusive' + f' --prop title.glow=00D2FF-6-60' + f' --prop legend=bottom' + f' --prop x=28 --prop y=19 --prop width=13 --prop height=18') + +# ========================================================================== +# Sheet 4: Pareto +# ========================================================================== +print("\n--- 4-Pareto ---") +cli(f'add "{FILE}" / --type sheet --prop name="4-Pareto"') + +# -------------------------------------------------------------------------- +# Chart 1: Pareto — defect analysis, raw counts auto-sorted + cumul% overlay +# +# officecli add charts-extended.xlsx "/4-Pareto" --type chart \ +# --prop chartType=pareto \ +# --prop title="Defect Pareto" \ +# --prop series1="Count:45,30,10,8,5,2" \ +# --prop categories=Scratches,Dents,Cracks,Chips,Stains,Other \ +# --prop dataLabels=true +# +# Features: chartType=pareto (2-series under the hood — clusteredColumn bars +# + paretoLine cumulative %), automatic descending sort, cumulative % +# computed server-side, dataLabels on both series. +# Input is a SINGLE user series; officecli pre-sorts by value desc and +# emits the two cx:series MSO expects (layoutId=clusteredColumn + +# layoutId=paretoLine with cx:binning intervalClosed="r"). +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Pareto" --type chart' + f' --prop chartType=pareto' + f' --prop title="Defect Pareto"' + f' --prop series1=Count:45,30,10,8,5,2' + f' --prop categories=Scratches,Dents,Cracks,Chips,Stains,Other' + f' --prop dataLabels=true' + f' --prop x=0 --prop y=0 --prop width=13 --prop height=18') + +# -------------------------------------------------------------------------- +# Chart 2: Pareto — root cause analysis, 10 categories, out-of-order input +# +# officecli add charts-extended.xlsx "/4-Pareto" --type chart \ +# --prop chartType=pareto \ +# --prop title="Root Cause Pareto" \ +# --prop series1="Tickets:12,87,5,45,3,120,22,67,8,31" \ +# --prop categories=Network,Auth,DB,Cache,UI,Config,Deploy,Monitor,Queue,Storage \ +# --prop title.glow="FFC000-6-50" \ +# --prop legend=bottom +# +# Features: pareto with unsorted input values (12, 87, 5, ...) — officecli +# re-sorts by value desc (120, 87, 67, ...) and re-aligns categories so +# the biggest contributor renders first. title.glow + legend=bottom +# demonstrate generic cx styling on pareto. +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Pareto" --type chart' + f' --prop chartType=pareto' + f' --prop title="Root Cause Pareto"' + f' --prop series1=Tickets:12,87,5,45,3,120,22,67,8,31' + f' --prop categories=Network,Auth,DB,Cache,UI,Config,Deploy,Monitor,Queue,Storage' + f' --prop title.glow=FFC000-6-50' + f' --prop legend=bottom' + f' --prop x=14 --prop y=0 --prop width=13 --prop height=18') + +# Remove blank default Sheet1 (all data is inline) +cli(f'remove "{FILE}" /Sheet1') + +print(f"\nDone! Generated: {FILE}") +print(" 4 sheets, 16 charts total (full cx:chart feature coverage)") +print(" Sheet 1: Waterfall (2) + Funnel (2)") +print(" Sheet 2: Treemap (3: overlapping/banner/none) + Sunburst (1)") +print(" Sheet 3: Histogram (4: auto/binCount/binSize/overflow+underflow+intervalClosed=l) + BoxWhisker (2: exclusive/inclusive)") +print(" Sheet 4: Pareto (2: sorted input / out-of-order input)") diff --git a/examples/excel/charts-extended.xlsx b/examples/excel/charts-extended.xlsx new file mode 100644 index 000000000..d4586aa61 Binary files /dev/null and b/examples/excel/charts-extended.xlsx differ diff --git a/examples/excel/charts-histogram.md b/examples/excel/charts-histogram.md new file mode 100644 index 000000000..d30045dea --- /dev/null +++ b/examples/excel/charts-histogram.md @@ -0,0 +1,278 @@ +# Histogram Charts — Grand Showcase + +The most thorough histogram demo officecli can produce. Every binning knob, +every styling vocabulary, every canonical distribution shape, six design +themes, four font-family type specimens, and a cohesive production-grade +ML dashboard. + +This demo is three files that work together: + +- **charts-histogram.py** — Python script that calls `officecli` to generate + the workbook. Each chart command is shown as a copyable shell command in + the comments. +- **charts-histogram.xlsx** — The generated workbook: 6 sheets, 29 charts. +- **charts-histogram.md** — This file. Maps each sheet to the features it + demonstrates and lists the full histogram property vocabulary. + +## Regenerate + +```bash +cd examples/excel +python3 charts-histogram.py +# → charts-histogram.xlsx +``` + +## Why a dedicated histogram showcase? + +Histograms are Excel's cx-namespace "extended" chart type. The binning layer +(`layoutPr/binning`) is where all the interesting knobs live — auto vs +explicit count, bin width, interval-closed side, outlier cut-offs — and +getting them right takes some care because Excel rejects the file entirely +if the XML uses the wrong form of `cx:binCount` / `cx:binSize`. + +Beyond binning, the cx pipeline in officecli has full parity with regular +cChart for typography, axis scaling, area fills/borders, drop shadows, +data labels, and legend styling. This file exercises every binning knob +AND every styling knob in one place, so you can copy-paste from whichever +row most matches the shape you want. + +## Sheets at a glance + +| Sheet | Charts | What it demonstrates | +|---|---|---| +| 0-Hero | 1 | Full-bleed magazine-grade poster using EVERY knob | +| 1-Binning Lab | 6 | Every binning strategy on one dataset, identical styling | +| 2-Distribution Zoo | 6 | Six canonical real-world distribution shapes | +| 3-Theme Gallery | 6 | Six complete design themes on the SAME dataset | +| 4-Typography | 4 | Four font-family type specimens | +| 5-ML Dashboard | 6 | Cohesive "Production ML Model Report" dashboard | + +## Sheet 0: 0-Hero + +One full-bleed 27×38-cell hero chart that combines EVERY histogram knob +into a single presentation-grade poster. Dark "Midnight Academia" palette +— navy plot area, gold bars, cream title, soft grid lines, locked Y axis, +dropped shadows on both title and series, data labels with number format, +top legend with compound font styling. If this chart renders correctly, +the entire histogram pipeline is healthy. + +```bash +officecli add charts-histogram.xlsx "/0-Hero" --type chart \ + --prop chartType=histogram \ + --prop title="The Shape of Data · 200-sample bell curve" \ + --prop title.color=F5F1E0 --prop title.size=22 --prop title.bold=true \ + --prop title.font="Helvetica Neue" \ + --prop "title.shadow=000000-8-45-4-70" \ + --prop series1="Samples:<200 bell values>" \ + --prop binCount=24 --prop intervalClosed=l \ + --prop fill=F0C96A --prop "series.shadow=000000-8-45-4-60" \ + --prop axismin=0 --prop axismax=28 --prop majorunit=4 \ + --prop xAxisTitle="Score" --prop yAxisTitle="Frequency" \ + --prop axisTitle.color=C9B87A --prop axisTitle.size=13 \ + --prop axisTitle.bold=true --prop axisTitle.font="Helvetica Neue" \ + --prop "axisfont=10:B8B090:Helvetica Neue" \ + --prop "axisline=6A6448:1.5" \ + --prop gridlineColor=2F3544 \ + --prop plotareafill=1A1F2C --prop "plotarea.border=3A3E4E:1.25" \ + --prop chartareafill=0B0F18 --prop "chartarea.border=2A2E3E:1" \ + --prop dataLabels=true --prop "datalabels.numfmt=0" \ + --prop legend=top --prop legend.overlay=false \ + --prop "legendfont=11:D4C994:Helvetica Neue" \ + --prop x=0 --prop y=0 --prop width=27 --prop height=38 +``` + +**Features:** title.color / title.size / title.bold / title.font / title.shadow, +fill, series.shadow, binCount, intervalClosed, axismin/axismax/majorunit, +xAxisTitle / yAxisTitle, axisTitle.color / axisTitle.size / axisTitle.bold / +axisTitle.font, axisfont compound, axisline, gridlineColor, plotareafill, +plotarea.border, chartareafill, chartarea.border, dataLabels, datalabels.numfmt, +legend, legend.overlay, legendfont. + +## Sheet 1: 1-Binning Lab + +Six charts, SAME dataset (200 bell-curve samples), IDENTICAL typography and +frame — the ONLY thing that varies is the binning strategy. Put side by +side, this sheet is the binning Rosetta stone. + +```bash +# 1. Auto-binning (no binCount, no binSize — Excel picks it) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=histogram --prop series1="Samples:" \ + --prop title="1 · Auto-binning (Excel default)" --prop fill=4472C4 + +# 2. Explicit binCount=8 (coarse) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=histogram --prop series1="Samples:" \ + --prop binCount=8 --prop title="2 · binCount=8 (coarse)" + +# 3. Explicit binCount=32 (fine) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=histogram --prop series1="Samples:" \ + --prop binCount=32 --prop title="3 · binCount=32 (fine)" + +# 4. Fixed bin width (binSize=5) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=histogram --prop series1="Samples:" \ + --prop binSize=5 --prop title="4 · binSize=5 (fixed-width bins)" + +# 5. Outlier fencing (underflowBin=55, overflowBin=95) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=histogram --prop series1="Samples:" \ + --prop binSize=5 --prop underflowBin=55 --prop overflowBin=95 + +# 6. Left-closed intervals [a,b) with gapWidth=30 between bars +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=histogram --prop series1="Samples:" \ + --prop binCount=16 --prop intervalClosed=l --prop gapWidth=30 +``` + +**Features:** `chartType=histogram`, auto-binning (default), `binCount=N`, +`binSize=W`, `underflowBin=N`, `overflowBin=M`, `intervalClosed=l`, `gapWidth=N` + +Notes: +- If both `binCount` and `binSize` are given, `binCount` wins. +- Histograms default `gapWidth=0` (bars touch) to match Excel's native output. +- `intervalClosed=l` makes bins half-open `[a,b)` instead of the default `(a,b]`. +- `underflow` / `overflow` fences let the interesting bulk stay readable + when the tail is catastrophic. + +## Sheet 2: 2-Distribution Zoo + +A 2×3 visual gallery of canonical real-world distribution shapes. Pattern +recognition: if you ever see one of these shapes in a telemetry chart, you +know immediately what's going on. Every chart shares the same typography +and frame; only the fill color, data, and binning strategy change. + +| Shape | Data | Fill | Binning | +|---|---|---|---| +| Normal · bell curve | 200 gauss(75, 12) | #2F5597 | binCount=18 | +| Bimodal · two cohorts | 80 gauss(55,6) + 80 gauss(88,5) | #ED7D31 | binCount=22 | +| Right-skewed · log-normal | 180 exp(gauss(3.2, 0.55)) | #70AD47 | binCount=20 | +| Left-skewed · retirement | 140 75 − exp(gauss(1.6, 0.6)) | #7030A0 | binCount=18 | +| Uniform · flat floor | 160 uniform(0, 100) | #00B0F0 | binSize=10 | +| Heavy-tailed · Pareto | 200 paretovariate(1.6) × 20 | #C00000 | binSize=20, overflow=250 | + +## Sheet 3: 3-Theme Gallery + +Six complete design themes applied to the SAME bell-curve dataset. Each +theme is a coordinated palette: plot-area fill, chart-area fill, series +fill, gridline color, axis line color, tick-label color, title color, +title font — all chosen to read as one coherent mood. + +| Theme | Mood | Plot BG | Bar | Title font | +|---|---|---|---|---| +| Midnight Academia | Dark, elegant | navy #1A1F2C | gold #F0C96A | Georgia | +| Sunset Terracotta | Warm, editorial | cream #FFF5E8 | coral #E85D4A | Georgia | +| Forest Parchment | Organic, retro | beige #F3EDD8 | forest #2F5D3A | Georgia | +| Editorial Mono | Pure grayscale | white #FFFFFF | dark #2A2A2A | Helvetica Neue | +| Neon Terminal | Cyberpunk | black #0A0A14 | cyan #00F0C8 | Courier New | +| Pastel Bloom | Soft, feminine | lavender #FDF4F8 | rose #F5A7C8 | Helvetica Neue | + +Each chart uses the full parity-knob vocabulary: `plotareafill`, +`plotarea.border`, `chartareafill`, `chartarea.border`, `gridlineColor`, +`axisline`, `axisfont`, `title.color` / `title.font`, `axisTitle.color` / +`axisTitle.font`. This is the sheet to copy-paste from when you want to +build a specific look for a report. + +## Sheet 4: 4-Typography + +Four font-family type specimens. Same data, same geometry, nearly identical +color — only the font family varies. Side by side, this sheet shows how +typography alone can reshape a chart's tone. + +| Font | Tone | Used for | +|---|---|---| +| Helvetica Neue | Modern sans | Dashboards, corporate reports | +| Georgia | Editorial serif | Magazines, long-form reports | +| Courier New | Data mono | Telemetry, engineering, terminals | +| Verdana | Friendly sans | Onboarding, public-facing UI | + +Each specimen sets `title.font`, `axisTitle.font`, and the fontname segment +of the `axisfont` compound form to the same family, so the entire chart +lives in one typographic voice. + +## Sheet 5: 5-ML Dashboard + +A cohesive "Production ML Model Report" dashboard. Every chart wears the +same uniform — typography, frames, gridlines, axis line — but each shows +a different slice of the model's behavior, deliberately using a different +color, binning strategy, and (where relevant) outlier-fencing or axis +locking. The six read as one dashboard. + +| Panel | Data shape | Color | Binning / parity knob | +|---|---|---|---| +| Inference Latency · p50–p99 | heavy-tail | #EF4444 | binSize=25, overflowBin=300, series.shadow | +| Prediction Confidence | right-skewed | #10B981 | binSize=5, axismin=0, majorunit=50 | +| Residual magnitude | half-normal | #F59E0B | binSize=0.25, intervalClosed=l | +| Token length | bimodal | #6366F1 | binCount=24 | +| GPU utilization | normal (clipped) | #8B5CF6 | binSize=5, axismin=0 axismax=50 majorunit=10 | +| Cost per request | log-normal | #EC4899 | binSize=5, overflowBin=120, dataLabels+numfmt | + +This sheet shows that one typographic uniform plus per-panel color and +binning choices is enough to build a production dashboard. Copy the +`DASH` style block from `charts-histogram.py` as a starting point. + +## Histogram Property Reference + +| Property | Default | Notes | +|---|---|---| +| `chartType` | — | Must be `histogram` | +| `title` | — | Chart title text | +| `series1` | — | `"name:v1,v2,v3,..."` — raw values, not pre-binned | +| `binCount` | auto | Integer: force exactly N bins | +| `binSize` | auto | Number: force fixed bin width | +| `intervalClosed` | `r` | `r` = (a,b], `l` = [a,b) | +| `underflowBin` | — | Group values < N into a single ` M into a single `>M` bar | +| `gapWidth` | `0` | Space between bars (0 = touching) | +| `fill` | — | Single-color shortcut (HEX) | +| `colors` | — | Comma list of HEX (multi-series) | +| `dataLabels` | `false` | `true` puts value count above each bar | +| `datalabels.numfmt` | — | Excel format code (`0`, `0.0`, `0.00%`, `#,##0`) | +| `xAxisTitle` / `yAxisTitle` | — | Axis titles | +| `gridlines` | `true` | Value-axis major gridlines | +| `xGridlines` | `false` | Category-axis major gridlines | +| `tickLabels` | `true` | Show bin range labels on x-axis | +| `axismin` / `axismax` | — | Value-axis range (numeric) | +| `majorunit` / `minorunit` | — | Value-axis gridline interval | +| `axis.visible` / `cataxis.visible` / `valaxis.visible` | — | Axis hidden flags | +| `axisline` | — | Axis spine: `"color"` / `"color:width"` / `"color:width:dash"` / `"none"` | +| `cataxis.line` / `valaxis.line` | — | Per-axis spine styling | +| `plotareafill` / `plotfill` | — | Plot-area solid background color | +| `plotarea.border` / `plotborder` | — | Plot-area outline | +| `chartareafill` / `chartfill` | — | Chart-area solid background color | +| `chartarea.border` / `chartborder` | — | Chart-area outline | +| `series.shadow` | — | Outer shadow on bars: `"COLOR-BLUR-ANGLE-DIST-OPACITY"` | +| `title.shadow` | — | Outer shadow on title: `"COLOR-BLUR-ANGLE-DIST-OPACITY"` | +| `legend` | — | `top` / `bottom` / `left` / `right` / `none` | +| `legend.overlay` | `false` | Legend floats on top of plot area when `true` | +| `legendfont` | — | Compound `"size:color:fontname"` | +| `title.color` / `title.size` / `title.bold` / `title.font` | — | Chart title styling | +| `axisTitle.color` / `axisTitle.size` / `axisTitle.font` / `axisTitle.bold` | — | Axis title styling (both X and Y) | +| `axisfont` | — | Compound tick-label styling: `"size:color:fontname"` | +| `gridlineColor` | — | Value-axis major gridline color | +| `xGridlineColor` | — | Category-axis major gridline color (requires `xGridlines=true`) | +| `x` / `y` / `width` / `height` | — | Chart cell placement and size | + +## Inspect the Generated File + +```bash +# Count all charts across all sheets +officecli query charts-histogram.xlsx chart + +# Introspect a single chart's bound properties +officecli get charts-histogram.xlsx "/0-Hero/chart[1]" +officecli get charts-histogram.xlsx "/5-ML Dashboard/chart[1]" + +# Render any sheet to HTML preview +officecli view charts-histogram.xlsx html > preview.html +``` + +> Note: officecli's HTML preview renders the full parity vocabulary +> (plot-area / chart-area fills, gridline + axis line colors, tick +> label colors, data labels, locked axis scales, gapWidth, etc.), +> but does not currently reproduce custom axis-label font families — +> all tick labels fall back to the preview's default sans font. Excel +> renders the full styling including the font family. Use the preview +> for layout + color verification, use Excel (or Numbers / LibreOffice) +> for final typographic QA. diff --git a/examples/excel/charts-histogram.py b/examples/excel/charts-histogram.py new file mode 100644 index 000000000..dbdd9c9d6 --- /dev/null +++ b/examples/excel/charts-histogram.py @@ -0,0 +1,1152 @@ +#!/usr/bin/env python3 +""" +Histogram Charts — Grand Showcase +================================== + +The most thorough, most visually polished histogram demo officecli can +produce. Every binning knob, every styling vocabulary, every canonical +distribution shape, six design themes on one dataset, four font type +specimens, and a cohesive production-grade ML dashboard — all driven by +real copyable officecli CLI commands. + +Generates: charts-histogram.xlsx (6 sheets, 29 histograms) + + 0-Hero 1 magazine-grade full-bleed hero poster chart + 1-Binning Lab 6 charts — every binning knob, identical styling + 2-Distribution Zoo 6 canonical real-world distribution shapes + 3-Theme Gallery 6 design themes on the SAME dataset + 4-Typography 4 font-family type specimens + 5-ML Dashboard 6-chart "Production ML Model Report" dashboard + +Usage: + python3 charts-histogram.py +""" + +import subprocess, os, atexit, random, math + +FILE = "charts-histogram.xlsx" + + +def cli(cmd): + """Run: officecli — prints stdout/stderr in real time.""" + r = subprocess.run(f"officecli {cmd}", shell=True, capture_output=True, text=True) + out = (r.stdout or "").strip() + if out: + for line in out.split("\n"): + if line.strip(): + print(f" {line.strip()}") + if r.returncode != 0: + err = (r.stderr or "").strip() + if err and "UNSUPPORTED" not in err and "process cannot access" not in err: + print(f" ERROR: {err}") + + +# -------------------------------------------------------------------------- +# Scaffolding: create file, open it in resident mode (fast subsequent calls), +# and register a graceful close() on exit. +# -------------------------------------------------------------------------- +if os.path.exists(FILE): + os.remove(FILE) + +cli(f'create "{FILE}"') +cli(f'open "{FILE}"') +atexit.register(lambda: cli(f'close "{FILE}"')) + + +# -------------------------------------------------------------------------- +# Deterministic sample generators — same seed, same file every regeneration. +# All datasets are CSV-joined once here and reused across sheets. +# -------------------------------------------------------------------------- +def csv(values): + return ",".join(str(v) for v in values) + +# The "reference" bell curve — 200 samples around 75±12. Used by the hero, +# the binning lab, the theme gallery, the typography specimens, and the zoo. +random.seed(42) +BELL_200 = sorted(round(random.gauss(75, 12), 1) for _ in range(200)) +BELL_CSV = csv(BELL_200) + +# Bimodal: two cohorts (beginners ~55, experts ~88) glued together. +random.seed(7) +BIMODAL = sorted( + [round(random.gauss(55, 6), 1) for _ in range(80)] + + [round(random.gauss(88, 5), 1) for _ in range(80)] +) +BIMODAL_CSV = csv(BIMODAL) + +# Right-skewed / log-normal: classic income shape. +random.seed(11) +LOGNORM = sorted(round(math.exp(random.gauss(3.2, 0.55)), 1) for _ in range(180)) +LOGNORM_CSV = csv(LOGNORM) + +# Left-skewed: retirement ages — most cluster high, a few retire early. +random.seed(23) +LEFT_SKEW = sorted(round(75 - math.exp(random.gauss(1.6, 0.6)), 1) for _ in range(140)) +LEFT_CSV = csv(LEFT_SKEW) + +# Uniform: random draws evenly distributed across a range. +random.seed(31) +UNIFORM = sorted(round(random.uniform(0, 100), 1) for _ in range(160)) +UNIFORM_CSV = csv(UNIFORM) + +# Heavy-tailed (Pareto): most small, tiny fraction catastrophic. +random.seed(47) +PARETO = sorted(round(random.paretovariate(1.6) * 20, 1) for _ in range(200)) +PARETO_CSV = csv(PARETO) + +# --- ML Dashboard datasets (sheet 5) --- +random.seed(101) +LATENCY_MS = sorted(round(random.paretovariate(1.8) * 15 + 10, 1) for _ in range(250)) +LATENCY_CSV = csv(LATENCY_MS) + +random.seed(102) +CONFIDENCE = sorted(round(random.betavariate(6, 2) * 100, 2) for _ in range(240)) +CONFIDENCE_CSV = csv(CONFIDENCE) + +random.seed(103) +ERROR_MAG = sorted(round(abs(random.gauss(0, 1.5)), 3) for _ in range(180)) +ERROR_MAG_CSV = csv(ERROR_MAG) + +random.seed(104) +TOKEN_LEN = sorted( + [max(1, round(random.gauss(180, 40))) for _ in range(100)] + + [max(1, round(random.gauss(520, 90))) for _ in range(80)] +) +TOKEN_CSV = csv(TOKEN_LEN) + +random.seed(105) +GPU_UTIL = sorted(round(min(99.0, max(30.0, random.gauss(82, 8))), 1) for _ in range(200)) +GPU_CSV = csv(GPU_UTIL) + +random.seed(106) +COST_REQ = sorted(round(math.exp(random.gauss(-3.2, 0.9)) * 1000, 3) for _ in range(220)) +COST_CSV = csv(COST_REQ) + + +# ========================================================================== +# Sheet 0: "0-Hero" — the full-bleed magazine hero poster +# +# A single giant chart using EVERY histogram knob at once: +# - Dark "Midnight Academia" palette: navy plot area, gold bars, cream text +# - title.* (color/size/bold/font/shadow) +# - series.shadow + fill +# - axisline + axisfont + axisTitle.* +# - plotareafill / plotarea.border / chartareafill / chartarea.border +# - axismin / axismax / majorunit (locked Y scale) +# - gridlineColor +# - dataLabels + datalabels.numfmt +# - legend=top + legend.overlay + legendfont +# - intervalClosed=l + explicit binCount +# +# This chart is the "representative sample" — if it renders correctly, the +# entire histogram pipeline is healthy. +# ========================================================================== +print("\n--- 0-Hero ---") +cli(f'set "{FILE}" /Sheet1 --prop name="0-Hero"') + +# officecli add charts-histogram.xlsx "/0-Hero" --type chart \ +# --prop chartType=histogram \ +# --prop title="The Shape of Data · 200-sample bell curve" \ +# --prop title.color=F5F1E0 --prop title.size=22 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop "title.shadow=000000-8-45-4-70" \ +# --prop series1="Samples:<200 bell values>" \ +# --prop binCount=24 --prop intervalClosed=l \ +# --prop fill=F0C96A --prop "series.shadow=000000-8-45-4-60" \ +# --prop axismin=0 --prop axismax=28 --prop majorunit=4 \ +# --prop xAxisTitle="Score" --prop yAxisTitle="Frequency" \ +# --prop axisTitle.color=C9B87A --prop axisTitle.size=13 \ +# --prop axisTitle.bold=true --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisfont=10:B8B090:Helvetica Neue" \ +# --prop "axisline=6A6448:1.5" \ +# --prop gridlineColor=2F3544 \ +# --prop plotareafill=1A1F2C --prop "plotarea.border=3A3E4E:1.25" \ +# --prop chartareafill=0B0F18 --prop "chartarea.border=2A2E3E:1" \ +# --prop dataLabels=true --prop "datalabels.numfmt=0" \ +# --prop legend=top --prop legend.overlay=false \ +# --prop "legendfont=11:D4C994:Helvetica Neue" \ +# --prop x=0 --prop y=0 --prop width=27 --prop height=38 +# Features: EVERY knob — title/series/axis/plotarea/chartarea/shadow/scaling/legend/datalabel +cli(f'add "{FILE}" "/0-Hero" --type chart' + f' --prop chartType=histogram' + f' --prop title="The Shape of Data · 200-sample bell curve"' + f' --prop title.color=F5F1E0 --prop title.size=22 --prop title.bold=true' + f' --prop title.font="Helvetica Neue"' + f' --prop "title.shadow=000000-8-45-4-70"' + f' --prop series1=Samples:{BELL_CSV}' + f' --prop binCount=24 --prop intervalClosed=l' + f' --prop fill=F0C96A --prop "series.shadow=000000-8-45-4-60"' + f' --prop axismin=0 --prop axismax=28 --prop majorunit=4' + f' --prop xAxisTitle="Score" --prop yAxisTitle="Frequency"' + f' --prop axisTitle.color=C9B87A --prop axisTitle.size=13' + f' --prop axisTitle.bold=true --prop axisTitle.font="Helvetica Neue"' + f' --prop "axisfont=10:B8B090:Helvetica Neue"' + f' --prop "axisline=6A6448:1.5"' + f' --prop gridlineColor=2F3544' + f' --prop plotareafill=1A1F2C --prop "plotarea.border=3A3E4E:1.25"' + f' --prop chartareafill=0B0F18 --prop "chartarea.border=2A2E3E:1"' + f' --prop dataLabels=true --prop "datalabels.numfmt=0"' + f' --prop legend=top --prop legend.overlay=false' + f' --prop "legendfont=11:D4C994:Helvetica Neue"' + f' --prop x=0 --prop y=0 --prop width=27 --prop height=38') + + +# ========================================================================== +# Sheet 1: "1-Binning Lab" +# +# Six histograms, SAME dataset (BELL_200), IDENTICAL typography / colors / +# frames — the ONLY thing that varies is the binning strategy. Put side by +# side, this sheet is the "Rosetta stone": once you see how each binning +# knob reshapes the bars, you'll never be confused about which to use. +# +# ┌──────────┬──────────┐ +# │ 1. auto │ 2. count │ +# ├──────────┼──────────┤ +# │ 3. fine │ 4. width │ +# ├──────────┼──────────┤ +# │ 5. fence │ 6. lclos │ +# └──────────┴──────────┘ +# ========================================================================== +print("\n--- 1-Binning Lab ---") +cli(f'add "{FILE}" / --type sheet --prop name="1-Binning Lab"') + +# Shared "clean lab" style — every chart on this sheet wears the exact same +# outfit so the bin-shape difference is the only visible variable. +LAB = ( + ' --prop fill=4472C4' + ' --prop title.color=1F2937 --prop title.size=13 --prop title.bold=true' + ' --prop title.font="Helvetica Neue"' + ' --prop xAxisTitle="Score" --prop yAxisTitle="Count"' + ' --prop axisTitle.color=6B7280 --prop axisTitle.size=10' + ' --prop axisTitle.font="Helvetica Neue"' + ' --prop "axisfont=9:6B7280:Helvetica Neue"' + ' --prop gridlineColor=F0F0F0' + ' --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75"' + ' --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75"' + ' --prop "axisline=9CA3AF:0.75"' +) + +# officecli add charts-histogram.xlsx "/1-Binning Lab" --type chart \ +# --prop chartType=histogram \ +# --prop title="1 · Auto-binning (Excel default)" \ +# --prop series1="Samples:<200 bell values>" \ +# --prop fill=4472C4 \ +# --prop title.color=1F2937 --prop title.size=13 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop xAxisTitle="Score" --prop yAxisTitle="Count" \ +# --prop axisTitle.color=6B7280 --prop axisTitle.size=10 \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisfont=9:6B7280:Helvetica Neue" \ +# --prop gridlineColor=F0F0F0 \ +# --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75" \ +# --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75" \ +# --prop "axisline=9CA3AF:0.75" \ +# --prop x=0 --prop y=0 --prop width=13 --prop height=18 +# Features: no binCount, no binSize — Excel picks the bin count automatically. +cli(f'add "{FILE}" "/1-Binning Lab" --type chart' + f' --prop chartType=histogram' + f' --prop title="1 · Auto-binning (Excel default)"' + f' --prop series1=Samples:{BELL_CSV}' + f'{LAB}' + f' --prop x=0 --prop y=0 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/1-Binning Lab" --type chart \ +# --prop chartType=histogram \ +# --prop title="2 · binCount=8 (coarse)" \ +# --prop series1="Samples:<200 bell values>" \ +# --prop binCount=8 \ +# --prop fill=4472C4 \ +# --prop title.color=1F2937 --prop title.size=13 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop xAxisTitle="Score" --prop yAxisTitle="Count" \ +# --prop axisTitle.color=6B7280 --prop axisTitle.size=10 \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisfont=9:6B7280:Helvetica Neue" \ +# --prop gridlineColor=F0F0F0 \ +# --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75" \ +# --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75" \ +# --prop "axisline=9CA3AF:0.75" \ +# --prop x=14 --prop y=0 --prop width=13 --prop height=18 +# Features: binCount=8 — coarse. Fewer, wider bars. Good for "what's the mode?" +cli(f'add "{FILE}" "/1-Binning Lab" --type chart' + f' --prop chartType=histogram' + f' --prop title="2 · binCount=8 (coarse)"' + f' --prop series1=Samples:{BELL_CSV}' + f' --prop binCount=8' + f'{LAB}' + f' --prop x=14 --prop y=0 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/1-Binning Lab" --type chart \ +# --prop chartType=histogram \ +# --prop title="3 · binCount=32 (fine)" \ +# --prop series1="Samples:<200 bell values>" \ +# --prop binCount=32 \ +# --prop fill=4472C4 \ +# --prop title.color=1F2937 --prop title.size=13 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop xAxisTitle="Score" --prop yAxisTitle="Count" \ +# --prop axisTitle.color=6B7280 --prop axisTitle.size=10 \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisfont=9:6B7280:Helvetica Neue" \ +# --prop gridlineColor=F0F0F0 \ +# --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75" \ +# --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75" \ +# --prop "axisline=9CA3AF:0.75" \ +# --prop x=0 --prop y=19 --prop width=13 --prop height=18 +# Features: binCount=32 — fine. Many narrow bars. Good for "is it really Gaussian?" +cli(f'add "{FILE}" "/1-Binning Lab" --type chart' + f' --prop chartType=histogram' + f' --prop title="3 · binCount=32 (fine)"' + f' --prop series1=Samples:{BELL_CSV}' + f' --prop binCount=32' + f'{LAB}' + f' --prop x=0 --prop y=19 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/1-Binning Lab" --type chart \ +# --prop chartType=histogram \ +# --prop title="4 · binSize=5 (fixed-width bins)" \ +# --prop series1="Samples:<200 bell values>" \ +# --prop binSize=5 \ +# --prop fill=4472C4 \ +# --prop title.color=1F2937 --prop title.size=13 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop xAxisTitle="Score" --prop yAxisTitle="Count" \ +# --prop axisTitle.color=6B7280 --prop axisTitle.size=10 \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisfont=9:6B7280:Helvetica Neue" \ +# --prop gridlineColor=F0F0F0 \ +# --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75" \ +# --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75" \ +# --prop "axisline=9CA3AF:0.75" \ +# --prop x=14 --prop y=19 --prop width=13 --prop height=18 +# Features: binSize=5 — fixed bin width. Use when you want human-friendly +# bin boundaries (multiples of 5, 10, etc) regardless of data range. +cli(f'add "{FILE}" "/1-Binning Lab" --type chart' + f' --prop chartType=histogram' + f' --prop title="4 · binSize=5 (fixed-width bins)"' + f' --prop series1=Samples:{BELL_CSV}' + f' --prop binSize=5' + f'{LAB}' + f' --prop x=14 --prop y=19 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/1-Binning Lab" --type chart \ +# --prop chartType=histogram \ +# --prop title="5 · underflow=55 · overflow=95 (fencing)" \ +# --prop series1="Samples:<200 bell values>" \ +# --prop binSize=5 --prop underflowBin=55 --prop overflowBin=95 \ +# --prop fill=4472C4 \ +# --prop title.color=1F2937 --prop title.size=13 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop xAxisTitle="Score" --prop yAxisTitle="Count" \ +# --prop axisTitle.color=6B7280 --prop axisTitle.size=10 \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisfont=9:6B7280:Helvetica Neue" \ +# --prop gridlineColor=F0F0F0 \ +# --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75" \ +# --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75" \ +# --prop "axisline=9CA3AF:0.75" \ +# --prop x=0 --prop y=38 --prop width=13 --prop height=18 +# Features: underflowBin=55 + overflowBin=95 — outlier fencing. Everything +# below 55 or above 95 collapses into a single <55 / >95 bar. +cli(f'add "{FILE}" "/1-Binning Lab" --type chart' + f' --prop chartType=histogram' + f' --prop title="5 · underflow=55 · overflow=95 (fencing)"' + f' --prop series1=Samples:{BELL_CSV}' + f' --prop binSize=5 --prop underflowBin=55 --prop overflowBin=95' + f'{LAB}' + f' --prop x=0 --prop y=38 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/1-Binning Lab" --type chart \ +# --prop chartType=histogram \ +# --prop title="6 · [a,b) intervals + gapWidth=30" \ +# --prop series1="Samples:<200 bell values>" \ +# --prop binCount=16 --prop intervalClosed=l --prop gapWidth=30 \ +# --prop fill=4472C4 \ +# --prop title.color=1F2937 --prop title.size=13 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop xAxisTitle="Score" --prop yAxisTitle="Count" \ +# --prop axisTitle.color=6B7280 --prop axisTitle.size=10 \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisfont=9:6B7280:Helvetica Neue" \ +# --prop gridlineColor=F0F0F0 \ +# --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75" \ +# --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75" \ +# --prop "axisline=9CA3AF:0.75" \ +# --prop x=14 --prop y=38 --prop width=13 --prop height=18 +# Features: intervalClosed=l (half-open [a,b)) + gapWidth=30 — shows the +# "left-closed" variant AND pushes bars apart so you can see each one. +# Useful when the dataset has values lying exactly on a bin boundary. +cli(f'add "{FILE}" "/1-Binning Lab" --type chart' + f' --prop chartType=histogram' + f' --prop title="6 · [a,b) intervals + gapWidth=30"' + f' --prop series1=Samples:{BELL_CSV}' + f' --prop binCount=16 --prop intervalClosed=l --prop gapWidth=30' + f'{LAB}' + f' --prop x=14 --prop y=38 --prop width=13 --prop height=18') + + +# ========================================================================== +# Sheet 2: "2-Distribution Zoo" +# +# A cohesive 2x3 gallery of the canonical distribution shapes you'll see +# in production data. Pattern recognition: if you ever see one of these +# shapes in a telemetry chart, you know immediately what's going on. +# +# Every chart shares the same typography + plot/chart area frames; only +# the fill color and data change. Uses different binning strategies +# appropriate to each distribution. +# ========================================================================== +print("\n--- 2-Distribution Zoo ---") +cli(f'add "{FILE}" / --type sheet --prop name="2-Distribution Zoo"') + +ZOO = ( + ' --prop title.color=1F2937 --prop title.size=13 --prop title.bold=true' + ' --prop title.font="Helvetica Neue"' + ' --prop axisTitle.color=6B7280 --prop axisTitle.size=10' + ' --prop axisTitle.font="Helvetica Neue"' + ' --prop "axisfont=9:6B7280:Helvetica Neue"' + ' --prop gridlineColor=EFEFEF' + ' --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75"' + ' --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75"' + ' --prop "axisline=9CA3AF:0.75"' +) + +# officecli add charts-histogram.xlsx "/2-Distribution Zoo" --type chart \ +# --prop chartType=histogram \ +# --prop title="Normal · bell curve (reference)" \ +# --prop series1="Samples:<200 bell values>" \ +# --prop binCount=18 --prop fill=2F5597 \ +# --prop xAxisTitle="Score" --prop yAxisTitle="Count" \ +# --prop title.color=1F2937 --prop title.size=13 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop axisTitle.color=6B7280 --prop axisTitle.size=10 \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisfont=9:6B7280:Helvetica Neue" \ +# --prop gridlineColor=EFEFEF \ +# --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75" \ +# --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75" \ +# --prop "axisline=9CA3AF:0.75" \ +# --prop x=0 --prop y=0 --prop width=13 --prop height=18 +# Features: classic bell curve reference, binCount=18, midnight blue fill. +cli(f'add "{FILE}" "/2-Distribution Zoo" --type chart' + f' --prop chartType=histogram' + f' --prop title="Normal · bell curve (reference)"' + f' --prop series1=Samples:{BELL_CSV}' + f' --prop binCount=18 --prop fill=2F5597' + f' --prop xAxisTitle="Score" --prop yAxisTitle="Count"' + f'{ZOO}' + f' --prop x=0 --prop y=0 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/2-Distribution Zoo" --type chart \ +# --prop chartType=histogram \ +# --prop title="Bimodal · two hidden cohorts" \ +# --prop series1="Score:<160 bimodal values>" \ +# --prop binCount=22 --prop fill=ED7D31 \ +# --prop xAxisTitle="Test score" --prop yAxisTitle="Students" \ +# --prop title.color=1F2937 --prop title.size=13 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop axisTitle.color=6B7280 --prop axisTitle.size=10 \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisfont=9:6B7280:Helvetica Neue" \ +# --prop gridlineColor=EFEFEF \ +# --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75" \ +# --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75" \ +# --prop "axisline=9CA3AF:0.75" \ +# --prop x=14 --prop y=0 --prop width=13 --prop height=18 +# Features: bimodal — two hidden populations. Narrow bins reveal the split. +cli(f'add "{FILE}" "/2-Distribution Zoo" --type chart' + f' --prop chartType=histogram' + f' --prop title="Bimodal · two hidden cohorts"' + f' --prop series1=Score:{BIMODAL_CSV}' + f' --prop binCount=22 --prop fill=ED7D31' + f' --prop xAxisTitle="Test score" --prop yAxisTitle="Students"' + f'{ZOO}' + f' --prop x=14 --prop y=0 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/2-Distribution Zoo" --type chart \ +# --prop chartType=histogram \ +# --prop title="Right-skewed · log-normal (income)" \ +# --prop series1="Income:<180 log-normal values>" \ +# --prop binCount=20 --prop fill=70AD47 \ +# --prop xAxisTitle="Monthly income ($k)" --prop yAxisTitle="People" \ +# --prop title.color=1F2937 --prop title.size=13 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop axisTitle.color=6B7280 --prop axisTitle.size=10 \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisfont=9:6B7280:Helvetica Neue" \ +# --prop gridlineColor=EFEFEF \ +# --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75" \ +# --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75" \ +# --prop "axisline=9CA3AF:0.75" \ +# --prop x=0 --prop y=19 --prop width=13 --prop height=18 +# Features: right-skewed log-normal. Mean >> median, long tail to the right. +cli(f'add "{FILE}" "/2-Distribution Zoo" --type chart' + f' --prop chartType=histogram' + f' --prop title="Right-skewed · log-normal (income)"' + f' --prop series1=Income:{LOGNORM_CSV}' + f' --prop binCount=20 --prop fill=70AD47' + f' --prop xAxisTitle="Monthly income ($k)" --prop yAxisTitle="People"' + f'{ZOO}' + f' --prop x=0 --prop y=19 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/2-Distribution Zoo" --type chart \ +# --prop chartType=histogram \ +# --prop title="Left-skewed · retirement ages" \ +# --prop series1="Age:<140 left-skewed values>" \ +# --prop binCount=18 --prop fill=7030A0 \ +# --prop xAxisTitle="Age at retirement" --prop yAxisTitle="Retirees" \ +# --prop title.color=1F2937 --prop title.size=13 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop axisTitle.color=6B7280 --prop axisTitle.size=10 \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisfont=9:6B7280:Helvetica Neue" \ +# --prop gridlineColor=EFEFEF \ +# --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75" \ +# --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75" \ +# --prop "axisline=9CA3AF:0.75" \ +# --prop x=14 --prop y=19 --prop width=13 --prop height=18 +# Features: left-skewed — retirement ages cluster high, tail stretches left. +cli(f'add "{FILE}" "/2-Distribution Zoo" --type chart' + f' --prop chartType=histogram' + f' --prop title="Left-skewed · retirement ages"' + f' --prop series1=Age:{LEFT_CSV}' + f' --prop binCount=18 --prop fill=7030A0' + f' --prop xAxisTitle="Age at retirement" --prop yAxisTitle="Retirees"' + f'{ZOO}' + f' --prop x=14 --prop y=19 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/2-Distribution Zoo" --type chart \ +# --prop chartType=histogram \ +# --prop title="Uniform · flat floor" \ +# --prop series1="Draws:<160 uniform values>" \ +# --prop binSize=10 --prop fill=00B0F0 \ +# --prop xAxisTitle="Random draw (0-100)" --prop yAxisTitle="Count" \ +# --prop title.color=1F2937 --prop title.size=13 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop axisTitle.color=6B7280 --prop axisTitle.size=10 \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisfont=9:6B7280:Helvetica Neue" \ +# --prop gridlineColor=EFEFEF \ +# --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75" \ +# --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75" \ +# --prop "axisline=9CA3AF:0.75" \ +# --prop x=0 --prop y=38 --prop width=13 --prop height=18 +# Features: uniform — every value equally likely. binSize emphasizes the +# "flat floor" visual tell. +cli(f'add "{FILE}" "/2-Distribution Zoo" --type chart' + f' --prop chartType=histogram' + f' --prop title="Uniform · flat floor"' + f' --prop series1=Draws:{UNIFORM_CSV}' + f' --prop binSize=10 --prop fill=00B0F0' + f' --prop xAxisTitle="Random draw (0-100)" --prop yAxisTitle="Count"' + f'{ZOO}' + f' --prop x=0 --prop y=38 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/2-Distribution Zoo" --type chart \ +# --prop chartType=histogram \ +# --prop title="Heavy-tailed · Pareto (overflow=250)" \ +# --prop series1="Latency:<200 Pareto values>" \ +# --prop binSize=20 --prop overflowBin=250 --prop fill=C00000 \ +# --prop xAxisTitle="Latency (ms)" --prop yAxisTitle="Requests" \ +# --prop title.color=1F2937 --prop title.size=13 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop axisTitle.color=6B7280 --prop axisTitle.size=10 \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisfont=9:6B7280:Helvetica Neue" \ +# --prop gridlineColor=EFEFEF \ +# --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75" \ +# --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75" \ +# --prop "axisline=9CA3AF:0.75" \ +# --prop x=14 --prop y=38 --prop width=13 --prop height=18 +# Features: heavy-tailed Pareto + overflowBin. Fences the catastrophic tail +# so the interesting bulk of the distribution stays readable. +cli(f'add "{FILE}" "/2-Distribution Zoo" --type chart' + f' --prop chartType=histogram' + f' --prop title="Heavy-tailed · Pareto (overflow=250)"' + f' --prop series1=Latency:{PARETO_CSV}' + f' --prop binSize=20 --prop overflowBin=250 --prop fill=C00000' + f' --prop xAxisTitle="Latency (ms)" --prop yAxisTitle="Requests"' + f'{ZOO}' + f' --prop x=14 --prop y=38 --prop width=13 --prop height=18') + + +# ========================================================================== +# Sheet 3: "3-Theme Gallery" +# +# Six complete design themes applied to the SAME bell-curve dataset. Each +# theme is a coordinated palette: plot-area fill, chart-area fill, series +# fill, gridline color, axis line color, tick-label color, title color, +# title font — all chosen to read as one coherent mood. +# +# Grid: +# ┌─────────────┬─────────────┐ +# │ 1. Midnight │ 2. Sunset │ +# ├─────────────┼─────────────┤ +# │ 3. Forest │ 4. Mono │ +# ├─────────────┼─────────────┤ +# │ 5. Neon │ 6. Pastel │ +# └─────────────┴─────────────┘ +# ========================================================================== +print("\n--- 3-Theme Gallery ---") +cli(f'add "{FILE}" / --type sheet --prop name="3-Theme Gallery"') + +# officecli add charts-histogram.xlsx "/3-Theme Gallery" --type chart \ +# --prop chartType=histogram \ +# --prop title="Midnight Academia" \ +# --prop title.color=F5F1E0 --prop title.size=14 --prop title.bold=true \ +# --prop title.font="Georgia" \ +# --prop "title.shadow=000000-6-45-3-70" \ +# --prop series1="Samples:<200 bell values>" \ +# --prop binCount=18 --prop fill=F0C96A \ +# --prop "series.shadow=000000-6-45-3-55" \ +# --prop plotareafill=1A1F2C --prop "plotarea.border=3A3E4E:1" \ +# --prop chartareafill=0B0F18 --prop "chartarea.border=2A2E3E:0.75" \ +# --prop gridlineColor=2F3544 \ +# --prop "axisfont=9:B8B090:Georgia" \ +# --prop xAxisTitle="Score" --prop yAxisTitle="Count" \ +# --prop axisTitle.color=C9B87A --prop axisTitle.size=10 \ +# --prop axisTitle.font="Georgia" \ +# --prop "axisline=5A5848:1" \ +# --prop x=0 --prop y=0 --prop width=13 --prop height=18 +# Features: dark plot area, gold bars, series.shadow, title.shadow +cli(f'add "{FILE}" "/3-Theme Gallery" --type chart' + f' --prop chartType=histogram' + f' --prop title="Midnight Academia"' + f' --prop title.color=F5F1E0 --prop title.size=14 --prop title.bold=true' + f' --prop title.font="Georgia"' + f' --prop "title.shadow=000000-6-45-3-70"' + f' --prop series1=Samples:{BELL_CSV}' + f' --prop binCount=18 --prop fill=F0C96A' + f' --prop "series.shadow=000000-6-45-3-55"' + f' --prop plotareafill=1A1F2C --prop "plotarea.border=3A3E4E:1"' + f' --prop chartareafill=0B0F18 --prop "chartarea.border=2A2E3E:0.75"' + f' --prop gridlineColor=2F3544' + f' --prop "axisfont=9:B8B090:Georgia"' + f' --prop xAxisTitle="Score" --prop yAxisTitle="Count"' + f' --prop axisTitle.color=C9B87A --prop axisTitle.size=10' + f' --prop axisTitle.font="Georgia"' + f' --prop "axisline=5A5848:1"' + f' --prop x=0 --prop y=0 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/3-Theme Gallery" --type chart \ +# --prop chartType=histogram \ +# --prop title="Sunset Terracotta" \ +# --prop title.color=3F2818 --prop title.size=14 --prop title.bold=true \ +# --prop title.font="Georgia" \ +# --prop series1="Samples:<200 bell values>" \ +# --prop binCount=18 --prop fill=E85D4A \ +# --prop plotareafill=FFF5E8 --prop "plotarea.border=F0D8B0:1" \ +# --prop chartareafill=FFE6C7 --prop "chartarea.border=E6BC88:1" \ +# --prop gridlineColor=F5C98A \ +# --prop "axisfont=9:6B4A2A:Georgia" \ +# --prop xAxisTitle="Score" --prop yAxisTitle="Count" \ +# --prop axisTitle.color=A8522C --prop axisTitle.size=10 \ +# --prop axisTitle.font="Georgia" \ +# --prop "axisline=C08050:1" \ +# --prop x=14 --prop y=0 --prop width=13 --prop height=18 +# Theme 2 · Sunset Terracotta (warm cream + coral, serif) +cli(f'add "{FILE}" "/3-Theme Gallery" --type chart' + f' --prop chartType=histogram' + f' --prop title="Sunset Terracotta"' + f' --prop title.color=3F2818 --prop title.size=14 --prop title.bold=true' + f' --prop title.font="Georgia"' + f' --prop series1=Samples:{BELL_CSV}' + f' --prop binCount=18 --prop fill=E85D4A' + f' --prop plotareafill=FFF5E8 --prop "plotarea.border=F0D8B0:1"' + f' --prop chartareafill=FFE6C7 --prop "chartarea.border=E6BC88:1"' + f' --prop gridlineColor=F5C98A' + f' --prop "axisfont=9:6B4A2A:Georgia"' + f' --prop xAxisTitle="Score" --prop yAxisTitle="Count"' + f' --prop axisTitle.color=A8522C --prop axisTitle.size=10' + f' --prop axisTitle.font="Georgia"' + f' --prop "axisline=C08050:1"' + f' --prop x=14 --prop y=0 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/3-Theme Gallery" --type chart \ +# --prop chartType=histogram \ +# --prop title="Forest Parchment" \ +# --prop title.color=1F3A1F --prop title.size=14 --prop title.bold=true \ +# --prop title.font="Georgia" \ +# --prop series1="Samples:<200 bell values>" \ +# --prop binCount=18 --prop fill=2F5D3A \ +# --prop plotareafill=F3EDD8 --prop "plotarea.border=C8B890:1" \ +# --prop chartareafill=EADFBE --prop "chartarea.border=A89858:1" \ +# --prop gridlineColor=C0B888 \ +# --prop "axisfont=9:4A5A3A:Georgia" \ +# --prop xAxisTitle="Score" --prop yAxisTitle="Count" \ +# --prop axisTitle.color=3F5A2F --prop axisTitle.size=10 \ +# --prop axisTitle.font="Georgia" \ +# --prop "axisline=6A7A4A:1" \ +# --prop x=0 --prop y=19 --prop width=13 --prop height=18 +# Theme 3 · Forest Parchment (beige + forest green, serif) +cli(f'add "{FILE}" "/3-Theme Gallery" --type chart' + f' --prop chartType=histogram' + f' --prop title="Forest Parchment"' + f' --prop title.color=1F3A1F --prop title.size=14 --prop title.bold=true' + f' --prop title.font="Georgia"' + f' --prop series1=Samples:{BELL_CSV}' + f' --prop binCount=18 --prop fill=2F5D3A' + f' --prop plotareafill=F3EDD8 --prop "plotarea.border=C8B890:1"' + f' --prop chartareafill=EADFBE --prop "chartarea.border=A89858:1"' + f' --prop gridlineColor=C0B888' + f' --prop "axisfont=9:4A5A3A:Georgia"' + f' --prop xAxisTitle="Score" --prop yAxisTitle="Count"' + f' --prop axisTitle.color=3F5A2F --prop axisTitle.size=10' + f' --prop axisTitle.font="Georgia"' + f' --prop "axisline=6A7A4A:1"' + f' --prop x=0 --prop y=19 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/3-Theme Gallery" --type chart \ +# --prop chartType=histogram \ +# --prop title="Editorial Mono" \ +# --prop title.color=111111 --prop title.size=14 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop series1="Samples:<200 bell values>" \ +# --prop binCount=18 --prop fill=2A2A2A \ +# --prop plotareafill=FFFFFF --prop "plotarea.border=CCCCCC:0.75" \ +# --prop chartareafill=FAFAFA --prop "chartarea.border=E0E0E0:0.75" \ +# --prop gridlineColor=EEEEEE \ +# --prop "axisfont=9:555555:Helvetica Neue" \ +# --prop xAxisTitle="Score" --prop yAxisTitle="Count" \ +# --prop axisTitle.color=333333 --prop axisTitle.size=10 \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisline=888888:1" \ +# --prop x=14 --prop y=19 --prop width=13 --prop height=18 +# Theme 4 · Editorial Mono (pure grayscale, sans) +cli(f'add "{FILE}" "/3-Theme Gallery" --type chart' + f' --prop chartType=histogram' + f' --prop title="Editorial Mono"' + f' --prop title.color=111111 --prop title.size=14 --prop title.bold=true' + f' --prop title.font="Helvetica Neue"' + f' --prop series1=Samples:{BELL_CSV}' + f' --prop binCount=18 --prop fill=2A2A2A' + f' --prop plotareafill=FFFFFF --prop "plotarea.border=CCCCCC:0.75"' + f' --prop chartareafill=FAFAFA --prop "chartarea.border=E0E0E0:0.75"' + f' --prop gridlineColor=EEEEEE' + f' --prop "axisfont=9:555555:Helvetica Neue"' + f' --prop xAxisTitle="Score" --prop yAxisTitle="Count"' + f' --prop axisTitle.color=333333 --prop axisTitle.size=10' + f' --prop axisTitle.font="Helvetica Neue"' + f' --prop "axisline=888888:1"' + f' --prop x=14 --prop y=19 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/3-Theme Gallery" --type chart \ +# --prop chartType=histogram \ +# --prop title="Neon Terminal" \ +# --prop title.color=00F0C8 --prop title.size=14 --prop title.bold=true \ +# --prop title.font="Courier New" \ +# --prop "title.shadow=00F0C8-6-45-0-40" \ +# --prop series1="Samples:<200 bell values>" \ +# --prop binCount=18 --prop fill=00F0C8 \ +# --prop "series.shadow=00F0C8-8-45-0-45" \ +# --prop plotareafill=0A0A14 --prop "plotarea.border=1F2F3F:1" \ +# --prop chartareafill=000008 --prop "chartarea.border=1F1F2F:1" \ +# --prop gridlineColor=1A2A3A \ +# --prop "axisfont=9:00D0E8:Courier New" \ +# --prop xAxisTitle="Score" --prop yAxisTitle="Count" \ +# --prop axisTitle.color=00D0E8 --prop axisTitle.size=10 \ +# --prop axisTitle.font="Courier New" \ +# --prop "axisline=00707F:1" \ +# --prop x=0 --prop y=38 --prop width=13 --prop height=18 +# Theme 5 · Neon Terminal (black + electric cyan, mono) +cli(f'add "{FILE}" "/3-Theme Gallery" --type chart' + f' --prop chartType=histogram' + f' --prop title="Neon Terminal"' + f' --prop title.color=00F0C8 --prop title.size=14 --prop title.bold=true' + f' --prop title.font="Courier New"' + f' --prop "title.shadow=00F0C8-6-45-0-40"' + f' --prop series1=Samples:{BELL_CSV}' + f' --prop binCount=18 --prop fill=00F0C8' + f' --prop "series.shadow=00F0C8-8-45-0-45"' + f' --prop plotareafill=0A0A14 --prop "plotarea.border=1F2F3F:1"' + f' --prop chartareafill=000008 --prop "chartarea.border=1F1F2F:1"' + f' --prop gridlineColor=1A2A3A' + f' --prop "axisfont=9:00D0E8:Courier New"' + f' --prop xAxisTitle="Score" --prop yAxisTitle="Count"' + f' --prop axisTitle.color=00D0E8 --prop axisTitle.size=10' + f' --prop axisTitle.font="Courier New"' + f' --prop "axisline=00707F:1"' + f' --prop x=0 --prop y=38 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/3-Theme Gallery" --type chart \ +# --prop chartType=histogram \ +# --prop title="Pastel Bloom" \ +# --prop title.color=5A3C4A --prop title.size=14 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop series1="Samples:<200 bell values>" \ +# --prop binCount=18 --prop fill=F5A7C8 \ +# --prop plotareafill=FDF4F8 --prop "plotarea.border=F0D0E0:1" \ +# --prop chartareafill=FAEDF2 --prop "chartarea.border=F0C0D8:1" \ +# --prop gridlineColor=F5D8E5 \ +# --prop "axisfont=9:8A6878:Helvetica Neue" \ +# --prop xAxisTitle="Score" --prop yAxisTitle="Count" \ +# --prop axisTitle.color=A04C6A --prop axisTitle.size=10 \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisline=C888A0:1" \ +# --prop x=14 --prop y=38 --prop width=13 --prop height=18 +# Theme 6 · Pastel Bloom (lavender cream + rose, sans) +cli(f'add "{FILE}" "/3-Theme Gallery" --type chart' + f' --prop chartType=histogram' + f' --prop title="Pastel Bloom"' + f' --prop title.color=5A3C4A --prop title.size=14 --prop title.bold=true' + f' --prop title.font="Helvetica Neue"' + f' --prop series1=Samples:{BELL_CSV}' + f' --prop binCount=18 --prop fill=F5A7C8' + f' --prop plotareafill=FDF4F8 --prop "plotarea.border=F0D0E0:1"' + f' --prop chartareafill=FAEDF2 --prop "chartarea.border=F0C0D8:1"' + f' --prop gridlineColor=F5D8E5' + f' --prop "axisfont=9:8A6878:Helvetica Neue"' + f' --prop xAxisTitle="Score" --prop yAxisTitle="Count"' + f' --prop axisTitle.color=A04C6A --prop axisTitle.size=10' + f' --prop axisTitle.font="Helvetica Neue"' + f' --prop "axisline=C888A0:1"' + f' --prop x=14 --prop y=38 --prop width=13 --prop height=18') + + +# ========================================================================== +# Sheet 4: "4-Typography" +# +# Four font-family "type specimens". Same data, same geometry, same colors — +# only the font varies. Side-by-side, this shows how typography alone reads +# as tone: Helvetica is corporate, Georgia is editorial, Courier is data, +# Verdana is approachable. +# ========================================================================== +print("\n--- 4-Typography ---") +cli(f'add "{FILE}" / --type sheet --prop name="4-Typography"') + +# officecli add charts-histogram.xlsx "/4-Typography" --type chart \ +# --prop chartType=histogram \ +# --prop title="Helvetica Neue · modern sans" \ +# --prop title.color=1F2937 --prop title.size=16 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop series1="Samples:<200 bell values>" \ +# --prop binCount=18 --prop fill=4472C4 \ +# --prop xAxisTitle="Score" --prop yAxisTitle="Count" \ +# --prop axisTitle.color=4472C4 --prop axisTitle.size=11 \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisfont=10:6B7280:Helvetica Neue" \ +# --prop gridlineColor=EEEEEE \ +# --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75" \ +# --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75" \ +# --prop x=0 --prop y=0 --prop width=13 --prop height=18 +# Specimen 1 · Helvetica Neue (modern sans — dashboards, corporate reports) +cli(f'add "{FILE}" "/4-Typography" --type chart' + f' --prop chartType=histogram' + f' --prop title="Helvetica Neue · modern sans"' + f' --prop title.color=1F2937 --prop title.size=16 --prop title.bold=true' + f' --prop title.font="Helvetica Neue"' + f' --prop series1=Samples:{BELL_CSV}' + f' --prop binCount=18 --prop fill=4472C4' + f' --prop xAxisTitle="Score" --prop yAxisTitle="Count"' + f' --prop axisTitle.color=4472C4 --prop axisTitle.size=11' + f' --prop axisTitle.font="Helvetica Neue"' + f' --prop "axisfont=10:6B7280:Helvetica Neue"' + f' --prop gridlineColor=EEEEEE' + f' --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75"' + f' --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75"' + f' --prop x=0 --prop y=0 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/4-Typography" --type chart \ +# --prop chartType=histogram \ +# --prop title="Georgia · editorial serif" \ +# --prop title.color=3F2818 --prop title.size=16 --prop title.bold=true \ +# --prop title.font="Georgia" \ +# --prop series1="Samples:<200 bell values>" \ +# --prop binCount=18 --prop fill=A8522C \ +# --prop xAxisTitle="Score" --prop yAxisTitle="Count" \ +# --prop axisTitle.color=A8522C --prop axisTitle.size=11 \ +# --prop axisTitle.font="Georgia" \ +# --prop "axisfont=10:6B4A2A:Georgia" \ +# --prop gridlineColor=F0E8D8 \ +# --prop plotareafill=FFFBF3 --prop "plotarea.border=E8D8B8:0.75" \ +# --prop chartareafill=FDF6E8 --prop "chartarea.border=E8D8B8:0.75" \ +# --prop x=14 --prop y=0 --prop width=13 --prop height=18 +# Specimen 2 · Georgia (editorial serif — magazines, long-form reports) +cli(f'add "{FILE}" "/4-Typography" --type chart' + f' --prop chartType=histogram' + f' --prop title="Georgia · editorial serif"' + f' --prop title.color=3F2818 --prop title.size=16 --prop title.bold=true' + f' --prop title.font="Georgia"' + f' --prop series1=Samples:{BELL_CSV}' + f' --prop binCount=18 --prop fill=A8522C' + f' --prop xAxisTitle="Score" --prop yAxisTitle="Count"' + f' --prop axisTitle.color=A8522C --prop axisTitle.size=11' + f' --prop axisTitle.font="Georgia"' + f' --prop "axisfont=10:6B4A2A:Georgia"' + f' --prop gridlineColor=F0E8D8' + f' --prop plotareafill=FFFBF3 --prop "plotarea.border=E8D8B8:0.75"' + f' --prop chartareafill=FDF6E8 --prop "chartarea.border=E8D8B8:0.75"' + f' --prop x=14 --prop y=0 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/4-Typography" --type chart \ +# --prop chartType=histogram \ +# --prop title="Courier New · data mono" \ +# --prop title.color=1A3A1A --prop title.size=16 --prop title.bold=true \ +# --prop title.font="Courier New" \ +# --prop series1="Samples:<200 bell values>" \ +# --prop binCount=18 --prop fill=2F8F4F \ +# --prop xAxisTitle="Score" --prop yAxisTitle="Count" \ +# --prop axisTitle.color=2F8F4F --prop axisTitle.size=11 \ +# --prop axisTitle.font="Courier New" \ +# --prop "axisfont=10:3A5A3A:Courier New" \ +# --prop gridlineColor=E0EDE0 \ +# --prop plotareafill=F7FBF7 --prop "plotarea.border=C8DCC8:0.75" \ +# --prop chartareafill=F0F7F0 --prop "chartarea.border=C8DCC8:0.75" \ +# --prop x=0 --prop y=19 --prop width=13 --prop height=18 +# Specimen 3 · Courier New (monospace — data, telemetry, engineering) +cli(f'add "{FILE}" "/4-Typography" --type chart' + f' --prop chartType=histogram' + f' --prop title="Courier New · data mono"' + f' --prop title.color=1A3A1A --prop title.size=16 --prop title.bold=true' + f' --prop title.font="Courier New"' + f' --prop series1=Samples:{BELL_CSV}' + f' --prop binCount=18 --prop fill=2F8F4F' + f' --prop xAxisTitle="Score" --prop yAxisTitle="Count"' + f' --prop axisTitle.color=2F8F4F --prop axisTitle.size=11' + f' --prop axisTitle.font="Courier New"' + f' --prop "axisfont=10:3A5A3A:Courier New"' + f' --prop gridlineColor=E0EDE0' + f' --prop plotareafill=F7FBF7 --prop "plotarea.border=C8DCC8:0.75"' + f' --prop chartareafill=F0F7F0 --prop "chartarea.border=C8DCC8:0.75"' + f' --prop x=0 --prop y=19 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/4-Typography" --type chart \ +# --prop chartType=histogram \ +# --prop title="Verdana · friendly sans" \ +# --prop title.color=4A2B6A --prop title.size=16 --prop title.bold=true \ +# --prop title.font="Verdana" \ +# --prop series1="Samples:<200 bell values>" \ +# --prop binCount=18 --prop fill=8E4DBB \ +# --prop xAxisTitle="Score" --prop yAxisTitle="Count" \ +# --prop axisTitle.color=8E4DBB --prop axisTitle.size=11 \ +# --prop axisTitle.font="Verdana" \ +# --prop "axisfont=10:6B4A8A:Verdana" \ +# --prop gridlineColor=ECE0F4 \ +# --prop plotareafill=FCF7FF --prop "plotarea.border=D8C4E8:0.75" \ +# --prop chartareafill=F6EDFA --prop "chartarea.border=D8C4E8:0.75" \ +# --prop x=14 --prop y=19 --prop width=13 --prop height=18 +# Specimen 4 · Verdana (friendly sans — onboarding, public-facing UI) +cli(f'add "{FILE}" "/4-Typography" --type chart' + f' --prop chartType=histogram' + f' --prop title="Verdana · friendly sans"' + f' --prop title.color=4A2B6A --prop title.size=16 --prop title.bold=true' + f' --prop title.font="Verdana"' + f' --prop series1=Samples:{BELL_CSV}' + f' --prop binCount=18 --prop fill=8E4DBB' + f' --prop xAxisTitle="Score" --prop yAxisTitle="Count"' + f' --prop axisTitle.color=8E4DBB --prop axisTitle.size=11' + f' --prop axisTitle.font="Verdana"' + f' --prop "axisfont=10:6B4A8A:Verdana"' + f' --prop gridlineColor=ECE0F4' + f' --prop plotareafill=FCF7FF --prop "plotarea.border=D8C4E8:0.75"' + f' --prop chartareafill=F6EDFA --prop "chartarea.border=D8C4E8:0.75"' + f' --prop x=14 --prop y=19 --prop width=13 --prop height=18') + + +# ========================================================================== +# Sheet 5: "5-ML Dashboard" +# +# A cohesive six-chart "Production ML Model Report". Every chart wears the +# same corporate dashboard uniform — same typography, same frames, same +# gridlines — but each shows a different slice of the model's behavior, +# deliberately using a different color + binning strategy so the six read +# as a single dashboard at a glance. +# +# Row 1: Inference latency (ms) | Prediction confidence (%) +# Row 2: |Residual| (logit) | Token length (chars) +# Row 3: GPU utilization (%) | Cost per request ($ × 0.001) +# ========================================================================== +print("\n--- 5-ML Dashboard ---") +cli(f'add "{FILE}" / --type sheet --prop name="5-ML Dashboard"') + +DASH = ( + ' --prop title.color=1F2937 --prop title.size=12 --prop title.bold=true' + ' --prop title.font="Helvetica Neue"' + ' --prop axisTitle.color=6B7280 --prop axisTitle.size=9' + ' --prop axisTitle.font="Helvetica Neue"' + ' --prop "axisfont=8:6B7280:Helvetica Neue"' + ' --prop gridlineColor=F0F0F0' + ' --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75"' + ' --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75"' + ' --prop "axisline=9CA3AF:0.75"' + ' --prop dataLabels=false' +) + +# officecli add charts-histogram.xlsx "/5-ML Dashboard" --type chart \ +# --prop chartType=histogram \ +# --prop title="Inference Latency · p50-p99 (ms)" \ +# --prop series1="Latency:<250 Pareto latency values>" \ +# --prop binSize=25 --prop overflowBin=300 --prop fill=EF4444 \ +# --prop "series.shadow=EF4444-4-45-2-25" \ +# --prop xAxisTitle="Latency (ms)" --prop yAxisTitle="Requests" \ +# --prop title.color=1F2937 --prop title.size=12 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop axisTitle.color=6B7280 --prop axisTitle.size=9 \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisfont=8:6B7280:Helvetica Neue" \ +# --prop gridlineColor=F0F0F0 \ +# --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75" \ +# --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75" \ +# --prop "axisline=9CA3AF:0.75" \ +# --prop dataLabels=false \ +# --prop x=0 --prop y=0 --prop width=13 --prop height=18 +# 1 · Inference Latency — heavy-tail, overflow-fenced, red for "watch this" +cli(f'add "{FILE}" "/5-ML Dashboard" --type chart' + f' --prop chartType=histogram' + f' --prop title="Inference Latency · p50-p99 (ms)"' + f' --prop series1=Latency:{LATENCY_CSV}' + f' --prop binSize=25 --prop overflowBin=300 --prop fill=EF4444' + f' --prop "series.shadow=EF4444-4-45-2-25"' + f' --prop xAxisTitle="Latency (ms)" --prop yAxisTitle="Requests"' + f'{DASH}' + f' --prop x=0 --prop y=0 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/5-ML Dashboard" --type chart \ +# --prop chartType=histogram \ +# --prop title="Prediction Confidence" \ +# --prop series1="Confidence:<240 beta confidence values>" \ +# --prop binSize=5 --prop fill=10B981 \ +# --prop axismin=0 --prop majorunit=50 \ +# --prop xAxisTitle="Softmax confidence (%)" --prop yAxisTitle="Samples" \ +# --prop title.color=1F2937 --prop title.size=12 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop axisTitle.color=6B7280 --prop axisTitle.size=9 \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisfont=8:6B7280:Helvetica Neue" \ +# --prop gridlineColor=F0F0F0 \ +# --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75" \ +# --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75" \ +# --prop "axisline=9CA3AF:0.75" \ +# --prop dataLabels=false \ +# --prop x=14 --prop y=0 --prop width=13 --prop height=18 +# 2 · Prediction Confidence — beta-like, axismin/max locked to 0..100 +cli(f'add "{FILE}" "/5-ML Dashboard" --type chart' + f' --prop chartType=histogram' + f' --prop title="Prediction Confidence"' + f' --prop series1=Confidence:{CONFIDENCE_CSV}' + f' --prop binSize=5 --prop fill=10B981' + f' --prop axismin=0 --prop majorunit=50' + f' --prop xAxisTitle="Softmax confidence (%)" --prop yAxisTitle="Samples"' + f'{DASH}' + f' --prop x=14 --prop y=0 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/5-ML Dashboard" --type chart \ +# --prop chartType=histogram \ +# --prop title="|Residual| · model calibration" \ +# --prop series1="Residual:<180 half-normal error values>" \ +# --prop binSize=0.25 --prop intervalClosed=l --prop fill=F59E0B \ +# --prop xAxisTitle="|y - ŷ| (logit)" --prop yAxisTitle="Samples" \ +# --prop title.color=1F2937 --prop title.size=12 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop axisTitle.color=6B7280 --prop axisTitle.size=9 \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisfont=8:6B7280:Helvetica Neue" \ +# --prop gridlineColor=F0F0F0 \ +# --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75" \ +# --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75" \ +# --prop "axisline=9CA3AF:0.75" \ +# --prop dataLabels=false \ +# --prop x=0 --prop y=19 --prop width=13 --prop height=18 +# 3 · Residual Magnitude — half-normal, intervalClosed=l so bin=0 catches zeros +cli(f'add "{FILE}" "/5-ML Dashboard" --type chart' + f' --prop chartType=histogram' + f' --prop title="|Residual| · model calibration"' + f' --prop series1=Residual:{ERROR_MAG_CSV}' + f' --prop binSize=0.25 --prop intervalClosed=l --prop fill=F59E0B' + f' --prop xAxisTitle="|y - ŷ| (logit)" --prop yAxisTitle="Samples"' + f'{DASH}' + f' --prop x=0 --prop y=19 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/5-ML Dashboard" --type chart \ +# --prop chartType=histogram \ +# --prop title="Token Length · short vs long prompts" \ +# --prop series1="Tokens:<180 bimodal token-length values>" \ +# --prop binCount=24 --prop fill=6366F1 \ +# --prop xAxisTitle="Tokens" --prop yAxisTitle="Requests" \ +# --prop title.color=1F2937 --prop title.size=12 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop axisTitle.color=6B7280 --prop axisTitle.size=9 \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisfont=8:6B7280:Helvetica Neue" \ +# --prop gridlineColor=F0F0F0 \ +# --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75" \ +# --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75" \ +# --prop "axisline=9CA3AF:0.75" \ +# --prop dataLabels=false \ +# --prop x=14 --prop y=19 --prop width=13 --prop height=18 +# 4 · Token Length — bimodal (short prompts vs long prompts) +cli(f'add "{FILE}" "/5-ML Dashboard" --type chart' + f' --prop chartType=histogram' + f' --prop title="Token Length · short vs long prompts"' + f' --prop series1=Tokens:{TOKEN_CSV}' + f' --prop binCount=24 --prop fill=6366F1' + f' --prop xAxisTitle="Tokens" --prop yAxisTitle="Requests"' + f'{DASH}' + f' --prop x=14 --prop y=19 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/5-ML Dashboard" --type chart \ +# --prop chartType=histogram \ +# --prop title="GPU Utilization" \ +# --prop series1="GPU:<200 normal GPU utilization values>" \ +# --prop binSize=5 --prop fill=8B5CF6 \ +# --prop axismin=0 --prop axismax=50 --prop majorunit=10 \ +# --prop xAxisTitle="Utilization (%)" --prop yAxisTitle="Samples" \ +# --prop title.color=1F2937 --prop title.size=12 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop axisTitle.color=6B7280 --prop axisTitle.size=9 \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisfont=8:6B7280:Helvetica Neue" \ +# --prop gridlineColor=F0F0F0 \ +# --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75" \ +# --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75" \ +# --prop "axisline=9CA3AF:0.75" \ +# --prop dataLabels=false \ +# --prop x=0 --prop y=38 --prop width=13 --prop height=18 +# 5 · GPU Utilization — locked axis range so dashboard charts share scale +cli(f'add "{FILE}" "/5-ML Dashboard" --type chart' + f' --prop chartType=histogram' + f' --prop title="GPU Utilization"' + f' --prop series1=GPU:{GPU_CSV}' + f' --prop binSize=5 --prop fill=8B5CF6' + f' --prop axismin=0 --prop axismax=50 --prop majorunit=10' + f' --prop xAxisTitle="Utilization (%)" --prop yAxisTitle="Samples"' + f'{DASH}' + f' --prop x=0 --prop y=38 --prop width=13 --prop height=18') + +# officecli add charts-histogram.xlsx "/5-ML Dashboard" --type chart \ +# --prop chartType=histogram \ +# --prop title="Cost per Request ($ × 0.001)" \ +# --prop series1="Cost:<220 log-normal cost values>" \ +# --prop binSize=5 --prop overflowBin=120 --prop fill=EC4899 \ +# --prop dataLabels=true --prop "datalabels.numfmt=0" \ +# --prop xAxisTitle="Cost (m$)" --prop yAxisTitle="Requests" \ +# --prop title.color=1F2937 --prop title.size=12 --prop title.bold=true \ +# --prop title.font="Helvetica Neue" \ +# --prop axisTitle.color=6B7280 --prop axisTitle.size=9 \ +# --prop axisTitle.font="Helvetica Neue" \ +# --prop "axisfont=8:6B7280:Helvetica Neue" \ +# --prop gridlineColor=F0F0F0 \ +# --prop plotareafill=FFFFFF --prop "plotarea.border=E5E7EB:0.75" \ +# --prop chartareafill=F9FAFB --prop "chartarea.border=E5E7EB:0.75" \ +# --prop "axisline=9CA3AF:0.75" \ +# --prop x=14 --prop y=38 --prop width=13 --prop height=18 +# 6 · Cost per Request — log-normal, overflow-fenced, data labels with numfmt +cli(f'add "{FILE}" "/5-ML Dashboard" --type chart' + f' --prop chartType=histogram' + f' --prop title="Cost per Request ($ × 0.001)"' + f' --prop series1=Cost:{COST_CSV}' + f' --prop binSize=5 --prop overflowBin=120 --prop fill=EC4899' + f' --prop dataLabels=true --prop "datalabels.numfmt=0"' + f' --prop xAxisTitle="Cost (m$)" --prop yAxisTitle="Requests"' + f'{DASH}' + f' --prop x=14 --prop y=38 --prop width=13 --prop height=18') + + +print(f"\nDone! Generated: {FILE}") +print(" 6 sheets, 29 histograms total") +print(" Sheet 0 (0-Hero): 1 magazine-grade full-bleed hero poster") +print(" Sheet 1 (1-Binning Lab): 6 charts — every binning knob, identical styling") +print(" Sheet 2 (2-Distribution Zoo): 6 canonical real-world distribution shapes") +print(" Sheet 3 (3-Theme Gallery): 6 design themes on the SAME dataset") +print(" Sheet 4 (4-Typography): 4 font-family type specimens") +print(" Sheet 5 (5-ML Dashboard): 6-chart Production ML Model Report") diff --git a/examples/excel/charts-histogram.xlsx b/examples/excel/charts-histogram.xlsx new file mode 100644 index 000000000..33e19b909 Binary files /dev/null and b/examples/excel/charts-histogram.xlsx differ diff --git a/examples/excel/charts-line.md b/examples/excel/charts-line.md new file mode 100644 index 000000000..993cf16f9 --- /dev/null +++ b/examples/excel/charts-line.md @@ -0,0 +1,292 @@ +# Line Charts Showcase + +This demo consists of three files that work together: + +- **charts-line.py** — Python script that calls `officecli` commands to generate the workbook. Each chart command is shown as a copyable shell command in the comments. +- **charts-line.xlsx** — The generated workbook with 8 sheets (1 data + 7 chart sheets, 28 charts total). +- **charts-line.md** — This file. Maps each sheet to the features it demonstrates. + +## Regenerate + +```bash +cd examples/excel +python3 charts-line.py +# → charts-line.xlsx +``` + +## Chart Sheets + +### Sheet: 1-Line Fundamentals + +Four basic line charts covering every data input method and marker fundamentals. + +```bash +# Inline named series with axis titles +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop series1="Product A:120,180,210,250" \ + --prop series2="Product B:90,140,160,200" \ + --prop categories=Q1,Q2,Q3,Q4 \ + --prop colors=4472C4,ED7D31,70AD47 \ + --prop catTitle=Quarter --prop axisTitle=Revenue \ + --prop axisfont=9:58626E:Arial --prop gridlines=D9D9D9:0.5:dot + +# Cell-range series (dotted syntax) with markers +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop series1.name=East \ + --prop series1.values=Sheet1!B2:B13 \ + --prop series1.categories=Sheet1!A2:A13 \ + --prop showMarkers=true --prop marker=circle:6:2E75B6 \ + --prop minorGridlines=EEEEEE:0.3:dot + +# dataRange (auto-reads headers) with diamond markers +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop dataRange=Sheet1!A1:E13 \ + --prop showMarkers=true --prop marker=diamond:5:333333 \ + --prop legend=bottom --prop legendfont=9:58626E:Calibri + +# Inline data shorthand with marker=none +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop 'data=Actual:80,120,160;Target:100,130,160' \ + --prop marker=none --prop legend=right +``` + +**Features:** `series1=Name:v1,v2`, `series1.name`/`.values`/`.categories` (cell range), `dataRange`, `data` (shorthand), `categories`, `colors`, `catTitle`, `axisTitle`, `axisfont`, `gridlines`, `minorGridlines`, `showMarkers`, `marker` (circle, diamond, none), `legend` (bottom, right), `legendfont` + +### Sheet: 2-Line Styles + +Four charts demonstrating visual styling — smoothing, dash patterns, markers, and transparency. + +```bash +# Smooth curves with shadow, axes hidden +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop smooth=true --prop lineWidth=2.5 \ + --prop gridlines=none --prop axisVisible=false \ + --prop series.shadow=000000-4-315-2-40 + +# Dashed lines (applies to all series) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop lineDash=dash --prop lineWidth=2 + +# Marker styles with series outline +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop showMarkers=true --prop marker=square:7:4472C4 \ + --prop series.outline=FFFFFF-0.5 + +# Transparent lines on gradient plot area +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop lineWidth=3 --prop smooth=true \ + --prop transparency=30 \ + --prop plotFill=F0F4F8-D6E4F0:90 --prop chartFill=FFFFFF \ + --prop title.font=Georgia --prop title.size=14 \ + --prop title.color=1F4E79 --prop title.bold=true \ + --prop roundedCorners=true +``` + +**Features:** `smooth`, `lineWidth`, `lineDash` (solid/dot/dash/dashdot/longdash/longdashdot/longdashdotdot), `marker` (square), `series.shadow` (color-blur-angle-dist-opacity), `series.outline`, `transparency`, `plotFill` (gradient), `chartFill`, `title.font`/`.size`/`.color`/`.bold`, `roundedCorners`, `gridlines=none`, `axisVisible=false` + +### Sheet: 3-Line Variants + +Four charts covering all line chart type variants. + +```bash +# Stacked line — cumulative values +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=lineStacked \ + --prop majorTickMark=outside --prop tickLabelPos=low + +# 100% stacked line — proportional +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=linePercentStacked \ + --prop axisNumFmt=0% + +# 3D line with perspective +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line3d \ + --prop view3d=15,20,30 --prop style=3 + +# Stacked line with data table +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=lineStacked \ + --prop dataTable=true --prop legend=none +``` + +**Features:** `lineStacked`, `linePercentStacked`, `line3d`, `majorTickMark`, `tickLabelPos`, `axisNumFmt`, `view3d` (rotX,rotY,perspective), `style` (preset 1-48), `dataTable`, `legend=none` + +### Sheet: 4-Axis & Gridlines + +Four charts demonstrating every axis and gridline configuration. + +```bash +# Custom axis scaling with axis lines +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop axisMin=80 --prop axisMax=220 \ + --prop majorUnit=20 --prop minorUnit=10 \ + --prop axisLine=C00000:1.5:solid --prop catAxisLine=2E75B6:1.5:solid + +# Logarithmic scale +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop logBase=10 \ + --prop marker=triangle:7:C00000 + +# Reversed value axis +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop axisReverse=true + +# Display units with tick marks +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop dispUnits=thousands \ + --prop majorTickMark=outside --prop minorTickMark=inside \ + --prop marker=star:7:2E75B6 +``` + +**Features:** `axisMin`, `axisMax`, `majorUnit`, `minorUnit`, `axisLine`, `catAxisLine`, `logBase` (logarithmic scale), `axisReverse` (flip direction), `dispUnits` (thousands/millions), `majorTickMark`, `minorTickMark`, `marker` (triangle, star) + +### Sheet: 5-Labels & Legend + +Four charts demonstrating data label and legend customization. + +```bash +# Data labels with number format +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop dataLabels=true --prop labelPos=top \ + --prop labelFont=9:333333:true \ + --prop dataLabels.numFmt=#,##0 \ + --prop dataLabels.separator=": " + +# Custom individual data labels (hide some, highlight peak with color + label) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop dataLabels=true \ + --prop dataLabel1.delete=true --prop dataLabel2.delete=true \ + --prop point4.color=C00000 \ + --prop dataLabel4.text="Peak: 210" --prop dataLabel4.y=0.15 + +# Legend overlay +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop legend=top --prop legend.overlay=true \ + --prop legendfont=10:1F4E79:Calibri + +# Manual layout — plotArea, title, legend positioning +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop plotArea.x=0.12 --prop plotArea.y=0.18 \ + --prop plotArea.w=0.82 --prop plotArea.h=0.55 \ + --prop title.x=0.25 --prop title.y=0.02 \ + --prop legend.x=0.15 --prop legend.y=0.82 \ + --prop legend.w=0.7 --prop legend.h=0.12 +``` + +**Features:** `dataLabels`, `labelPos` (top/center/insideEnd/outsideEnd/bestFit), `labelFont`, `dataLabels.numFmt`, `dataLabels.separator`, `dataLabel{N}.delete`, `dataLabel{N}.text`, `dataLabel{N}.y` (manual label position), `point{N}.color` (individual point color), `legend` (top), `legend.overlay`, `legendfont`, `plotArea.x/y/w/h`, `title.x/y`, `legend.x/y/w/h` + +### Sheet: 6-Effects & Advanced + +Four charts demonstrating advanced features — secondary axis, reference lines, effects, and conditional coloring. + +```bash +# Secondary axis (dual scale) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop secondaryAxis=2 \ + --prop series1="Revenue:120,180,250,310" \ + --prop series2="Growth %:50,33,39,24" + +# Reference line with longdash style +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop referenceLine=150:FF0000:1.5:dash \ + --prop lineDash=longdash + +# Title glow/shadow effects +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop title.glow=4472C4-8-60 \ + --prop title.shadow=000000-3-315-2-40 \ + --prop series.shadow=000000-3-315-1-30 + +# Conditional coloring with chart/plot borders +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop colorRule=0:C00000:70AD47 \ + --prop referenceLine=0:888888:1:solid \ + --prop chartArea.border=D0D0D0:1:solid \ + --prop plotArea.border=E0E0E0:0.5:dot +``` + +**Features:** `secondaryAxis` (1-based series indices), `referenceLine` (value:color:width:dash), `title.glow` (color-radius-opacity), `title.shadow` (color-blur-angle-dist-opacity), `series.shadow`, `colorRule` (threshold:belowColor:aboveColor), `chartArea.border`, `plotArea.border` + +### Sheet: 7-Line Elements + +Four charts demonstrating line-chart-specific structural elements. + +```bash +# Drop lines — vertical lines from points to X axis +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop dropLines=true + +# High-low lines — connect highest and lowest series per category +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop hiLowLines=true + +# Up-down bars with custom gain/loss colors +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line \ + --prop updownbars=100:70AD47:C00000 + +# 3D line with gap depth +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=line3d \ + --prop gapDepth=300 +``` + +**Features:** `dropLines` (vertical drop to axis), `hiLowLines` (high-low connectors), `updownbars` (gapWidth:upColor:downColor), `gapDepth` (3D depth spacing 0-500) + +## Complete Feature Coverage + +| Feature | Sheet | +|---------|-------| +| **Chart types:** line, lineStacked, linePercentStacked, line3d | 1, 3 | +| **Data input:** series, dataRange, data, series.name/values/categories | 1 | +| **Line styling:** smooth, lineWidth, lineDash, colors | 2 | +| **Markers:** circle, diamond, square, triangle, star, none, auto | 1, 2, 4 | +| **Axis scaling:** axisMin/Max, majorUnit, minorUnit | 4 | +| **Axis features:** logBase, axisReverse, dispUnits, axisNumFmt | 3, 4 | +| **Axis lines:** axisLine, catAxisLine | 4 | +| **Axis visibility:** axisVisible | 2 | +| **Tick marks:** majorTickMark, minorTickMark, tickLabelPos | 3, 4 | +| **Gridlines:** gridlines, minorGridlines, gridlines=none | 1, 2, 4 | +| **Data labels:** dataLabels, labelPos, labelFont, numFmt, separator | 5 | +| **Custom labels:** dataLabel{N}.text, dataLabel{N}.delete, dataLabel{N}.y | 5 | +| **Point color:** point{N}.color | 5 | +| **Legend:** position, legendfont, legend.overlay, legend=none | 1, 3, 5 | +| **Layout:** plotArea.x/y/w/h, title.x/y, legend.x/y/w/h | 5 | +| **Effects:** series.shadow, series.outline, transparency | 2, 6 | +| **Title styling:** font, size, color, bold, glow, shadow | 2, 6 | +| **Fills:** plotFill, chartFill (solid + gradient) | 2, 3, 6 | +| **Borders:** chartArea.border, plotArea.border | 6 | +| **Advanced:** secondaryAxis, referenceLine, colorRule | 6 | +| **Line elements:** dropLines, hiLowLines, upDownBars | 7 | +| **3D:** view3d, gapDepth, style | 3, 7 | +| **Other:** dataTable, roundedCorners | 2, 3 | + +## Inspect the Generated File + +```bash +officecli query charts-line.xlsx chart +officecli get charts-line.xlsx "/1-Line Fundamentals/chart[1]" +``` diff --git a/examples/excel/charts-line.py b/examples/excel/charts-line.py new file mode 100644 index 000000000..3c267e4ba --- /dev/null +++ b/examples/excel/charts-line.py @@ -0,0 +1,993 @@ +#!/usr/bin/env python3 +""" +Line Charts Showcase — line, lineStacked, linePercentStacked, and line3d with all variations. + +Generates: charts-line.xlsx + +Every line chart feature officecli supports is demonstrated at least once: +line styles, markers, smoothing, dash patterns, axis scaling, gridlines, +data labels, legend positioning, reference lines, secondary axis, error bars, +gradients, transparency, shadows, manual layout, data table, and 3D rotation. + +6 sheets, 24 charts total. + + 1-Line Fundamentals 4 charts — data input variants, markers, cell-range series + 2-Line Styles 4 charts — lineWidth, lineDash, smooth, color palettes + 3-Line Variants 4 charts — lineStacked, linePercentStacked, line3d + 4-Axis & Gridlines 4 charts — axis scaling, log scale, reverse, tick marks + 5-Labels & Legend 4 charts — data labels, custom labels, legend layout + 6-Effects & Advanced 4 charts — shadows, gradients, secondary axis, reference lines + +Usage: + python3 charts-line.py +""" + +import subprocess, sys, os, json, atexit + +FILE = "charts-line.xlsx" + +def cli(cmd): + """Run: officecli """ + r = subprocess.run(f"officecli {cmd}", shell=True, capture_output=True, text=True) + out = (r.stdout or "").strip() + if out: + for line in out.split("\n"): + if line.strip(): + print(f" {line.strip()}") + if r.returncode != 0: + err = (r.stderr or "").strip() + if err and "UNSUPPORTED" not in err and "process cannot access" not in err: + print(f" ERROR: {err}") + +if os.path.exists(FILE): + os.remove(FILE) + +cli(f'create "{FILE}"') +cli(f'open "{FILE}"') +atexit.register(lambda: cli(f'close "{FILE}"')) + +# ========================================================================== +# Source data — shared across all charts +# ========================================================================== +print("\n--- Populating source data ---") + +data_cmds = [] +for j, h in enumerate(["Month", "East", "South", "North", "West"]): + data_cmds.append({"command": "set", "path": f"/Sheet1/{'ABCDE'[j]}1", "props": {"text": h, "bold": "true"}}) + +months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] +east = [120, 135, 148, 162, 155, 178, 195, 210, 188, 172, 165, 198] +south = [95, 108, 115, 128, 142, 155, 168, 175, 160, 148, 135, 158] +north = [88, 92, 105, 118, 125, 138, 145, 152, 140, 130, 122, 142] +west = [110, 118, 130, 145, 138, 162, 175, 190, 170, 155, 148, 180] + +for i in range(12): + r = i + 2 + for j, val in enumerate([months[i], east[i], south[i], north[i], west[i]]): + data_cmds.append({"command": "set", "path": f"/Sheet1/{'ABCDE'[j]}{r}", "props": {"text": str(val)}}) + +cli(f'batch "{FILE}" --force --commands \'{json.dumps(data_cmds)}\'') + +# ========================================================================== +# Sheet: 1-Line Fundamentals +# ========================================================================== +print("\n--- 1-Line Fundamentals ---") +cli(f'add "{FILE}" / --type sheet --prop name="1-Line Fundamentals"') + +# -------------------------------------------------------------------------- +# Chart 1: Basic line with inline named series and categories +# +# officecli add charts-line.xlsx "/1-Line Fundamentals" --type chart \ +# --prop chartType=line \ +# --prop title="Quarterly Revenue" \ +# --prop series1="Product A:120,180,210,250" \ +# --prop series2="Product B:90,140,160,200" \ +# --prop series3="Product C:60,85,110,145" \ +# --prop categories=Q1,Q2,Q3,Q4 \ +# --prop colors=4472C4,ED7D31,70AD47 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop catTitle=Quarter --prop axisTitle=Revenue \ +# --prop axisfont=9:C00000:Arial \ +# --prop gridlines=D9D9D9:0.5:dot +# +# Features: chartType=line, inline series (series1=Name:v1,v2,...), +# categories, colors, catTitle, axisTitle, axisfont, gridlines +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Line Fundamentals" --type chart' + f' --prop chartType=line' + f' --prop title="Quarterly Revenue"' + f' --prop series1="Product A:120,180,210,250"' + f' --prop series2="Product B:90,140,160,200"' + f' --prop series3="Product C:60,85,110,145"' + f' --prop categories=Q1,Q2,Q3,Q4' + f' --prop colors=4472C4,ED7D31,70AD47' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop catTitle=Quarter --prop axisTitle=Revenue' + f' --prop axisfont=9:C00000:Arial' + f' --prop gridlines=D9D9D9:0.5:dot') + +# -------------------------------------------------------------------------- +# Chart 2: Line with cell-range series (dotted syntax) and markers +# +# officecli add charts-line.xlsx "/1-Line Fundamentals" --type chart \ +# --prop chartType=line \ +# --prop title="East Region Trend" \ +# --prop series1.name=East \ +# --prop series1.values=Sheet1!B2:B13 \ +# --prop series1.categories=Sheet1!A2:A13 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop showMarkers=true --prop marker=circle:6:2E75B6 \ +# --prop gridlines=D9D9D9:0.5:dot \ +# --prop minorGridlines=EEEEEE:0.3:dot +# +# Features: series.name/values/categories (cell range via dotted syntax), +# showMarkers, marker (style:size:color), minorGridlines +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Line Fundamentals" --type chart' + f' --prop chartType=line' + f' --prop title="East Region Trend"' + f' --prop series1.name=East' + f' --prop series1.values=Sheet1!B2:B13' + f' --prop series1.categories=Sheet1!A2:A13' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop showMarkers=true --prop marker=circle:6:2E75B6' + f' --prop gridlines=D9D9D9:0.5:dot' + f' --prop minorGridlines=EEEEEE:0.3:dot') + +# -------------------------------------------------------------------------- +# Chart 3: Line from dataRange with all four regions +# +# officecli add charts-line.xlsx "/1-Line Fundamentals" --type chart \ +# --prop chartType=line \ +# --prop title="All Regions — Full Year" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop colors=2E75B6,70AD47,FFC000,C00000 \ +# --prop showMarkers=true --prop marker=diamond:5:333333 \ +# --prop lineWidth=2 \ +# --prop legend=bottom \ +# --prop legendfont=9:58626E:Calibri +# +# Features: dataRange (auto-reads headers as series names), marker=diamond, +# lineWidth, legend=bottom, legendfont +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Line Fundamentals" --type chart' + f' --prop chartType=line' + f' --prop title="All Regions — Full Year"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop colors=2E75B6,70AD47,FFC000,C00000' + f' --prop showMarkers=true --prop marker=diamond:5:333333' + f' --prop lineWidth=2' + f' --prop legend=bottom' + f' --prop legendfont=9:58626E:Calibri') + +# -------------------------------------------------------------------------- +# Chart 4: Line with inline data shorthand and marker=none +# +# officecli add charts-line.xlsx "/1-Line Fundamentals" --type chart \ +# --prop chartType=line \ +# --prop title="Simple Two-Series" \ +# --prop 'data=Actual:80,120,160,200,240;Target:100,130,160,190,220' \ +# --prop categories=Week 1,Week 2,Week 3,Week 4,Week 5 \ +# --prop colors=0070C0,FF0000 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop marker=none \ +# --prop legend=right +# +# Features: data (inline shorthand Name:v1;Name2:v2), marker=none, +# legend=right +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Line Fundamentals" --type chart' + f' --prop chartType=line' + f' --prop title="Simple Two-Series"' + f' --prop "data=Actual:80,120,160,200,240;Target:100,130,160,190,220"' + f' --prop categories=Week 1,Week 2,Week 3,Week 4,Week 5' + f' --prop colors=0070C0,FF0000' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop marker=none' + f' --prop legend=right') + +# ========================================================================== +# Sheet: 2-Line Styles +# ========================================================================== +print("\n--- 2-Line Styles ---") +cli(f'add "{FILE}" / --type sheet --prop name="2-Line Styles"') + +# -------------------------------------------------------------------------- +# Chart 1: Smooth line with thick width and shadow +# +# officecli add charts-line.xlsx "/2-Line Styles" --type chart \ +# --prop chartType=line \ +# --prop title="Smooth Curves with Shadow" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop smooth=true --prop lineWidth=2.5 \ +# --prop colors=0070C0,00B050,FFC000,FF0000 \ +# --prop gridlines=none \ +# --prop axisVisible=false \ +# --prop series.shadow=000000-4-315-2-40 +# +# Features: smooth=true (Bezier curves), lineWidth=2.5, gridlines=none, +# axisVisible=false (hide both axes for sparkline-like minimal look), +# series.shadow (color-blur-angle-dist-opacity) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Line Styles" --type chart' + f' --prop chartType=line' + f' --prop title="Smooth Curves with Shadow"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop smooth=true --prop lineWidth=2.5' + f' --prop colors=0070C0,00B050,FFC000,FF0000' + f' --prop gridlines=none' + f' --prop axisVisible=false' + f' --prop series.shadow=000000-4-315-2-40') + +# -------------------------------------------------------------------------- +# Chart 2: Dashed lines — all dash styles demonstrated +# +# officecli add charts-line.xlsx "/2-Line Styles" --type chart \ +# --prop chartType=line \ +# --prop title="Dash Pattern Gallery" \ +# --prop series1="solid:120,135,148,162,155" \ +# --prop series2="dot:95,108,115,128,142" \ +# --prop series3="dash:88,92,105,118,125" \ +# --prop series4="dashdot:110,118,130,145,138" \ +# --prop categories=Jan,Feb,Mar,Apr,May \ +# --prop colors=2E75B6,ED7D31,70AD47,FFC000 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop lineDash=dash --prop lineWidth=2 \ +# --prop legend=bottom +# +# Note: lineDash applies to ALL series. Supported values: +# solid, dot, dash, dashdot, longdash, longdashdot, longdashdotdot +# +# Features: lineDash (applied globally to all series), lineWidth +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Line Styles" --type chart' + f' --prop chartType=line' + f' --prop title="Dash Pattern Gallery"' + f' --prop series1="solid:120,135,148,162,155"' + f' --prop series2="dot:95,108,115,128,142"' + f' --prop series3="dash:88,92,105,118,125"' + f' --prop series4="dashdot:110,118,130,145,138"' + f' --prop categories=Jan,Feb,Mar,Apr,May' + f' --prop colors=2E75B6,ED7D31,70AD47,FFC000' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop lineDash=dash --prop lineWidth=2' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: Multiple marker styles — circle, square, triangle, star +# +# officecli add charts-line.xlsx "/2-Line Styles" --type chart \ +# --prop chartType=line \ +# --prop title="Marker Style Showcase" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop showMarkers=true --prop marker=square:7:4472C4 \ +# --prop lineWidth=1.5 \ +# --prop colors=4472C4,ED7D31,70AD47,FFC000 \ +# --prop series.outline=FFFFFF-0.5 +# +# Note: marker applies to ALL series. Supported styles: +# circle, diamond, square, triangle, star, x, plus, dash, dot, none +# +# Features: marker=square:7:color (style:size:fillColor), +# series.outline (white border around markers/lines) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Line Styles" --type chart' + f' --prop chartType=line' + f' --prop title="Marker Style Showcase"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop showMarkers=true --prop marker=square:7:4472C4' + f' --prop lineWidth=1.5' + f' --prop colors=4472C4,ED7D31,70AD47,FFC000' + f' --prop series.outline=FFFFFF-0.5') + +# -------------------------------------------------------------------------- +# Chart 4: Transparent lines with gradient plot area and styled title +# +# officecli add charts-line.xlsx "/2-Line Styles" --type chart \ +# --prop chartType=line \ +# --prop title="Translucent Lines on Gradient" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop lineWidth=3 --prop smooth=true \ +# --prop transparency=30 \ +# --prop plotFill=F0F4F8-D6E4F0:90 \ +# --prop chartFill=FFFFFF \ +# --prop colors=1F4E79,2E75B6,5B9BD5,9DC3E6 \ +# --prop title.font=Georgia --prop title.size=14 \ +# --prop title.color=1F4E79 --prop title.bold=true \ +# --prop roundedCorners=true +# +# Features: transparency=30 (30% transparent), plotFill gradient, +# chartFill, title.font/size/color/bold, roundedCorners +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Line Styles" --type chart' + f' --prop chartType=line' + f' --prop title="Translucent Lines on Gradient"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop lineWidth=3 --prop smooth=true' + f' --prop transparency=30' + f' --prop plotFill=F0F4F8-D6E4F0:90' + f' --prop chartFill=FFFFFF' + f' --prop colors=1F4E79,2E75B6,5B9BD5,9DC3E6' + f' --prop title.font=Georgia --prop title.size=14' + f' --prop title.color=1F4E79 --prop title.bold=true' + f' --prop roundedCorners=true') + +# ========================================================================== +# Sheet: 3-Line Variants +# ========================================================================== +print("\n--- 3-Line Variants ---") +cli(f'add "{FILE}" / --type sheet --prop name="3-Line Variants"') + +# -------------------------------------------------------------------------- +# Chart 1: Stacked line chart +# +# officecli add charts-line.xlsx "/3-Line Variants" --type chart \ +# --prop chartType=lineStacked \ +# --prop title="Cumulative Sales by Region" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop catTitle=Month --prop axisTitle=Cumulative \ +# --prop colors=4472C4,ED7D31,70AD47,FFC000 \ +# --prop majorTickMark=outside --prop tickLabelPos=low +# +# Features: lineStacked (cumulative stacking), majorTickMark=outside, +# tickLabelPos=low +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Line Variants" --type chart' + f' --prop chartType=lineStacked' + f' --prop title="Cumulative Sales by Region"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop catTitle=Month --prop axisTitle=Cumulative' + f' --prop colors=4472C4,ED7D31,70AD47,FFC000' + f' --prop majorTickMark=outside --prop tickLabelPos=low') + +# -------------------------------------------------------------------------- +# Chart 2: 100% stacked line chart with axis number format +# +# officecli add charts-line.xlsx "/3-Line Variants" --type chart \ +# --prop chartType=linePercentStacked \ +# --prop title="Regional Contribution %" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop colors=1F4E79,2E75B6,9DC3E6,BDD7EE \ +# --prop axisNumFmt=0% \ +# --prop legend=right \ +# --prop gridlines=E0E0E0:0.5:solid +# +# Features: linePercentStacked (each month sums to 100%), +# axisNumFmt (value axis number format) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Line Variants" --type chart' + f' --prop chartType=linePercentStacked' + f' --prop title="Regional Contribution %"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop colors=1F4E79,2E75B6,9DC3E6,BDD7EE' + f' --prop axisNumFmt=0%' + f' --prop legend=right' + f' --prop gridlines=E0E0E0:0.5:solid') + +# -------------------------------------------------------------------------- +# Chart 3: 3D line chart with perspective +# +# officecli add charts-line.xlsx "/3-Line Variants" --type chart \ +# --prop chartType=line3d \ +# --prop title="3D Regional Trends" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop view3d=15,20,30 \ +# --prop colors=4472C4,ED7D31,70AD47,FFC000 \ +# --prop chartFill=F8F8F8 \ +# --prop style=3 +# +# Features: line3d (3D line chart), view3d (rotX,rotY,perspective), +# style/styleId (preset chart style 1-48) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Line Variants" --type chart' + f' --prop chartType=line3d' + f' --prop title="3D Regional Trends"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop view3d=15,20,30' + f' --prop colors=4472C4,ED7D31,70AD47,FFC000' + f' --prop chartFill=F8F8F8' + f' --prop style=3') + +# -------------------------------------------------------------------------- +# Chart 4: Stacked line with area fill and data table +# +# officecli add charts-line.xlsx "/3-Line Variants" --type chart \ +# --prop chartType=lineStacked \ +# --prop title="Stacked with Data Table" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop dataTable=true \ +# --prop legend=none \ +# --prop lineWidth=1.5 \ +# --prop colors=2E75B6,ED7D31,70AD47,FFC000 \ +# --prop plotFill=FAFAFA +# +# Features: dataTable=true (show value table below chart), +# legend=none (hidden because data table shows series names) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Line Variants" --type chart' + f' --prop chartType=lineStacked' + f' --prop title="Stacked with Data Table"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop dataTable=true' + f' --prop legend=none' + f' --prop lineWidth=1.5' + f' --prop colors=2E75B6,ED7D31,70AD47,FFC000' + f' --prop plotFill=FAFAFA') + +# ========================================================================== +# Sheet: 4-Axis & Gridlines +# ========================================================================== +print("\n--- 4-Axis & Gridlines ---") +cli(f'add "{FILE}" / --type sheet --prop name="4-Axis & Gridlines"') + +# -------------------------------------------------------------------------- +# Chart 1: Custom axis scaling — min, max, majorUnit +# +# officecli add charts-line.xlsx "/4-Axis & Gridlines" --type chart \ +# --prop chartType=line \ +# --prop title="Custom Axis Scale (80–220)" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop axisMin=80 --prop axisMax=220 --prop majorUnit=20 \ +# --prop minorUnit=10 \ +# --prop showMarkers=true --prop marker=circle:4:4472C4 \ +# --prop gridlines=D0D0D0:0.5:solid \ +# --prop minorGridlines=EEEEEE:0.3:dot \ +# --prop axisLine=C00000:1.5:solid \ +# --prop catAxisLine=2E75B6:1.5:solid +# +# Features: axisMin, axisMax, majorUnit, minorUnit, +# axisLine (value axis line styling — red), catAxisLine (category axis line — blue) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Axis & Gridlines" --type chart' + f' --prop chartType=line' + f' --prop title="Custom Axis Scale (80–220)"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop axisMin=80 --prop axisMax=220 --prop majorUnit=20' + f' --prop minorUnit=10' + f' --prop showMarkers=true --prop marker=circle:4:4472C4' + f' --prop gridlines=D0D0D0:0.5:solid' + f' --prop minorGridlines=EEEEEE:0.3:dot' + f' --prop axisLine=C00000:1.5:solid' + f' --prop catAxisLine=2E75B6:1.5:solid') + +# -------------------------------------------------------------------------- +# Chart 2: Logarithmic scale with display units +# +# officecli add charts-line.xlsx "/4-Axis & Gridlines" --type chart \ +# --prop chartType=line \ +# --prop title="Exponential Growth (Log Scale)" \ +# --prop series1="Growth:1,5,25,125,625,3125" \ +# --prop categories=Year 1,Year 2,Year 3,Year 4,Year 5,Year 6 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop logBase=10 \ +# --prop colors=C00000 \ +# --prop lineWidth=2.5 \ +# --prop showMarkers=true --prop marker=triangle:7:C00000 \ +# --prop axisTitle=Value (log₁₀) \ +# --prop catTitle=Year \ +# --prop gridlines=E0E0E0:0.5:dash +# +# Features: logBase=10 (logarithmic scale), marker=triangle +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Axis & Gridlines" --type chart' + f' --prop chartType=line' + f' --prop title="Exponential Growth (Log Scale)"' + f' --prop "series1=Growth:1,5,25,125,625,3125"' + f' --prop "categories=Year 1,Year 2,Year 3,Year 4,Year 5,Year 6"' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop logBase=10' + f' --prop colors=C00000' + f' --prop lineWidth=2.5' + f' --prop showMarkers=true --prop marker=triangle:7:C00000' + f' --prop axisTitle="Value (log)"' + f' --prop catTitle=Year' + f' --prop gridlines=E0E0E0:0.5:dash') + +# -------------------------------------------------------------------------- +# Chart 3: Reversed axis and hidden axes +# +# officecli add charts-line.xlsx "/4-Axis & Gridlines" --type chart \ +# --prop chartType=line \ +# --prop title="Reversed Value Axis" \ +# --prop series1="Depth:0,50,120,200,350,500" \ +# --prop categories=Station A,Station B,Station C,Station D,Station E,Station F \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop axisReverse=true \ +# --prop colors=0070C0 \ +# --prop lineWidth=2 \ +# --prop showMarkers=true --prop marker=diamond:6:0070C0 \ +# --prop smooth=true \ +# --prop axisTitle="Depth (m)" \ +# --prop gridlines=D9D9D9:0.5:solid +# +# Features: axisReverse=true (value axis direction flipped), +# smooth + markers together +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Axis & Gridlines" --type chart' + f' --prop chartType=line' + f' --prop title="Reversed Value Axis"' + f' --prop "series1=Depth:0,50,120,200,350,500"' + f' --prop "categories=Station A,Station B,Station C,Station D,Station E,Station F"' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop axisReverse=true' + f' --prop colors=0070C0' + f' --prop lineWidth=2' + f' --prop showMarkers=true --prop marker=diamond:6:0070C0' + f' --prop smooth=true' + f' --prop axisTitle="Depth (m)"' + f' --prop gridlines=D9D9D9:0.5:solid') + +# -------------------------------------------------------------------------- +# Chart 4: Display units and tick mark styles +# +# officecli add charts-line.xlsx "/4-Axis & Gridlines" --type chart \ +# --prop chartType=line \ +# --prop title="Revenue (in Thousands)" \ +# --prop series1="Revenue:12000,18500,22000,31000,45000,52000" \ +# --prop series2="Cost:8000,11000,14000,19500,28000,33000" \ +# --prop categories=2020,2021,2022,2023,2024,2025 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop dispUnits=thousands \ +# --prop colors=2E75B6,C00000 \ +# --prop lineWidth=2 \ +# --prop majorTickMark=outside --prop minorTickMark=inside \ +# --prop showMarkers=true --prop marker=star:7:2E75B6 \ +# --prop catTitle=Year --prop axisTitle=Amount (K) +# +# Features: dispUnits=thousands (display units label), +# majorTickMark=outside, minorTickMark=inside, marker=star +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Axis & Gridlines" --type chart' + f' --prop chartType=line' + f' --prop title="Revenue (in Thousands)"' + f' --prop "series1=Revenue:12000,18500,22000,31000,45000,52000"' + f' --prop "series2=Cost:8000,11000,14000,19500,28000,33000"' + f' --prop categories=2020,2021,2022,2023,2024,2025' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop dispUnits=thousands' + f' --prop colors=2E75B6,C00000' + f' --prop lineWidth=2' + f' --prop majorTickMark=outside --prop minorTickMark=inside' + f' --prop showMarkers=true --prop marker=star:7:2E75B6' + f' --prop catTitle=Year --prop axisTitle="Amount (K)"') + +# ========================================================================== +# Sheet: 5-Labels & Legend +# ========================================================================== +print("\n--- 5-Labels & Legend ---") +cli(f'add "{FILE}" / --type sheet --prop name="5-Labels & Legend"') + +# -------------------------------------------------------------------------- +# Chart 1: Data labels at various positions with number format +# +# officecli add charts-line.xlsx "/5-Labels & Legend" --type chart \ +# --prop chartType=line \ +# --prop title="Sales with Labels" \ +# --prop series1="Revenue:120,180,210,250,280" \ +# --prop categories=Jan,Feb,Mar,Apr,May \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop colors=4472C4 \ +# --prop lineWidth=2 \ +# --prop showMarkers=true --prop marker=circle:6:4472C4 \ +# --prop dataLabels=true --prop labelPos=top \ +# --prop labelFont=9:333333:true \ +# --prop dataLabels.numFmt=#,##0 \ +# --prop dataLabels.separator=": " +# +# Features: dataLabels=true, labelPos=top, labelFont (size:color:bold), +# dataLabels.numFmt (number format), dataLabels.separator +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Labels & Legend" --type chart' + f' --prop chartType=line' + f' --prop title="Sales with Labels"' + f' --prop "series1=Revenue:120,180,210,250,280"' + f' --prop categories=Jan,Feb,Mar,Apr,May' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop colors=4472C4' + f' --prop lineWidth=2' + f' --prop showMarkers=true --prop marker=circle:6:4472C4' + f' --prop dataLabels=true --prop labelPos=top' + f' --prop labelFont=9:333333:true' + f' --prop dataLabels.numFmt=#,##0' + f' --prop "dataLabels.separator=: "') + +# -------------------------------------------------------------------------- +# Chart 2: Custom individual data labels (highlight peak) +# +# officecli add charts-line.xlsx "/5-Labels & Legend" --type chart \ +# --prop chartType=line \ +# --prop title="Peak Highlight" \ +# --prop series1="Sales:88,120,165,210,195,178" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop colors=2E75B6 \ +# --prop lineWidth=2.5 --prop smooth=true \ +# --prop showMarkers=true --prop marker=circle:5:2E75B6 \ +# --prop dataLabels=true --prop labelPos=top \ +# --prop dataLabel1.delete=true --prop dataLabel2.delete=true \ +# --prop point4.color=C00000 \ +# --prop dataLabel4.text="Peak: 210" \ +# --prop dataLabel4.y=0.15 \ +# --prop dataLabel5.delete=true --prop dataLabel6.delete=true +# +# Features: dataLabel{N}.delete (hide specific labels), +# dataLabel{N}.text (custom text on specific point), +# point{N}.color (highlight individual data point marker in red), +# dataLabel{N}.y (manual vertical position of individual label, 0-1 fraction) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Labels & Legend" --type chart' + f' --prop chartType=line' + f' --prop title="Peak Highlight"' + f' --prop "series1=Sales:88,120,165,210,195,178"' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop colors=2E75B6' + f' --prop lineWidth=2.5 --prop smooth=true' + f' --prop showMarkers=true --prop marker=circle:5:2E75B6' + f' --prop dataLabels=true --prop labelPos=top' + f' --prop dataLabel1.delete=true --prop dataLabel2.delete=true' + f' --prop point4.color=C00000' + f' --prop dataLabel4.text="Peak: 210"' + f' --prop dataLabel4.y=0.15' + f' --prop dataLabel5.delete=true --prop dataLabel6.delete=true') + +# -------------------------------------------------------------------------- +# Chart 3: Legend positioning and overlay +# +# officecli add charts-line.xlsx "/5-Labels & Legend" --type chart \ +# --prop chartType=line \ +# --prop title="Legend Overlay on Chart" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop colors=4472C4,ED7D31,70AD47,FFC000 \ +# --prop lineWidth=2 \ +# --prop legend=top \ +# --prop legend.overlay=true \ +# --prop legendfont=10:1F4E79:Calibri \ +# --prop plotFill=F5F5F5 +# +# Features: legend=top, legend.overlay=true (legend overlays chart area), +# legendfont (size:color:fontname) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Labels & Legend" --type chart' + f' --prop chartType=line' + f' --prop title="Legend Overlay on Chart"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop colors=4472C4,ED7D31,70AD47,FFC000' + f' --prop lineWidth=2' + f' --prop legend=top' + f' --prop legend.overlay=true' + f' --prop legendfont=10:1F4E79:Calibri' + f' --prop plotFill=F5F5F5') + +# -------------------------------------------------------------------------- +# Chart 4: Manual layout — plotArea, title, and legend positioning +# +# officecli add charts-line.xlsx "/5-Labels & Legend" --type chart \ +# --prop chartType=line \ +# --prop title="Manual Layout Control" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop colors=2E75B6,ED7D31,70AD47,FFC000 \ +# --prop lineWidth=1.5 \ +# --prop plotArea.x=0.12 --prop plotArea.y=0.18 \ +# --prop plotArea.w=0.82 --prop plotArea.h=0.55 \ +# --prop title.x=0.25 --prop title.y=0.02 \ +# --prop legend.x=0.15 --prop legend.y=0.82 \ +# --prop legend.w=0.7 --prop legend.h=0.12 \ +# --prop title.font=Arial --prop title.size=13 \ +# --prop title.bold=true +# +# Features: plotArea.x/y/w/h (plot area manual layout, 0-1 fraction), +# title.x/y (title position), legend.x/y/w/h (legend position/size) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Labels & Legend" --type chart' + f' --prop chartType=line' + f' --prop title="Manual Layout Control"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop colors=2E75B6,ED7D31,70AD47,FFC000' + f' --prop lineWidth=1.5' + f' --prop plotArea.x=0.12 --prop plotArea.y=0.18' + f' --prop plotArea.w=0.82 --prop plotArea.h=0.55' + f' --prop title.x=0.25 --prop title.y=0.02' + f' --prop legend.x=0.15 --prop legend.y=0.82' + f' --prop legend.w=0.7 --prop legend.h=0.12' + f' --prop title.font=Arial --prop title.size=13' + f' --prop title.bold=true') + +# ========================================================================== +# Sheet: 6-Effects & Advanced +# ========================================================================== +print("\n--- 6-Effects & Advanced ---") +cli(f'add "{FILE}" / --type sheet --prop name="6-Effects & Advanced"') + +# -------------------------------------------------------------------------- +# Chart 1: Secondary axis — two series on different scales +# +# officecli add charts-line.xlsx "/6-Effects & Advanced" --type chart \ +# --prop chartType=line \ +# --prop title="Revenue vs Growth Rate" \ +# --prop series1="Revenue:120,180,250,310,380,420" \ +# --prop series2="Growth %:50,33,39,24,23,11" \ +# --prop categories=2020,2021,2022,2023,2024,2025 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop secondaryAxis=2 \ +# --prop colors=2E75B6,C00000 \ +# --prop lineWidth=2.5 \ +# --prop showMarkers=true --prop marker=circle:6:2E75B6 \ +# --prop catTitle=Year --prop axisTitle=Revenue \ +# --prop dataLabels=true --prop labelPos=top +# +# Features: secondaryAxis=2 (series 2 on right-hand axis), +# dual-scale visualization +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/6-Effects & Advanced" --type chart' + f' --prop chartType=line' + f' --prop title="Revenue vs Growth Rate"' + f' --prop "series1=Revenue:120,180,250,310,380,420"' + f' --prop "series2=Growth %:50,33,39,24,23,11"' + f' --prop categories=2020,2021,2022,2023,2024,2025' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop secondaryAxis=2' + f' --prop colors=2E75B6,C00000' + f' --prop lineWidth=2.5' + f' --prop showMarkers=true --prop marker=circle:6:2E75B6' + f' --prop catTitle=Year --prop axisTitle=Revenue' + f' --prop dataLabels=true --prop labelPos=top') + +# -------------------------------------------------------------------------- +# Chart 2: Reference line (target/threshold) with error bars +# +# officecli add charts-line.xlsx "/6-Effects & Advanced" --type chart \ +# --prop chartType=line \ +# --prop title="vs Target (150)" \ +# --prop dataRange=Sheet1!A1:C13 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop colors=4472C4,70AD47 \ +# --prop lineWidth=2 \ +# --prop referenceLine=150:FF0000:1.5:dash \ +# --prop showMarkers=true --prop marker=circle:4:4472C4 \ +# --prop legend=bottom \ +# --prop lineDash=longdash --prop lineWidth=1.5 +# +# referenceLine format: value:color:width:dash +# - value: the threshold/target value on the Y axis +# - color: hex RGB (no #) +# - width: line thickness in pt (default 1.5) +# - dash: solid/dot/dash/dashdot/longdash +# +# Features: referenceLine (horizontal target line), lineDash=longdash +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/6-Effects & Advanced" --type chart' + f' --prop chartType=line' + f' --prop title="vs Target (150)"' + f' --prop dataRange=Sheet1!A1:C13' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop colors=4472C4,70AD47' + f' --prop lineWidth=2' + f' --prop referenceLine=150:FF0000:1.5:dash' + f' --prop showMarkers=true --prop marker=circle:4:4472C4' + f' --prop legend=bottom' + f' --prop lineDash=longdash --prop lineWidth=1.5') + +# -------------------------------------------------------------------------- +# Chart 3: Title glow/shadow effects with per-series gradients +# +# officecli add charts-line.xlsx "/6-Effects & Advanced" --type chart \ +# --prop chartType=line \ +# --prop title="Glow & Shadow Effects" \ +# --prop series1="East:120,135,148,162,155,178" \ +# --prop series2="West:110,118,130,145,138,162" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop lineWidth=3 --prop smooth=true \ +# --prop colors=4472C4,ED7D31 \ +# --prop title.glow=4472C4-8-60 \ +# --prop title.shadow=000000-3-315-2-40 \ +# --prop title.font=Calibri --prop title.size=16 \ +# --prop title.bold=true --prop title.color=1F4E79 \ +# --prop series.shadow=000000-3-315-1-30 \ +# --prop plotFill=F0F4F8 --prop chartFill=FFFFFF +# +# Features: title.glow (color-radius-opacity), title.shadow, +# series.shadow on line charts, plotFill + chartFill +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/6-Effects & Advanced" --type chart' + f' --prop chartType=line' + f' --prop title="Glow & Shadow Effects"' + f' --prop "series1=East:120,135,148,162,155,178"' + f' --prop "series2=West:110,118,130,145,138,162"' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop lineWidth=3 --prop smooth=true' + f' --prop colors=4472C4,ED7D31' + f' --prop title.glow=4472C4-8-60' + f' --prop title.shadow=000000-3-315-2-40' + f' --prop title.font=Calibri --prop title.size=16' + f' --prop title.bold=true --prop title.color=1F4E79' + f' --prop series.shadow=000000-3-315-1-30' + f' --prop plotFill=F0F4F8 --prop chartFill=FFFFFF') + +# -------------------------------------------------------------------------- +# Chart 4: Conditional coloring with chart/plot borders +# +# officecli add charts-line.xlsx "/6-Effects & Advanced" --type chart \ +# --prop chartType=line \ +# --prop title="Conditional Colors & Borders" \ +# --prop series1="Profit:80,120,-30,160,-50,200,140,-20,180,90" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop colors=2E75B6 \ +# --prop lineWidth=2 \ +# --prop showMarkers=true --prop marker=circle:6:2E75B6 \ +# --prop colorRule=0:C00000:70AD47 \ +# --prop referenceLine=0:888888:1:solid \ +# --prop chartArea.border=D0D0D0:1:solid \ +# --prop plotArea.border=E0E0E0:0.5:dot \ +# --prop dataLabels=true --prop labelPos=top \ +# --prop labelFont=8:666666:false +# +# colorRule format: threshold:belowColor:aboveColor +# - values below 0 → red (C00000), above 0 → green (70AD47) +# +# Features: colorRule (threshold-based conditional coloring), +# chartArea.border, plotArea.border, referenceLine=0 (zero line) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/6-Effects & Advanced" --type chart' + f' --prop chartType=line' + f' --prop title="Conditional Colors & Borders"' + f' --prop "series1=Profit:80,120,-30,160,-50,200,140,-20,180,90"' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop colors=2E75B6' + f' --prop lineWidth=2' + f' --prop showMarkers=true --prop marker=circle:6:2E75B6' + f' --prop colorRule=0:C00000:70AD47' + f' --prop referenceLine=0:888888:1:solid' + f' --prop chartArea.border=D0D0D0:1:solid' + f' --prop plotArea.border=E0E0E0:0.5:dot' + f' --prop dataLabels=true --prop labelPos=top' + f' --prop labelFont=8:666666:false') + +# ========================================================================== +# Sheet: 7-Line Elements +# ========================================================================== +print("\n--- 7-Line Elements ---") +cli(f'add "{FILE}" / --type sheet --prop name="7-Line Elements"') + +# -------------------------------------------------------------------------- +# Chart 1: Drop lines — vertical lines from data points to category axis +# +# officecli add charts-line.xlsx "/7-Line Elements" --type chart \ +# --prop chartType=line \ +# --prop title="Drop Lines" \ +# --prop dataRange=Sheet1!A1:C13 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop colors=4472C4,ED7D31 \ +# --prop showMarkers=true --prop marker=circle:5:4472C4 \ +# --prop dropLines=true \ +# --prop legend=bottom +# +# Features: dropLines=true (simple toggle — default thin gray lines) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/7-Line Elements" --type chart' + f' --prop chartType=line' + f' --prop title="Drop Lines"' + f' --prop dataRange=Sheet1!A1:C13' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop colors=4472C4,ED7D31' + f' --prop showMarkers=true --prop marker=circle:5:4472C4' + f' --prop dropLines=true' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 2: High-low lines — connect highest and lowest series at each point +# +# officecli add charts-line.xlsx "/7-Line Elements" --type chart \ +# --prop chartType=line \ +# --prop title="High-Low Lines" \ +# --prop series1="High:210,195,220,240,230,250" \ +# --prop series2="Low:150,135,160,170,155,180" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop colors=2E75B6,C00000 \ +# --prop showMarkers=true --prop marker=diamond:5:2E75B6 \ +# --prop hiLowLines=true \ +# --prop legend=bottom +# +# Features: hiLowLines=true (lines connecting highest and lowest values) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/7-Line Elements" --type chart' + f' --prop chartType=line' + f' --prop title="High-Low Lines"' + f' --prop "series1=High:210,195,220,240,230,250"' + f' --prop "series2=Low:150,135,160,170,155,180"' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop colors=2E75B6,C00000' + f' --prop showMarkers=true --prop marker=diamond:5:2E75B6' + f' --prop hiLowLines=true' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: Up-down bars with custom colors — show gain/loss between series +# +# officecli add charts-line.xlsx "/7-Line Elements" --type chart \ +# --prop chartType=line \ +# --prop title="Up-Down Bars (Gain/Loss)" \ +# --prop series1="Open:120,135,148,130,155,162" \ +# --prop series2="Close:135,128,162,145,170,155" \ +# --prop categories=Mon,Tue,Wed,Thu,Fri,Sat \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop colors=4472C4,ED7D31 \ +# --prop showMarkers=true --prop marker=circle:4:4472C4 \ +# --prop updownbars=100:70AD47:C00000 \ +# --prop legend=bottom +# +# updownbars format: gapWidth:upColor:downColor +# - gapWidth: gap between bars (0-500, default 150) +# - upColor: fill color for increase (Close > Open) +# - downColor: fill color for decrease (Close < Open) +# +# Features: updownbars with custom colors (gain=green, loss=red) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/7-Line Elements" --type chart' + f' --prop chartType=line' + f' --prop title="Up-Down Bars (Gain/Loss)"' + f' --prop "series1=Open:120,135,148,130,155,162"' + f' --prop "series2=Close:135,128,162,145,170,155"' + f' --prop categories=Mon,Tue,Wed,Thu,Fri,Sat' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop colors=4472C4,ED7D31' + f' --prop showMarkers=true --prop marker=circle:4:4472C4' + f' --prop updownbars=100:70AD47:C00000' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 4: Auto markers + 3D line with gapDepth +# +# officecli add charts-line.xlsx "/7-Line Elements" --type chart \ +# --prop chartType=line3d \ +# --prop title="3D Line with Gap Depth" \ +# --prop dataRange=Sheet1!A1:E13 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop view3d=15,25,30 \ +# --prop gapDepth=300 \ +# --prop colors=4472C4,ED7D31,70AD47,FFC000 \ +# --prop chartFill=F5F5F5 +# +# Features: gapDepth=300 (3D depth spacing, 0-500), +# line3d with custom perspective +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/7-Line Elements" --type chart' + f' --prop chartType=line3d' + f' --prop title="3D Line with Gap Depth"' + f' --prop dataRange=Sheet1!A1:E13' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop view3d=15,25,30' + f' --prop gapDepth=300' + f' --prop colors=4472C4,ED7D31,70AD47,FFC000' + f' --prop chartFill=F5F5F5') + +print(f"\nDone! Generated: {FILE}") +print(" 8 sheets (Sheet1 data + 7 chart sheets, 28 charts total)") diff --git a/examples/excel/charts-line.xlsx b/examples/excel/charts-line.xlsx new file mode 100644 index 000000000..816e5bbe6 Binary files /dev/null and b/examples/excel/charts-line.xlsx differ diff --git a/examples/excel/charts-pie.md b/examples/excel/charts-pie.md new file mode 100644 index 000000000..59a3c40f1 --- /dev/null +++ b/examples/excel/charts-pie.md @@ -0,0 +1,95 @@ +# Pie & Doughnut Charts Showcase + +This demo consists of three files that work together: + +- **charts-pie.py** — Python script that calls `officecli` commands to generate the workbook. Each chart command is shown as a copyable shell command in the comments. +- **charts-pie.xlsx** — The generated workbook with 3 sheets (1 default + 2 chart sheets, 8 charts total). +- **charts-pie.md** — This file. Maps each sheet to the features it demonstrates. + +## Regenerate + +```bash +cd examples/excel +python3 charts-pie.py +# → charts-pie.xlsx +``` + +## Chart Sheets + +### Sheet: 1-Pie Charts + +Four pie chart variants covering flat, 3D, exploded, and gradient fills. + +```bash +# Basic pie with colors and data labels +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=pie \ + --prop series1="Share:40,25,20,15" \ + --prop categories=Product A,Product B,Product C,Product D \ + --prop colors=4472C4,ED7D31,70AD47,FFC000 \ + --prop dataLabels=true --prop labelPos=outsideEnd + +# Exploded pie with per-point colors and percentage labels +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=pie \ + --prop explosion=15 \ + --prop point1.color=1F4E79 --prop point2.color=2E75B6 \ + --prop dataLabels.numFmt=0.0"%" --prop labelPos=bestFit + +# 3D pie with tilt angle and styled title +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=pie3d \ + --prop view3d=30,0,0 \ + --prop title.font=Georgia --prop title.size=16 \ + --prop labelFont=12:FFFFFF:true --prop labelPos=center + +# Pie with per-slice gradients and leader lines +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=pie \ + --prop 'gradients=4472C4-BDD7EE:90;ED7D31-FBE5D6:90;...' \ + --prop dataLabels.showLeaderLines=true \ + --prop legend=right --prop legendfont=10:333333:Helvetica +``` + +**Features:** `pie`, `pie3d`, `explosion`, `point{N}.color`, `view3d`, `labelPos=bestFit`, `dataLabels.numFmt`, `labelFont`, `title.font/size/color/bold`, `gradients` (per-slice), `dataLabels.showLeaderLines`, `legendfont`, `chartFill`, `roundedCorners` + +### Sheet: 2-Doughnut Charts + +Four doughnut chart variants including multi-ring and styled effects. + +```bash +# Basic doughnut with center labels +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=doughnut \ + --prop dataLabels=true --prop labelPos=center \ + --prop labelFont=14:FFFFFF:true + +# Multi-ring doughnut (multiple series = concentric rings) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=doughnut \ + --prop series1="2024:40,35,25" \ + --prop series2="2025:45,30,25" \ + --prop series.outline=FFFFFF-1 + +# Styled doughnut with shadow effects +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=doughnut \ + --prop series.shadow=000000-4-315-2-30 \ + --prop title.shadow=000000-3-315-2-30 \ + --prop plotFill=F5F5F5 + +# Doughnut with explosion and per-slice gradients +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=doughnut \ + --prop explosion=8 \ + --prop 'gradients=1F4E79-5B9BD5:90;C55A11-F4B183:90;...' +``` + +**Features:** `doughnut`, multi-ring (multiple `series`), `labelPos=center`, `labelFont`, `series.outline`, `series.shadow`, `title.shadow`, `plotFill`, `explosion`, `gradients` + +## Inspect the Generated File + +```bash +officecli query charts-pie.xlsx chart +officecli get charts-pie.xlsx "/1-Pie Charts/chart[1]" +``` diff --git a/examples/excel/charts-pie.py b/examples/excel/charts-pie.py new file mode 100644 index 000000000..d1453a526 --- /dev/null +++ b/examples/excel/charts-pie.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +""" +Pie & Doughnut Charts Showcase — pie, pie3d, and doughnut with all variations. + +Generates: charts-pie.xlsx + +Usage: + python3 charts-pie.py +""" + +import subprocess, sys, os, json, atexit + +FILE = "charts-pie.xlsx" + +def cli(cmd): + """Run: officecli """ + r = subprocess.run(f"officecli {cmd}", shell=True, capture_output=True, text=True) + out = (r.stdout or "").strip() + if out: + for line in out.split("\n"): + if line.strip(): + print(f" {line.strip()}") + if r.returncode != 0: + err = (r.stderr or "").strip() + if err and "UNSUPPORTED" not in err and "process cannot access" not in err: + print(f" ERROR: {err}") + +if os.path.exists(FILE): + os.remove(FILE) + +cli(f'create "{FILE}"') +cli(f'open "{FILE}"') +atexit.register(lambda: cli(f'close "{FILE}"')) + +# ========================================================================== +# Sheet: 1-Pie Charts +# ========================================================================== +print("\n--- 1-Pie Charts ---") +cli(f'add "{FILE}" / --type sheet --prop name="1-Pie Charts"') + +# -------------------------------------------------------------------------- +# Chart 1: Basic pie chart with inline data and custom colors +# +# officecli add charts-pie.xlsx "/1-Pie Charts" --type chart \ +# --prop chartType=pie \ +# --prop title="Market Share" \ +# --prop series1="Share:40,25,20,15" \ +# --prop categories=Product A,Product B,Product C,Product D \ +# --prop colors=4472C4,ED7D31,70AD47,FFC000 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop dataLabels=true --prop labelPos=outsideEnd +# +# Features: chartType=pie, inline series, categories, colors, dataLabels +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Pie Charts" --type chart' + f' --prop chartType=pie' + f' --prop title="Market Share"' + f' --prop series1=Share:40,25,20,15' + f' --prop categories=Product A,Product B,Product C,Product D' + f' --prop colors=4472C4,ED7D31,70AD47,FFC000' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop dataLabels=true --prop labelPos=outsideEnd') + +# -------------------------------------------------------------------------- +# Chart 2: Pie with exploded slice and per-point colors +# +# officecli add charts-pie.xlsx "/1-Pie Charts" --type chart \ +# --prop chartType=pie \ +# --prop title="Revenue by Region" \ +# --prop series1="Revenue:35,28,22,15" \ +# --prop categories=North,South,East,West \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop explosion=15 \ +# --prop point1.color=1F4E79 --prop point2.color=2E75B6 \ +# --prop point3.color=9DC3E6 --prop point4.color=BDD7EE \ +# --prop dataLabels=percent --prop labelPos=bestFit +# +# Features: explosion (slice separation %), point{N}.color, labelPos=bestFit, +# dataLabels=percent +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Pie Charts" --type chart' + f' --prop chartType=pie' + f' --prop title="Revenue by Region"' + f' --prop series1=Revenue:35,28,22,15' + f' --prop categories=North,South,East,West' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop explosion=15' + f' --prop point1.color=1F4E79 --prop point2.color=2E75B6' + f' --prop point3.color=9DC3E6 --prop point4.color=BDD7EE' + f' --prop dataLabels=true --prop labelPos=bestFit' + f' --prop dataLabels=percent --prop labelPos=bestFit') + +# -------------------------------------------------------------------------- +# Chart 3: 3D pie with perspective and title styling +# +# officecli add charts-pie.xlsx "/1-Pie Charts" --type chart \ +# --prop chartType=pie3d \ +# --prop title="3D Category Split" \ +# --prop series1="Sales:45,30,25" \ +# --prop categories=Electronics,Clothing,Food \ +# --prop colors=2E75B6,70AD47,FFC000 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop view3d=30,0,0 \ +# --prop title.font=Georgia --prop title.size=16 \ +# --prop title.color=1F4E79 --prop title.bold=true \ +# --prop dataLabels=true --prop labelPos=center \ +# --prop labelFont=12:FFFFFF:true +# +# Features: pie3d, view3d on pie (tilt angle), title.font/size/color/bold, +# labelFont (size:color:bold) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Pie Charts" --type chart' + f' --prop chartType=pie3d' + f' --prop title="3D Category Split"' + f' --prop series1=Sales:45,30,25' + f' --prop categories=Electronics,Clothing,Food' + f' --prop colors=2E75B6,70AD47,FFC000' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop view3d=30,0,0' + f' --prop title.font=Georgia --prop title.size=16' + f' --prop title.color=1F4E79 --prop title.bold=true' + f' --prop dataLabels=true --prop labelPos=center' + f' --prop labelFont=12:FFFFFF:true') + +# -------------------------------------------------------------------------- +# Chart 4: Pie with gradient fills, leader lines, and legend positioning +# +# officecli add charts-pie.xlsx "/1-Pie Charts" --type chart \ +# --prop chartType=pie \ +# --prop title="Q4 Distribution" \ +# --prop series1="Q4:198,158,142,180" \ +# --prop categories=East,South,North,West \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop 'gradients=4472C4-BDD7EE:90;ED7D31-FBE5D6:90;70AD47-C5E0B4:90;FFC000-FFF2CC:90' \ +# --prop legend=right --prop legendfont=10:333333:Helvetica \ +# --prop dataLabels=true \ +# --prop dataLabels.showLeaderLines=true \ +# --prop chartFill=FAFAFA --prop roundedCorners=true +# +# Features: gradients (per-slice), legend=right, legendfont, +# dataLabels.showLeaderLines, chartFill, roundedCorners +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Pie Charts" --type chart' + f' --prop chartType=pie' + f' --prop title="Q4 Distribution"' + f' --prop series1=Q4:198,158,142,180' + f' --prop categories=East,South,North,West' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop "gradients=4472C4-BDD7EE:90;ED7D31-FBE5D6:90;70AD47-C5E0B4:90;FFC000-FFF2CC:90"' + f' --prop legend=right --prop legendfont=10:333333:Helvetica' + f' --prop dataLabels=true' + f' --prop dataLabels.showLeaderLines=true' + f' --prop chartFill=FAFAFA --prop roundedCorners=true') + +# ========================================================================== +# Sheet: 2-Doughnut Charts +# ========================================================================== +print("\n--- 2-Doughnut Charts ---") +cli(f'add "{FILE}" / --type sheet --prop name="2-Doughnut Charts"') + +# -------------------------------------------------------------------------- +# Chart 1: Basic doughnut chart +# +# officecli add charts-pie.xlsx "/2-Doughnut Charts" --type chart \ +# --prop chartType=doughnut \ +# --prop title="Channel Mix" \ +# --prop series1="Channel:55,45" \ +# --prop categories=Online,Retail \ +# --prop colors=4472C4,ED7D31 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop dataLabels=true --prop labelPos=center \ +# --prop labelFont=14:FFFFFF:true +# +# Features: chartType=doughnut, center labels +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Doughnut Charts" --type chart' + f' --prop chartType=doughnut' + f' --prop title="Channel Mix"' + f' --prop series1=Channel:55,45' + f' --prop categories=Online,Retail' + f' --prop colors=4472C4,ED7D31' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop dataLabels=true --prop labelPos=center' + f' --prop labelFont=14:FFFFFF:true') + +# -------------------------------------------------------------------------- +# Chart 2: Multi-ring doughnut (multiple series) +# +# officecli add charts-pie.xlsx "/2-Doughnut Charts" --type chart \ +# --prop chartType=doughnut \ +# --prop title="Year-over-Year Comparison" \ +# --prop series1="2024:40,35,25" \ +# --prop series2="2025:45,30,25" \ +# --prop categories=Electronics,Clothing,Food \ +# --prop colors=4472C4,70AD47,FFC000 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop series.outline=FFFFFF-1 \ +# --prop legend=bottom +# +# Features: multi-ring doughnut (multiple series = concentric rings), +# series.outline (white separator between slices) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Doughnut Charts" --type chart' + f' --prop chartType=doughnut' + f' --prop title="Year-over-Year Comparison"' + f' --prop series1=2024:40,35,25' + f' --prop series2=2025:45,30,25' + f' --prop categories=Electronics,Clothing,Food' + f' --prop colors=4472C4,70AD47,FFC000' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop series.outline=FFFFFF-1' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: Styled doughnut with shadow and custom data labels +# +# officecli add charts-pie.xlsx "/2-Doughnut Charts" --type chart \ +# --prop chartType=doughnut \ +# --prop title="Priority Breakdown" \ +# --prop series1="Priority:50,30,20" \ +# --prop categories=High,Medium,Low \ +# --prop colors=C00000,FFC000,70AD47 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop series.shadow=000000-4-315-2-30 \ +# --prop dataLabels=true --prop labelPos=outsideEnd \ +# --prop dataLabels.numFmt=0"%" \ +# --prop title.shadow=000000-3-315-2-30 \ +# --prop plotFill=F5F5F5 +# +# Features: series.shadow on doughnut, title.shadow, plotFill +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Doughnut Charts" --type chart' + f' --prop chartType=doughnut' + f' --prop title="Priority Breakdown"' + f' --prop series1=Priority:50,30,20' + f' --prop categories=High,Medium,Low' + f' --prop colors=C00000,FFC000,70AD47' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop series.shadow=000000-4-315-2-30' + f' --prop dataLabels=true --prop labelPos=outsideEnd' + f' --prop dataLabels.numFmt=0"%"' + f' --prop title.shadow=000000-3-315-2-30' + f' --prop plotFill=F5F5F5') + +# -------------------------------------------------------------------------- +# Chart 4: Doughnut with per-slice gradient and explosion +# +# officecli add charts-pie.xlsx "/2-Doughnut Charts" --type chart \ +# --prop chartType=doughnut \ +# --prop title="Product Revenue" \ +# --prop series1="Revenue:35,25,20,12,8" \ +# --prop categories=Laptop,Phone,Tablet,Jacket,Coffee \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop explosion=8 \ +# --prop 'gradients=1F4E79-5B9BD5:90;C55A11-F4B183:90;548235-A9D18E:90;7F6000-FFD966:90;843C0B-DDA15E:90' \ +# --prop legend=right \ +# --prop dataLabels=true --prop labelPos=bestFit +# +# Features: explosion on doughnut, 5-slice gradients +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Doughnut Charts" --type chart' + f' --prop chartType=doughnut' + f' --prop title="Product Revenue"' + f' --prop series1=Revenue:35,25,20,12,8' + f' --prop categories=Laptop,Phone,Tablet,Jacket,Coffee' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop explosion=8' + f' --prop "gradients=1F4E79-5B9BD5:90;C55A11-F4B183:90;548235-A9D18E:90;7F6000-FFD966:90;843C0B-DDA15E:90"' + f' --prop legend=right' + f' --prop dataLabels=true --prop labelPos=bestFit') + +# Remove blank default Sheet1 (all data is inline) +cli(f'remove "{FILE}" /Sheet1') + +print(f"\nDone! Generated: {FILE}") +print(" 3 sheets (2 chart sheets, 8 charts total)") diff --git a/examples/excel/charts-pie.xlsx b/examples/excel/charts-pie.xlsx new file mode 100644 index 000000000..91fd7af8d Binary files /dev/null and b/examples/excel/charts-pie.xlsx differ diff --git a/examples/excel/charts-radar.md b/examples/excel/charts-radar.md new file mode 100644 index 000000000..f4ae5fdfa --- /dev/null +++ b/examples/excel/charts-radar.md @@ -0,0 +1,174 @@ +# Radar Charts Showcase + +This demo consists of three files that work together: + +- **charts-radar.py** — Python script that calls `officecli` commands to generate the workbook. Each chart command is shown as a copyable shell command in the comments. +- **charts-radar.xlsx** — The generated workbook with 5 sheets (1 default + 4 chart sheets, 16 charts total). +- **charts-radar.md** — This file. Maps each sheet to the features it demonstrates. + +## Regenerate + +```bash +cd examples/excel +python3 charts-radar.py +# → charts-radar.xlsx +``` + +## Chart Sheets + +### Sheet: 1-Radar Fundamentals + +Four radar chart variants covering standard, marker, and filled styles. + +```bash +# Basic radar (standard) with 3 series +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=radar \ + --prop radarStyle=standard \ + --prop series1="Alice:85,70,90,60,75" \ + --prop series2="Bob:65,90,70,80,85" \ + --prop categories=Speed,Strength,Stamina,Agility,Accuracy \ + --prop colors=4472C4,ED7D31,70AD47 + +# Radar with markers and data labels +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=radar \ + --prop radarStyle=marker \ + --prop marker=circle:6:2E75B6 \ + --prop dataLabels=true + +# Filled radar with transparency +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=radar \ + --prop radarStyle=filled \ + --prop transparency=40 + +# Filled radar with white outline separators +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=radar \ + --prop radarStyle=filled \ + --prop series.outline=FFFFFF-0.5 \ + --prop transparency=35 +``` + +**Features:** `radar`, `radarStyle=standard/marker/filled`, `marker=circle:6:color`, `transparency`, `series.outline`, `dataLabels`, `legend=bottom` + +### Sheet: 2-Radar Styling + +Four charts demonstrating title styling, shadows, axis fonts, gridlines, and chart area decoration. + +```bash +# Title styling with font, size, color, bold, shadow +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=radar \ + --prop title.font=Georgia --prop title.size=18 \ + --prop title.color=1F4E79 --prop title.bold=true \ + --prop title.shadow=000000-3-315-2-30 + +# Series shadow on filled radar +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=radar \ + --prop radarStyle=filled \ + --prop series.shadow=000000-4-315-2-30 \ + --prop transparency=30 + +# Axis font and gridlines +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=radar \ + --prop axisfont=10:333333:Calibri \ + --prop gridlines=D9D9D9:0.5 + +# Chart area styling with fills, corners, borders +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=radar \ + --prop plotFill=F5F5F5 --prop chartFill=FAFAFA \ + --prop roundedCorners=true \ + --prop chartArea.border=BFBFBF:0.5 \ + --prop plotArea.border=D9D9D9:0.25 +``` + +**Features:** `title.font/size/color/bold/shadow`, `series.shadow`, `axisfont`, `gridlines`, `plotFill`, `chartFill`, `roundedCorners`, `chartArea.border`, `plotArea.border` + +### Sheet: 3-Labels & Legend + +Four charts covering data labels, legend positioning, manual layout, and multi-series comparison. + +```bash +# Data labels with font styling +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=radar \ + --prop radarStyle=marker \ + --prop dataLabels=true --prop labelPos=outsideEnd \ + --prop labelFont=9:333333:true \ + --prop marker=circle:6:2E75B6 + +# Legend positioning with overlay +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=radar \ + --prop legend=right \ + --prop legendfont=10:1F4E79:Calibri \ + --prop legend.overlay=true + +# Manual plot area layout (fractional) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=radar \ + --prop plotArea.x=0.1 --prop plotArea.y=0.15 \ + --prop plotArea.w=0.8 --prop plotArea.h=0.75 + +# Five series comparison +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=radar \ + --prop series1="Dev:90,70,80,65,75" \ + --prop series2="QA:60,85,70,80,90" \ + --prop series3="Design:75,80,85,70,60" \ + --prop series4="PM:80,65,75,90,70" \ + --prop series5="DevOps:70,75,60,85,80" \ + --prop colors=4472C4,ED7D31,70AD47,FFC000,7030A0 +``` + +**Features:** `dataLabels`, `labelPos=outsideEnd`, `labelFont`, `legend=right`, `legendfont`, `legend.overlay`, `plotArea.x/y/w/h`, 5+ series on single radar + +### Sheet: 4-Advanced + +Four charts with advanced effects: title glow, many-spoke layouts, themed styling, and overlap visualization. + +```bash +# Title with glow and shadow effects +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=radar \ + --prop title.glow=4472C4-8 \ + --prop title.shadow=000000-3-315-2-30 \ + --prop marker=diamond:7:2E75B6 + +# 8-spoke radar with benchmark overlay +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=radar \ + --prop radarStyle=filled \ + --prop categories=Technical,Communication,Leadership,Creativity,Analytical,Teamwork,Adaptability,Initiative \ + --prop gridlines=D9D9D9:0.25 --prop transparency=35 + +# Single-series with themed purple styling +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=radar \ + --prop radarStyle=marker \ + --prop colors=7030A0 --prop marker=square:7:7030A0 \ + --prop title.color=7030A0 --prop plotFill=F8F0FF \ + --prop chartArea.border=7030A0:0.5 --prop roundedCorners=true + +# Before/After comparison with low transparency overlap +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=radar \ + --prop radarStyle=filled \ + --prop transparency=20 \ + --prop series.outline=FFFFFF-0.75 \ + --prop chartFill=FAFAFA --prop plotFill=F5F5F5 +``` + +**Features:** `title.glow`, `title.shadow`, `marker=diamond/square`, 8-category spokes, themed color scheme, low-transparency overlap visualization, before/after comparison pattern + +## Inspect the Generated File + +```bash +officecli query charts-radar.xlsx chart +officecli get charts-radar.xlsx "/1-Radar Fundamentals/chart[1]" +``` diff --git a/examples/excel/charts-radar.py b/examples/excel/charts-radar.py new file mode 100644 index 000000000..d90a03e69 --- /dev/null +++ b/examples/excel/charts-radar.py @@ -0,0 +1,575 @@ +#!/usr/bin/env python3 +""" +Radar Charts Showcase — radar with standard, filled, and marker styles. + +Generates: charts-radar.xlsx + +Usage: + python3 charts-radar.py +""" + +import subprocess, sys, os, json, atexit + +FILE = "charts-radar.xlsx" + +def cli(cmd): + """Run: officecli """ + r = subprocess.run(f"officecli {cmd}", shell=True, capture_output=True, text=True) + out = (r.stdout or "").strip() + if out: + for line in out.split("\n"): + if line.strip(): + print(f" {line.strip()}") + if r.returncode != 0: + err = (r.stderr or "").strip() + if err and "UNSUPPORTED" not in err and "process cannot access" not in err: + print(f" ERROR: {err}") + +if os.path.exists(FILE): + os.remove(FILE) + +cli(f'create "{FILE}"') +cli(f'open "{FILE}"') +atexit.register(lambda: cli(f'close "{FILE}"')) + +# ========================================================================== +# Sheet: 1-Radar Fundamentals +# ========================================================================== +print("\n--- 1-Radar Fundamentals ---") +cli(f'add "{FILE}" / --type sheet --prop name="1-Radar Fundamentals"') + +# -------------------------------------------------------------------------- +# Chart 1: Basic radar (standard style) with 3 series +# +# officecli add charts-radar.xlsx "/1-Radar Fundamentals" --type chart \ +# --prop chartType=radar \ +# --prop radarStyle=standard \ +# --prop title="Athlete Comparison" \ +# --prop series1="Alice:85,70,90,60,75" \ +# --prop series2="Bob:65,90,70,80,85" \ +# --prop series3="Carol:75,80,80,70,65" \ +# --prop categories=Speed,Strength,Stamina,Agility,Accuracy \ +# --prop colors=4472C4,ED7D31,70AD47 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop legend=bottom +# +# Features: chartType=radar, radarStyle=standard, 3 series, categories as spokes +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Radar Fundamentals" --type chart' + f' --prop chartType=radar' + f' --prop radarStyle=standard' + f' --prop title="Athlete Comparison"' + f' --prop series1=Alice:85,70,90,60,75' + f' --prop series2=Bob:65,90,70,80,85' + f' --prop series3=Carol:75,80,80,70,65' + f' --prop categories=Speed,Strength,Stamina,Agility,Accuracy' + f' --prop colors=4472C4,ED7D31,70AD47' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 2: Radar with markers (marker style) +# +# officecli add charts-radar.xlsx "/1-Radar Fundamentals" --type chart \ +# --prop chartType=radar \ +# --prop radarStyle=marker \ +# --prop title="Product Ratings" \ +# --prop series1="Product A:9,7,8,6,8" \ +# --prop series2="Product B:6,9,7,8,5" \ +# --prop categories=Quality,Price,Design,Support,Delivery \ +# --prop colors=2E75B6,C00000 \ +# --prop marker=circle:6:2E75B6 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop legend=bottom \ +# --prop dataLabels=true +# +# Features: radarStyle=marker, marker=circle:6:color, dataLabels +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Radar Fundamentals" --type chart' + f' --prop chartType=radar' + f' --prop radarStyle=marker' + f' --prop title="Product Ratings"' + f' --prop series1="Product A:9,7,8,6,8"' + f' --prop series2="Product B:6,9,7,8,5"' + f' --prop categories=Quality,Price,Design,Support,Delivery' + f' --prop colors=2E75B6,C00000' + f' --prop marker=circle:6:2E75B6' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop legend=bottom' + f' --prop dataLabels=true') + +# -------------------------------------------------------------------------- +# Chart 3: Filled radar with transparency +# +# officecli add charts-radar.xlsx "/1-Radar Fundamentals" --type chart \ +# --prop chartType=radar \ +# --prop radarStyle=filled \ +# --prop title="Skills Assessment" \ +# --prop series1="Junior:50,40,60,70,55" \ +# --prop series2="Senior:85,80,75,90,80" \ +# --prop categories=Coding,Design,Testing,Communication,Leadership \ +# --prop colors=4472C4,70AD47 \ +# --prop transparency=40 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop legend=bottom +# +# Features: radarStyle=filled, transparency=40 (semi-transparent fill) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Radar Fundamentals" --type chart' + f' --prop chartType=radar' + f' --prop radarStyle=filled' + f' --prop title="Skills Assessment"' + f' --prop series1=Junior:50,40,60,70,55' + f' --prop series2=Senior:85,80,75,90,80' + f' --prop categories=Coding,Design,Testing,Communication,Leadership' + f' --prop colors=4472C4,70AD47' + f' --prop transparency=40' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 4: Filled radar with per-series colors and white outline +# +# officecli add charts-radar.xlsx "/1-Radar Fundamentals" --type chart \ +# --prop chartType=radar \ +# --prop radarStyle=filled \ +# --prop title="Department Scores" \ +# --prop series1="Engineering:90,75,60,85,70" \ +# --prop series2="Marketing:60,85,80,70,90" \ +# --prop series3="Sales:70,80,75,65,85" \ +# --prop categories=Innovation,Teamwork,Efficiency,Quality,Growth \ +# --prop colors=4472C4,ED7D31,70AD47 \ +# --prop series.outline=FFFFFF-0.5 \ +# --prop transparency=35 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop legend=bottom +# +# Features: filled radar, series.outline (white border between areas), +# 3 overlapping series with transparency +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Radar Fundamentals" --type chart' + f' --prop chartType=radar' + f' --prop radarStyle=filled' + f' --prop title="Department Scores"' + f' --prop series1=Engineering:90,75,60,85,70' + f' --prop series2=Marketing:60,85,80,70,90' + f' --prop series3=Sales:70,80,75,65,85' + f' --prop categories=Innovation,Teamwork,Efficiency,Quality,Growth' + f' --prop colors=4472C4,ED7D31,70AD47' + f' --prop series.outline=FFFFFF-0.5' + f' --prop transparency=35' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop legend=bottom') + +# ========================================================================== +# Sheet: 2-Radar Styling +# ========================================================================== +print("\n--- 2-Radar Styling ---") +cli(f'add "{FILE}" / --type sheet --prop name="2-Radar Styling"') + +# -------------------------------------------------------------------------- +# Chart 1: Title styling (font, size, color, bold, shadow) +# +# officecli add charts-radar.xlsx "/2-Radar Styling" --type chart \ +# --prop chartType=radar \ +# --prop radarStyle=marker \ +# --prop title="Styled Title Demo" \ +# --prop series1="Team A:80,65,90,70,85" \ +# --prop categories=Attack,Defense,Speed,Skill,Stamina \ +# --prop colors=2E75B6 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop title.font=Georgia --prop title.size=18 \ +# --prop title.color=1F4E79 --prop title.bold=true \ +# --prop title.shadow=000000-3-315-2-30 +# +# Features: title.font, title.size, title.color, title.bold, title.shadow +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Radar Styling" --type chart' + f' --prop chartType=radar' + f' --prop radarStyle=marker' + f' --prop title="Styled Title Demo"' + f' --prop series1="Team A:80,65,90,70,85"' + f' --prop categories=Attack,Defense,Speed,Skill,Stamina' + f' --prop colors=2E75B6' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop title.font=Georgia --prop title.size=18' + f' --prop title.color=1F4E79 --prop title.bold=true' + f' --prop title.shadow=000000-3-315-2-30') + +# -------------------------------------------------------------------------- +# Chart 2: Series shadow effects +# +# officecli add charts-radar.xlsx "/2-Radar Styling" --type chart \ +# --prop chartType=radar \ +# --prop radarStyle=filled \ +# --prop title="Shadow Effects" \ +# --prop series1="Region A:75,80,65,90,70" \ +# --prop series2="Region B:60,70,85,75,80" \ +# --prop categories=Revenue,Profit,Growth,Retention,Satisfaction \ +# --prop colors=4472C4,ED7D31 \ +# --prop series.shadow=000000-4-315-2-30 \ +# --prop transparency=30 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop legend=bottom +# +# Features: series.shadow on filled radar, transparency with shadow +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Radar Styling" --type chart' + f' --prop chartType=radar' + f' --prop radarStyle=filled' + f' --prop title="Shadow Effects"' + f' --prop series1="Region A:75,80,65,90,70"' + f' --prop series2="Region B:60,70,85,75,80"' + f' --prop categories=Revenue,Profit,Growth,Retention,Satisfaction' + f' --prop colors=4472C4,ED7D31' + f' --prop series.shadow=000000-4-315-2-30' + f' --prop transparency=30' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: Axis font and gridlines styling +# +# officecli add charts-radar.xlsx "/2-Radar Styling" --type chart \ +# --prop chartType=radar \ +# --prop radarStyle=marker \ +# --prop title="Axis & Gridlines" \ +# --prop series1="Actual:70,85,60,75,80" \ +# --prop series2="Target:80,80,80,80,80" \ +# --prop categories=KPI 1,KPI 2,KPI 3,KPI 4,KPI 5 \ +# --prop colors=4472C4,C00000 \ +# --prop axisfont=10:333333:Calibri \ +# --prop gridlines=D9D9D9:0.5 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop legend=bottom +# +# Features: axisfont (size:color:fontFamily), gridlines (color-width) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Radar Styling" --type chart' + f' --prop chartType=radar' + f' --prop radarStyle=marker' + f' --prop title="Axis & Gridlines"' + f' --prop series1=Actual:70,85,60,75,80' + f' --prop series2=Target:80,80,80,80,80' + f' --prop categories=KPI 1,KPI 2,KPI 3,KPI 4,KPI 5' + f' --prop colors=4472C4,C00000' + f' --prop axisfont=10:333333:Calibri' + f' --prop gridlines=D9D9D9:0.5' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 4: Plot fill, chart fill, rounded corners, borders +# +# officecli add charts-radar.xlsx "/2-Radar Styling" --type chart \ +# --prop chartType=radar \ +# --prop radarStyle=filled \ +# --prop title="Chart Area Styling" \ +# --prop series1="Score:85,70,90,60,75" \ +# --prop categories=Speed,Power,Technique,Endurance,Flexibility \ +# --prop colors=4472C4 \ +# --prop transparency=25 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop plotFill=F5F5F5 --prop chartFill=FAFAFA \ +# --prop roundedCorners=true \ +# --prop chartArea.border=BFBFBF:0.5 \ +# --prop plotArea.border=D9D9D9:0.25 +# +# Features: plotFill, chartFill, roundedCorners, chartArea.border, +# plotArea.border +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Radar Styling" --type chart' + f' --prop chartType=radar' + f' --prop radarStyle=filled' + f' --prop title="Chart Area Styling"' + f' --prop series1=Score:85,70,90,60,75' + f' --prop categories=Speed,Power,Technique,Endurance,Flexibility' + f' --prop colors=4472C4' + f' --prop transparency=25' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop plotFill=F5F5F5 --prop chartFill=FAFAFA' + f' --prop roundedCorners=true' + f' --prop chartArea.border=BFBFBF:0.5' + f' --prop plotArea.border=D9D9D9:0.25') + +# ========================================================================== +# Sheet: 3-Labels & Legend +# ========================================================================== +print("\n--- 3-Labels & Legend ---") +cli(f'add "{FILE}" / --type sheet --prop name="3-Labels & Legend"') + +# -------------------------------------------------------------------------- +# Chart 1: Data labels with font styling and position +# +# officecli add charts-radar.xlsx "/3-Labels & Legend" --type chart \ +# --prop chartType=radar \ +# --prop radarStyle=marker \ +# --prop title="Data Labels" \ +# --prop series1="Performance:88,72,95,67,81" \ +# --prop categories=Speed,Strength,Stamina,Agility,Accuracy \ +# --prop colors=2E75B6 \ +# --prop marker=circle:6:2E75B6 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop dataLabels=true --prop labelPos=outsideEnd \ +# --prop labelFont=9:333333:true +# +# Features: dataLabels=true, labelPos=outsideEnd, labelFont (size:color:bold) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Labels & Legend" --type chart' + f' --prop chartType=radar' + f' --prop radarStyle=marker' + f' --prop title="Data Labels"' + f' --prop series1=Performance:88,72,95,67,81' + f' --prop categories=Speed,Strength,Stamina,Agility,Accuracy' + f' --prop colors=2E75B6' + f' --prop marker=circle:6:2E75B6' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop dataLabels=true --prop labelPos=outsideEnd' + f' --prop labelFont=9:333333:true') + +# -------------------------------------------------------------------------- +# Chart 2: Legend positioning and styling with overlay +# +# officecli add charts-radar.xlsx "/3-Labels & Legend" --type chart \ +# --prop chartType=radar \ +# --prop radarStyle=standard \ +# --prop title="Legend Styles" \ +# --prop series1="Alpha:80,60,75,90,70" \ +# --prop series2="Beta:70,80,85,65,75" \ +# --prop series3="Gamma:65,75,70,80,85" \ +# --prop categories=Metric A,Metric B,Metric C,Metric D,Metric E \ +# --prop colors=4472C4,ED7D31,70AD47 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop legend=right \ +# --prop legendfont=10:1F4E79:Calibri \ +# --prop legend.overlay=true +# +# Features: legend=right, legendfont (size:color:fontFamily), legend.overlay +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Labels & Legend" --type chart' + f' --prop chartType=radar' + f' --prop radarStyle=standard' + f' --prop title="Legend Styles"' + f' --prop series1=Alpha:80,60,75,90,70' + f' --prop series2=Beta:70,80,85,65,75' + f' --prop series3=Gamma:65,75,70,80,85' + f' --prop categories=Metric A,Metric B,Metric C,Metric D,Metric E' + f' --prop colors=4472C4,ED7D31,70AD47' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop legend=right' + f' --prop legendfont=10:1F4E79:Calibri' + f' --prop legend.overlay=true') + +# -------------------------------------------------------------------------- +# Chart 3: Manual plot area layout +# +# officecli add charts-radar.xlsx "/3-Labels & Legend" --type chart \ +# --prop chartType=radar \ +# --prop radarStyle=filled \ +# --prop title="Custom Layout" \ +# --prop series1="Team:85,70,90,65,80" \ +# --prop categories=Vision,Execution,Culture,Agility,Impact \ +# --prop colors=4472C4 \ +# --prop transparency=30 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop plotArea.x=0.1 --prop plotArea.y=0.15 \ +# --prop plotArea.w=0.8 --prop plotArea.h=0.75 +# +# Features: plotArea.x/y/w/h (fractional manual layout positioning) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Labels & Legend" --type chart' + f' --prop chartType=radar' + f' --prop radarStyle=filled' + f' --prop title="Custom Layout"' + f' --prop series1=Team:85,70,90,65,80' + f' --prop categories=Vision,Execution,Culture,Agility,Impact' + f' --prop colors=4472C4' + f' --prop transparency=30' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop plotArea.x=0.1 --prop plotArea.y=0.15' + f' --prop plotArea.w=0.8 --prop plotArea.h=0.75') + +# -------------------------------------------------------------------------- +# Chart 4: Multiple series (5+) comparison +# +# officecli add charts-radar.xlsx "/3-Labels & Legend" --type chart \ +# --prop chartType=radar \ +# --prop radarStyle=standard \ +# --prop title="Multi-Team Comparison" \ +# --prop series1="Dev:90,70,80,65,75" \ +# --prop series2="QA:60,85,70,80,90" \ +# --prop series3="Design:75,80,85,70,60" \ +# --prop series4="PM:80,65,75,90,70" \ +# --prop series5="DevOps:70,75,60,85,80" \ +# --prop categories=Speed,Quality,Innovation,Teamwork,Delivery \ +# --prop colors=4472C4,ED7D31,70AD47,FFC000,7030A0 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop legend=bottom \ +# --prop legendfont=9:333333:Calibri +# +# Features: 5 series on one radar, distinguishing many overlapping lines +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Labels & Legend" --type chart' + f' --prop chartType=radar' + f' --prop radarStyle=standard' + f' --prop title="Multi-Team Comparison"' + f' --prop series1=Dev:90,70,80,65,75' + f' --prop series2=QA:60,85,70,80,90' + f' --prop series3=Design:75,80,85,70,60' + f' --prop series4=PM:80,65,75,90,70' + f' --prop series5=DevOps:70,75,60,85,80' + f' --prop categories=Speed,Quality,Innovation,Teamwork,Delivery' + f' --prop colors=4472C4,ED7D31,70AD47,FFC000,7030A0' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop legend=bottom' + f' --prop legendfont=9:333333:Calibri') + +# ========================================================================== +# Sheet: 4-Advanced +# ========================================================================== +print("\n--- 4-Advanced ---") +cli(f'add "{FILE}" / --type sheet --prop name="4-Advanced"') + +# -------------------------------------------------------------------------- +# Chart 1: Title glow and shadow effects +# +# officecli add charts-radar.xlsx "/4-Advanced" --type chart \ +# --prop chartType=radar \ +# --prop radarStyle=marker \ +# --prop title="Glow & Shadow Title" \ +# --prop series1="Score:75,85,65,90,80" \ +# --prop categories=Creativity,Logic,Memory,Focus,Speed \ +# --prop colors=2E75B6 \ +# --prop marker=diamond:7:2E75B6 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop title.font=Georgia --prop title.size=16 \ +# --prop title.bold=true --prop title.color=1F4E79 \ +# --prop title.glow=4472C4-8 \ +# --prop title.shadow=000000-3-315-2-30 +# +# Features: title.glow (color-radius), title.shadow combined +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Advanced" --type chart' + f' --prop chartType=radar' + f' --prop radarStyle=marker' + f' --prop title="Glow & Shadow Title"' + f' --prop series1=Score:75,85,65,90,80' + f' --prop categories=Creativity,Logic,Memory,Focus,Speed' + f' --prop colors=2E75B6' + f' --prop marker=diamond:7:2E75B6' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop title.font=Georgia --prop title.size=16' + f' --prop title.bold=true --prop title.color=1F4E79' + f' --prop title.glow=4472C4-8' + f' --prop title.shadow=000000-3-315-2-30') + +# -------------------------------------------------------------------------- +# Chart 2: Radar with many spokes (8 categories) +# +# officecli add charts-radar.xlsx "/4-Advanced" --type chart \ +# --prop chartType=radar \ +# --prop radarStyle=filled \ +# --prop title="8-Spoke Assessment" \ +# --prop series1="Candidate:85,70,90,60,75,80,65,88" \ +# --prop series2="Benchmark:70,70,70,70,70,70,70,70" \ +# --prop categories=Technical,Communication,Leadership,Creativity,Analytical,Teamwork,Adaptability,Initiative \ +# --prop colors=4472C4,BFBFBF \ +# --prop transparency=35 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop legend=bottom \ +# --prop gridlines=D9D9D9:0.25 +# +# Features: 8 categories (many spokes), benchmark overlay, gridlines +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Advanced" --type chart' + f' --prop chartType=radar' + f' --prop radarStyle=filled' + f' --prop title="8-Spoke Assessment"' + f' --prop series1=Candidate:85,70,90,60,75,80,65,88' + f' --prop series2=Benchmark:70,70,70,70,70,70,70,70' + f' --prop categories=Technical,Communication,Leadership,Creativity,Analytical,Teamwork,Adaptability,Initiative' + f' --prop colors=4472C4,BFBFBF' + f' --prop transparency=35' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop legend=bottom' + f' --prop gridlines=D9D9D9:0.25') + +# -------------------------------------------------------------------------- +# Chart 3: Single-series radar with full styling +# +# officecli add charts-radar.xlsx "/4-Advanced" --type chart \ +# --prop chartType=radar \ +# --prop radarStyle=marker \ +# --prop title="Personal Profile" \ +# --prop series1="Self:92,78,85,65,88,70" \ +# --prop categories=Python,JavaScript,SQL,DevOps,Testing,Design \ +# --prop colors=7030A0 \ +# --prop marker=square:7:7030A0 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop dataLabels=true --prop labelFont=9:7030A0:true \ +# --prop title.font=Calibri --prop title.size=14 \ +# --prop title.color=7030A0 --prop title.bold=true \ +# --prop plotFill=F8F0FF --prop chartFill=FFFFFF \ +# --prop roundedCorners=true \ +# --prop chartArea.border=7030A0:0.5 +# +# Features: single series with marker, full title/chart/plot styling, +# themed color scheme (purple) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Advanced" --type chart' + f' --prop chartType=radar' + f' --prop radarStyle=marker' + f' --prop title="Personal Profile"' + f' --prop series1=Self:92,78,85,65,88,70' + f' --prop categories=Python,JavaScript,SQL,DevOps,Testing,Design' + f' --prop colors=7030A0' + f' --prop marker=square:7:7030A0' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop dataLabels=true --prop labelFont=9:7030A0:true' + f' --prop title.font=Calibri --prop title.size=14' + f' --prop title.color=7030A0 --prop title.bold=true' + f' --prop plotFill=F8F0FF --prop chartFill=FFFFFF' + f' --prop roundedCorners=true' + f' --prop chartArea.border=7030A0:0.5') + +# -------------------------------------------------------------------------- +# Chart 4: Two-series filled radar with low transparency for overlap +# +# officecli add charts-radar.xlsx "/4-Advanced" --type chart \ +# --prop chartType=radar \ +# --prop radarStyle=filled \ +# --prop title="Before vs After" \ +# --prop series1="Before:55,40,65,50,45" \ +# --prop series2="After:85,75,80,70,80" \ +# --prop categories=Revenue,Efficiency,Satisfaction,Innovation,Retention \ +# --prop colors=C00000,70AD47 \ +# --prop transparency=20 \ +# --prop series.outline=FFFFFF-0.75 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop legend=bottom \ +# --prop dataLabels=true --prop labelFont=9:333333:false \ +# --prop chartFill=FAFAFA --prop plotFill=F5F5F5 +# +# Features: low transparency (20%) for visible overlap, before/after +# comparison pattern, series.outline for separation +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Advanced" --type chart' + f' --prop chartType=radar' + f' --prop radarStyle=filled' + f' --prop title="Before vs After"' + f' --prop series1=Before:55,40,65,50,45' + f' --prop series2=After:85,75,80,70,80' + f' --prop categories=Revenue,Efficiency,Satisfaction,Innovation,Retention' + f' --prop colors=C00000,70AD47' + f' --prop transparency=20' + f' --prop series.outline=FFFFFF-0.75' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop legend=bottom' + f' --prop dataLabels=true --prop labelFont=9:333333:false' + f' --prop chartFill=FAFAFA --prop plotFill=F5F5F5') + +# Remove blank default Sheet1 (all data is inline) +cli(f'remove "{FILE}" /Sheet1') + +print(f"\nDone! Generated: {FILE}") +print(" 5 sheets (4 chart sheets, 16 charts total)") diff --git a/examples/excel/charts-radar.xlsx b/examples/excel/charts-radar.xlsx new file mode 100644 index 000000000..3d70eec1e Binary files /dev/null and b/examples/excel/charts-radar.xlsx differ diff --git a/examples/excel/charts-scatter.md b/examples/excel/charts-scatter.md new file mode 100644 index 000000000..b89ab3b9c --- /dev/null +++ b/examples/excel/charts-scatter.md @@ -0,0 +1,217 @@ +# Scatter Charts Showcase + +This demo consists of three files that work together: + +- **charts-scatter.py** — Python script that calls `officecli` commands to generate the workbook. Each chart command is shown as a copyable shell command in the comments. +- **charts-scatter.xlsx** — The generated workbook with 7 sheets (1 default + 6 chart sheets, 24 charts total). +- **charts-scatter.md** — This file. Maps each sheet to the features it demonstrates. + +## Regenerate + +```bash +cd examples/excel +python3 charts-scatter.py +# → charts-scatter.xlsx +``` + +## Chart Sheets + +### Sheet: 1-Scatter Fundamentals + +Four scatter variants covering markers+lines, marker-only, smooth curves, and line-only. + +```bash +# Basic scatter with circle markers and connecting lines +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter \ + --prop series1="Male:62,68,72,78,82,88,95" \ + --prop categories=160,165,170,175,180,185,190 \ + --prop marker=circle --prop markerSize=6 --prop lineWidth=1.5 \ + --prop catTitle=Height (cm) --prop axisTitle=Weight (kg) + +# Scatter marker-only (no connecting lines) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter --prop scatterStyle=marker \ + --prop markerSize=8 --prop gridlines=D9D9D9:0.5:dot + +# Scatter smooth curve (Bezier interpolation) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter --prop scatterStyle=smooth \ + --prop smooth=true --prop marker=diamond --prop lineWidth=2 + +# Scatter line-only (no markers) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter --prop scatterStyle=line \ + --prop showMarker=false --prop lineWidth=2.5 --prop lineDash=dash +``` + +**Features:** `scatter`, `scatterStyle=marker|smooth|line`, `smooth=true`, `marker=circle|diamond`, `markerSize`, `lineWidth`, `lineDash=dash`, `showMarker=false`, `catTitle`, `axisTitle`, `gridlines` + +### Sheet: 2-Marker Styles + +Four charts demonstrating all marker shapes and per-series marker control. + +```bash +# Per-series markers: circle, diamond, square +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter \ + --prop series1.marker=circle --prop series2.marker=diamond \ + --prop series3.marker=square --prop markerSize=8 + +# Per-series markers: triangle, star, x +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter \ + --prop series1.marker=triangle --prop series2.marker=star \ + --prop series3.marker=x --prop markerSize=9 + +# Large markers with plus and dash shapes +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter --prop scatterStyle=marker \ + --prop series1.marker=circle --prop series2.marker=plus \ + --prop series3.marker=dash --prop markerSize=10 + +# showMarker=false with lineDash=dashDot +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter --prop scatterStyle=lineMarker \ + --prop showMarker=false --prop lineDash=dashDot +``` + +**Features:** `series{N}.marker=circle|diamond|square|triangle|star|x|plus|dash`, `markerSize`, `scatterStyle=lineMarker|marker`, `showMarker=false`, `lineDash=dashDot` + +### Sheet: 3-Trendlines + +Four charts covering all trendline types and sub-properties. + +```bash +# Linear trendline with equation display +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter --prop scatterStyle=marker \ + --prop trendline=linear \ + --prop series1.trendline.equation=true + +# Polynomial (order 3) with R-squared display +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter --prop scatterStyle=marker \ + --prop trendline=poly:3 \ + --prop series1.trendline.rsquared=true + +# Exponential with forward/backward extrapolation +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter --prop scatterStyle=marker \ + --prop trendline=exp:2:1 \ + --prop series1.trendline.name=Exponential Fit + +# Per-series trendlines: linear vs logarithmic +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter --prop scatterStyle=marker \ + --prop series1.trendline=linear --prop series2.trendline=log \ + --prop series1.trendline.equation=true \ + --prop series2.trendline.rsquared=true +``` + +**Features:** `trendline=linear|poly:N|exp|log|power|movingAvg`, `trendline=exp:forward:backward` (extrapolation), `series{N}.trendline` (per-series), `series{N}.trendline.equation`, `series{N}.trendline.rsquared`, `series{N}.trendline.name` + +### Sheet: 4-Error Bars + +Four charts covering all error bar types on scatter series. + +```bash +# Fixed error bars (+/-5) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter \ + --prop errBars=fixed:5 + +# Percentage error bars (10%) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter \ + --prop errBars=percent:10 + +# Standard deviation error bars +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter \ + --prop errBars=stddev + +# Standard error with series shadow +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter \ + --prop errBars=stderr \ + --prop series.shadow=000000-4-315-2-30 +``` + +**Features:** `errBars=fixed:N|percent:N|stddev|stderr`, `series.shadow` + +### Sheet: 5-Styling + +Four charts covering title styling, fills, gradients, borders, and axis formatting. + +```bash +# Title styling with series shadow and outline +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter \ + --prop title.font=Georgia --prop title.size=16 \ + --prop title.color=1F4E79 --prop title.bold=true \ + --prop title.shadow=000000-3-315-2-30 \ + --prop series.shadow=000000-4-315-2-30 \ + --prop series.outline=333333-1.5 + +# Gradients, transparency, plotFill, chartFill +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter \ + --prop 'gradients=4472C4-BDD7EE:90;ED7D31-FBE5D6:90' \ + --prop transparency=20 \ + --prop plotFill=F5F5F5 --prop chartFill=FAFAFA + +# Axis font, gridlines, minor gridlines, axis line +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter \ + --prop axisfont=9:C00000:Arial \ + --prop gridlines=BFBFBF:0.75:solid \ + --prop minorGridlines=E0E0E0:0.25:dot \ + --prop axisLine=333333:1 + +# Chart/plot borders and rounded corners +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter \ + --prop chartArea.border=333333-1.5 \ + --prop plotArea.border=999999-0.75 \ + --prop roundedCorners=true +``` + +**Features:** `title.font/size/color/bold`, `title.shadow`, `series.shadow`, `series.outline`, `gradients`, `transparency`, `plotFill`, `chartFill`, `axisfont`, `gridlines`, `minorGridlines`, `axisLine`, `chartArea.border`, `plotArea.border`, `roundedCorners` + +### Sheet: 6-Advanced + +Four charts covering secondary axis, reference lines, log scale, and conditional coloring. + +```bash +# Secondary Y-axis for dual-unit scatter +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter \ + --prop secondaryAxis=2 + +# Reference line (horizontal target) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter \ + --prop referenceLine=75:FF0000:Target:dash + +# Logarithmic axis with min/max bounds +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter \ + --prop logBase=10 \ + --prop axisMin=1 --prop axisMax=10000 + +# Data labels with conditional color rule +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=scatter --prop scatterStyle=marker \ + --prop dataLabels=true --prop labelPos=top \ + --prop colorRule=60:C00000:00AA00 +``` + +**Features:** `secondaryAxis`, `referenceLine=value:color:label:dash`, `logBase`, `axisMin`, `axisMax`, `dataLabels`, `labelPos=top`, `colorRule=threshold:belowColor:aboveColor` + +## Inspect the Generated File + +```bash +officecli query charts-scatter.xlsx chart +officecli get charts-scatter.xlsx "/1-Scatter Fundamentals/chart[1]" +``` diff --git a/examples/excel/charts-scatter.py b/examples/excel/charts-scatter.py new file mode 100644 index 000000000..a4206bc46 --- /dev/null +++ b/examples/excel/charts-scatter.py @@ -0,0 +1,873 @@ +#!/usr/bin/env python3 +""" +Scatter Charts Showcase — scatter with all marker, trendline, error bar, and styling variations. + +Generates: charts-scatter.xlsx + +Every scatter chart feature officecli supports is demonstrated at least once: +scatter styles, marker types, smooth curves, trendlines (linear, polynomial, +exponential, logarithmic, power, movingAvg), error bars, axis scaling, +gridlines, data labels, legend, fills, shadows, borders, secondary axis, +reference lines, log scale, and color rules. + +6 sheets, 24 charts total. + + 1-Scatter Fundamentals 4 charts — basic scatter, marker-only, smooth curve, line-only + 2-Marker Styles 4 charts — per-series markers, shapes, sizes, toggle + 3-Trendlines 4 charts — linear, polynomial, exponential, per-series + 4-Error Bars 4 charts — fixed, percent, stddev, stderr + 5-Styling 4 charts — title/shadow, gradients, axis/grid, borders + 6-Advanced 4 charts — secondary axis, reference line, log scale, color rule + +Usage: + python3 charts-scatter.py +""" + +import subprocess, sys, os, json, atexit + +FILE = "charts-scatter.xlsx" + +def cli(cmd): + """Run: officecli """ + r = subprocess.run(f"officecli {cmd}", shell=True, capture_output=True, text=True) + out = (r.stdout or "").strip() + if out: + for line in out.split("\n"): + if line.strip(): + print(f" {line.strip()}") + if r.returncode != 0: + err = (r.stderr or "").strip() + if err and "UNSUPPORTED" not in err and "process cannot access" not in err: + print(f" ERROR: {err}") + +if os.path.exists(FILE): + os.remove(FILE) + +cli(f'create "{FILE}"') +cli(f'open "{FILE}"') +atexit.register(lambda: cli(f'close "{FILE}"')) + +# ========================================================================== +# Sheet: 1-Scatter Fundamentals +# ========================================================================== +print("\n--- 1-Scatter Fundamentals ---") +cli(f'add "{FILE}" / --type sheet --prop name="1-Scatter Fundamentals"') + +# -------------------------------------------------------------------------- +# Chart 1: Basic scatter with circle markers and connecting lines +# +# officecli add charts-scatter.xlsx "/1-Scatter Fundamentals" --type chart \ +# --prop chartType=scatter \ +# --prop title="Height vs Weight" \ +# --prop categories=160,165,170,175,180,185,190 \ +# --prop series1="Male:62,68,72,78,82,88,95" \ +# --prop series2="Female:50,55,58,62,65,70,74" \ +# --prop colors=2E75B6,ED7D31 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop marker=circle --prop markerSize=6 \ +# --prop lineWidth=1.5 \ +# --prop catTitle=Height (cm) --prop axisTitle=Weight (kg) \ +# --prop legend=bottom +# +# Features: chartType=scatter, marker=circle, markerSize=6, lineWidth=1.5, +# catTitle, axisTitle, legend +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Scatter Fundamentals" --type chart' + f' --prop chartType=scatter' + f' --prop title="Height vs Weight"' + f' --prop categories=160,165,170,175,180,185,190' + f' --prop series1=Male:62,68,72,78,82,88,95' + f' --prop series2=Female:50,55,58,62,65,70,74' + f' --prop colors=2E75B6,ED7D31' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop marker=circle --prop markerSize=6' + f' --prop lineWidth=1.5' + f' --prop catTitle="Height (cm)" --prop axisTitle="Weight (kg)"' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 2: Scatter marker-only (scatterStyle=marker), various marker sizes +# +# officecli add charts-scatter.xlsx "/1-Scatter Fundamentals" --type chart \ +# --prop chartType=scatter \ +# --prop scatterStyle=marker \ +# --prop title="Study Hours vs Test Score" \ +# --prop categories=1,2,3,4,5,6,7,8 \ +# --prop series1="Class A:55,60,65,72,78,82,88,92" \ +# --prop series2="Class B:50,58,62,68,74,80,85,90" \ +# --prop colors=4472C4,70AD47 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop markerSize=8 \ +# --prop catTitle=Study Hours --prop axisTitle=Score \ +# --prop gridlines=D9D9D9:0.5:dot +# +# Features: scatterStyle=marker (no connecting lines), markerSize=8, +# gridlines styling +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Scatter Fundamentals" --type chart' + f' --prop chartType=scatter' + f' --prop scatterStyle=marker' + f' --prop title="Study Hours vs Test Score"' + f' --prop categories=1,2,3,4,5,6,7,8' + f' --prop series1="Class A:55,60,65,72,78,82,88,92"' + f' --prop series2="Class B:50,58,62,68,74,80,85,90"' + f' --prop colors=4472C4,70AD47' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop markerSize=8' + f' --prop catTitle="Study Hours" --prop axisTitle=Score' + f' --prop gridlines=D9D9D9:0.5:dot') + +# -------------------------------------------------------------------------- +# Chart 3: Scatter smooth curve (smooth=true, scatterStyle=smooth) +# +# officecli add charts-scatter.xlsx "/1-Scatter Fundamentals" --type chart \ +# --prop chartType=scatter \ +# --prop scatterStyle=smooth \ +# --prop smooth=true \ +# --prop title="Temperature vs Ice Cream Sales" \ +# --prop categories=15,18,22,25,28,30,33,35 \ +# --prop series1="Sales ($):120,180,260,340,420,480,530,560" \ +# --prop colors=C00000 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop marker=diamond --prop markerSize=7 \ +# --prop lineWidth=2 \ +# --prop catTitle=Temperature (C) --prop axisTitle=Daily Sales ($) +# +# Features: scatterStyle=smooth, smooth=true (Bezier interpolation), +# marker=diamond, single series +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Scatter Fundamentals" --type chart' + f' --prop chartType=scatter' + f' --prop scatterStyle=smooth' + f' --prop smooth=true' + f' --prop title="Temperature vs Ice Cream Sales"' + f' --prop categories=15,18,22,25,28,30,33,35' + f' --prop series1="Sales ($):120,180,260,340,420,480,530,560"' + f' --prop colors=C00000' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop marker=diamond --prop markerSize=7' + f' --prop lineWidth=2' + f' --prop catTitle="Temperature (C)" --prop axisTitle="Daily Sales ($)"') + +# -------------------------------------------------------------------------- +# Chart 4: Scatter line-only (no markers, scatterStyle=line) +# +# officecli add charts-scatter.xlsx "/1-Scatter Fundamentals" --type chart \ +# --prop chartType=scatter \ +# --prop scatterStyle=line \ +# --prop title="Altitude vs Air Pressure" \ +# --prop categories=0,500,1000,2000,3000,5000,8000 \ +# --prop series1="Pressure (hPa):1013,955,899,795,701,540,356" \ +# --prop colors=1F4E79 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop showMarker=false \ +# --prop lineWidth=2.5 \ +# --prop lineDash=dash \ +# --prop catTitle=Altitude (m) --prop axisTitle=Pressure (hPa) +# +# Features: scatterStyle=line (line without markers), showMarker=false, +# lineWidth=2.5, lineDash=dash +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Scatter Fundamentals" --type chart' + f' --prop chartType=scatter' + f' --prop scatterStyle=line' + f' --prop title="Altitude vs Air Pressure"' + f' --prop categories=0,500,1000,2000,3000,5000,8000' + f' --prop series1="Pressure (hPa):1013,955,899,795,701,540,356"' + f' --prop colors=1F4E79' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop showMarker=false' + f' --prop lineWidth=2.5' + f' --prop lineDash=dash' + f' --prop catTitle="Altitude (m)" --prop axisTitle="Pressure (hPa)"') + +# ========================================================================== +# Sheet: 2-Marker Styles +# ========================================================================== +print("\n--- 2-Marker Styles ---") +cli(f'add "{FILE}" / --type sheet --prop name="2-Marker Styles"') + +# -------------------------------------------------------------------------- +# Chart 1: Per-series markers — circle, diamond, square +# +# officecli add charts-scatter.xlsx "/2-Marker Styles" --type chart \ +# --prop chartType=scatter \ +# --prop title="Per-Series Markers: Circle, Diamond, Square" \ +# --prop categories=10,20,30,40,50,60 \ +# --prop series1="Sensor A:12,28,35,42,55,68" \ +# --prop series2="Sensor B:8,22,30,38,48,58" \ +# --prop series3="Sensor C:15,25,32,45,52,62" \ +# --prop colors=4472C4,ED7D31,70AD47 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop series1.marker=circle \ +# --prop series2.marker=diamond \ +# --prop series3.marker=square \ +# --prop markerSize=8 --prop lineWidth=1 \ +# --prop legend=bottom +# +# Features: series1.marker=circle, series2.marker=diamond, +# series3.marker=square (per-series marker style) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Marker Styles" --type chart' + f' --prop chartType=scatter' + f' --prop title="Per-Series Markers: Circle, Diamond, Square"' + f' --prop categories=10,20,30,40,50,60' + f' --prop series1="Sensor A:12,28,35,42,55,68"' + f' --prop series2="Sensor B:8,22,30,38,48,58"' + f' --prop series3="Sensor C:15,25,32,45,52,62"' + f' --prop colors=4472C4,ED7D31,70AD47' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop series1.marker=circle' + f' --prop series2.marker=diamond' + f' --prop series3.marker=square' + f' --prop markerSize=8 --prop lineWidth=1' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 2: Per-series markers — triangle, star, x +# +# officecli add charts-scatter.xlsx "/2-Marker Styles" --type chart \ +# --prop chartType=scatter \ +# --prop title="Per-Series Markers: Triangle, Star, X" \ +# --prop categories=5,10,15,20,25,30 \ +# --prop series1="Lab 1:18,32,28,45,52,60" \ +# --prop series2="Lab 2:22,25,38,40,48,55" \ +# --prop series3="Lab 3:10,20,32,35,42,50" \ +# --prop colors=FFC000,9DC3E6,843C0B \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop series1.marker=triangle \ +# --prop series2.marker=star \ +# --prop series3.marker=x \ +# --prop markerSize=9 --prop lineWidth=1 \ +# --prop legend=bottom +# +# Features: series1.marker=triangle, series2.marker=star, +# series3.marker=x +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Marker Styles" --type chart' + f' --prop chartType=scatter' + f' --prop title="Per-Series Markers: Triangle, Star, X"' + f' --prop categories=5,10,15,20,25,30' + f' --prop series1="Lab 1:18,32,28,45,52,60"' + f' --prop series2="Lab 2:22,25,38,40,48,55"' + f' --prop series3="Lab 3:10,20,32,35,42,50"' + f' --prop colors=FFC000,9DC3E6,843C0B' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop series1.marker=triangle' + f' --prop series2.marker=star' + f' --prop series3.marker=x' + f' --prop markerSize=9 --prop lineWidth=1' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: Large markers with series colors, markerSize=10 +# +# officecli add charts-scatter.xlsx "/2-Marker Styles" --type chart \ +# --prop chartType=scatter \ +# --prop scatterStyle=marker \ +# --prop title="Large Markers (size=10)" \ +# --prop categories=100,200,300,400,500 \ +# --prop series1="Revenue:150,280,350,420,510" \ +# --prop series2="Profit:80,140,180,220,280" \ +# --prop series3="Cost:70,140,170,200,230" \ +# --prop colors=2E75B6,548235,BF8F00 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop series1.marker=circle \ +# --prop series2.marker=plus \ +# --prop series3.marker=dash \ +# --prop markerSize=10 \ +# --prop legend=right +# +# Features: markerSize=10, marker=plus, marker=dash, scatterStyle=marker +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Marker Styles" --type chart' + f' --prop chartType=scatter' + f' --prop scatterStyle=marker' + f' --prop title="Large Markers (size=10)"' + f' --prop categories=100,200,300,400,500' + f' --prop series1="Revenue:150,280,350,420,510"' + f' --prop series2="Profit:80,140,180,220,280"' + f' --prop series3="Cost:70,140,170,200,230"' + f' --prop colors=2E75B6,548235,BF8F00' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop series1.marker=circle' + f' --prop series2.marker=plus' + f' --prop series3.marker=dash' + f' --prop markerSize=10' + f' --prop legend=right') + +# -------------------------------------------------------------------------- +# Chart 4: showMarker=false (line only) vs showMarker=true +# +# officecli add charts-scatter.xlsx "/2-Marker Styles" --type chart \ +# --prop chartType=scatter \ +# --prop scatterStyle=lineMarker \ +# --prop title="Marker Toggle (none shown)" \ +# --prop categories=1,2,3,4,5,6,7,8,9,10 \ +# --prop series1="Signal:3,7,5,11,9,14,12,18,15,20" \ +# --prop series2="Noise:2,4,6,5,8,7,10,9,12,11" \ +# --prop colors=4472C4,BFBFBF \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop showMarker=false \ +# --prop lineWidth=2 \ +# --prop lineDash=dashDot \ +# --prop legend=bottom +# +# Features: scatterStyle=lineMarker, showMarker=false (markers hidden), +# lineDash=dashDot +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Marker Styles" --type chart' + f' --prop chartType=scatter' + f' --prop scatterStyle=lineMarker' + f' --prop title="Marker Toggle (none shown)"' + f' --prop categories=1,2,3,4,5,6,7,8,9,10' + f' --prop series1="Signal:3,7,5,11,9,14,12,18,15,20"' + f' --prop series2="Noise:2,4,6,5,8,7,10,9,12,11"' + f' --prop colors=4472C4,BFBFBF' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop showMarker=false' + f' --prop lineWidth=2' + f' --prop lineDash=dashDot' + f' --prop legend=bottom') + +# ========================================================================== +# Sheet: 3-Trendlines +# ========================================================================== +print("\n--- 3-Trendlines ---") +cli(f'add "{FILE}" / --type sheet --prop name="3-Trendlines"') + +# -------------------------------------------------------------------------- +# Chart 1: Linear trendline with equation display +# +# officecli add charts-scatter.xlsx "/3-Trendlines" --type chart \ +# --prop chartType=scatter \ +# --prop scatterStyle=marker \ +# --prop title="Linear Trendline + Equation" \ +# --prop categories=1,2,3,4,5,6,7,8,9,10 \ +# --prop series1="Observed:8,15,22,28,33,42,48,55,60,68" \ +# --prop colors=4472C4 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop markerSize=7 \ +# --prop trendline=linear \ +# --prop series1.trendline.equation=true \ +# --prop catTitle=X --prop axisTitle=Y +# +# Features: trendline=linear, series1.trendline.equation=true +# (display equation on chart) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Trendlines" --type chart' + f' --prop chartType=scatter' + f' --prop scatterStyle=marker' + f' --prop title="Linear Trendline + Equation"' + f' --prop categories=1,2,3,4,5,6,7,8,9,10' + f' --prop series1="Observed:8,15,22,28,33,42,48,55,60,68"' + f' --prop colors=4472C4' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop markerSize=7' + f' --prop trendline=linear' + f' --prop series1.trendline.equation=true' + f' --prop catTitle=X --prop axisTitle=Y') + +# -------------------------------------------------------------------------- +# Chart 2: Polynomial trendline (order 3) with R-squared display +# +# officecli add charts-scatter.xlsx "/3-Trendlines" --type chart \ +# --prop chartType=scatter \ +# --prop scatterStyle=marker \ +# --prop title="Polynomial (order 3) + R-squared" \ +# --prop categories=1,2,3,4,5,6,7,8,9,10 \ +# --prop series1="Measurement:5,12,25,30,28,35,50,62,58,72" \ +# --prop colors=70AD47 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop markerSize=7 --prop marker=square \ +# --prop trendline=poly:3 \ +# --prop series1.trendline.rsquared=true \ +# --prop catTitle=Sample --prop axisTitle=Value +# +# Features: trendline=poly:3 (polynomial order 3), +# series1.trendline.rsquared=true (R-squared display) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Trendlines" --type chart' + f' --prop chartType=scatter' + f' --prop scatterStyle=marker' + f' --prop title="Polynomial (order 3) + R-squared"' + f' --prop categories=1,2,3,4,5,6,7,8,9,10' + f' --prop series1="Measurement:5,12,25,30,28,35,50,62,58,72"' + f' --prop colors=70AD47' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop markerSize=7 --prop marker=square' + f' --prop trendline=poly:3' + f' --prop series1.trendline.rsquared=true' + f' --prop catTitle=Sample --prop axisTitle=Value') + +# -------------------------------------------------------------------------- +# Chart 3: Exponential trendline with forward/backward extrapolation +# +# officecli add charts-scatter.xlsx "/3-Trendlines" --type chart \ +# --prop chartType=scatter \ +# --prop scatterStyle=marker \ +# --prop title="Exponential + Extrapolation" \ +# --prop categories=1,2,3,4,5,6,7,8 \ +# --prop series1="Growth:2,4,7,12,20,35,58,95" \ +# --prop colors=ED7D31 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop markerSize=7 --prop marker=triangle \ +# --prop trendline=exp:2:1 \ +# --prop series1.trendline.name=Exponential Fit \ +# --prop catTitle=Period --prop axisTitle=Amount +# +# Features: trendline=exp:2:1 (exponential, forward=2, backward=1), +# series1.trendline.name (custom trendline label) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Trendlines" --type chart' + f' --prop chartType=scatter' + f' --prop scatterStyle=marker' + f' --prop title="Exponential + Extrapolation"' + f' --prop categories=1,2,3,4,5,6,7,8' + f' --prop series1="Growth:2,4,7,12,20,35,58,95"' + f' --prop colors=ED7D31' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop markerSize=7 --prop marker=triangle' + f' --prop trendline=exp:2:1' + f' --prop series1.trendline.name="Exponential Fit"' + f' --prop catTitle=Period --prop axisTitle=Amount') + +# -------------------------------------------------------------------------- +# Chart 4: Per-series trendlines — linear vs logarithmic +# +# officecli add charts-scatter.xlsx "/3-Trendlines" --type chart \ +# --prop chartType=scatter \ +# --prop scatterStyle=marker \ +# --prop title="Per-Series: Linear vs Logarithmic" \ +# --prop categories=1,2,4,8,16,32,64 \ +# --prop series1="Dataset A:10,18,30,45,62,78,95" \ +# --prop series2="Dataset B:5,25,38,45,50,54,56" \ +# --prop colors=4472C4,C00000 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop markerSize=7 \ +# --prop series1.trendline=linear \ +# --prop series2.trendline=log \ +# --prop series1.trendline.equation=true \ +# --prop series2.trendline.rsquared=true \ +# --prop legend=bottom +# +# Features: series1.trendline=linear, series2.trendline=log, +# per-series trendline with sub-properties +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Trendlines" --type chart' + f' --prop chartType=scatter' + f' --prop scatterStyle=marker' + f' --prop title="Per-Series: Linear vs Logarithmic"' + f' --prop categories=1,2,4,8,16,32,64' + f' --prop series1="Dataset A:10,18,30,45,62,78,95"' + f' --prop series2="Dataset B:5,25,38,45,50,54,56"' + f' --prop colors=4472C4,C00000' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop markerSize=7' + f' --prop series1.trendline=linear' + f' --prop series2.trendline=log' + f' --prop series1.trendline.equation=true' + f' --prop series2.trendline.rsquared=true' + f' --prop legend=bottom') + +# ========================================================================== +# Sheet: 4-Error Bars +# ========================================================================== +print("\n--- 4-Error Bars ---") +cli(f'add "{FILE}" / --type sheet --prop name="4-Error Bars"') + +# -------------------------------------------------------------------------- +# Chart 1: Fixed error bars (errBars=fixed:5) +# +# officecli add charts-scatter.xlsx "/4-Error Bars" --type chart \ +# --prop chartType=scatter \ +# --prop title="Fixed Error Bars (+-5)" \ +# --prop categories=10,20,30,40,50,60 \ +# --prop series1="Measurement:25,42,58,72,88,105" \ +# --prop colors=4472C4 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop marker=circle --prop markerSize=7 \ +# --prop lineWidth=1 \ +# --prop errBars=fixed:5 \ +# --prop catTitle=Input --prop axisTitle=Output +# +# Features: errBars=fixed:5 (constant +/-5 error) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Error Bars" --type chart' + f' --prop chartType=scatter' + f' --prop title="Fixed Error Bars (+-5)"' + f' --prop categories=10,20,30,40,50,60' + f' --prop series1="Measurement:25,42,58,72,88,105"' + f' --prop colors=4472C4' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop marker=circle --prop markerSize=7' + f' --prop lineWidth=1' + f' --prop errBars=fixed:5' + f' --prop catTitle=Input --prop axisTitle=Output') + +# -------------------------------------------------------------------------- +# Chart 2: Percentage error bars (errBars=percent:10) +# +# officecli add charts-scatter.xlsx "/4-Error Bars" --type chart \ +# --prop chartType=scatter \ +# --prop title="Percentage Error Bars (10%)" \ +# --prop categories=5,10,15,20,25,30 \ +# --prop series1="Yield:120,185,240,310,375,450" \ +# --prop colors=70AD47 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop marker=diamond --prop markerSize=7 \ +# --prop lineWidth=1 \ +# --prop errBars=percent:10 \ +# --prop catTitle=Dosage --prop axisTitle=Yield +# +# Features: errBars=percent:10 (10% of each value) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Error Bars" --type chart' + f' --prop chartType=scatter' + f' --prop title="Percentage Error Bars (10%)"' + f' --prop categories=5,10,15,20,25,30' + f' --prop series1="Yield:120,185,240,310,375,450"' + f' --prop colors=70AD47' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop marker=diamond --prop markerSize=7' + f' --prop lineWidth=1' + f' --prop errBars=percent:10' + f' --prop catTitle=Dosage --prop axisTitle=Yield') + +# -------------------------------------------------------------------------- +# Chart 3: Standard deviation error bars (errBars=stddev) +# +# officecli add charts-scatter.xlsx "/4-Error Bars" --type chart \ +# --prop chartType=scatter \ +# --prop title="Standard Deviation Error Bars" \ +# --prop categories=0,1,2,3,4,5,6,7 \ +# --prop series1="Trial 1:48,52,47,55,50,53,49,51" \ +# --prop series2="Trial 2:30,35,28,40,32,38,34,36" \ +# --prop colors=ED7D31,9DC3E6 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop marker=square --prop markerSize=6 \ +# --prop lineWidth=1 \ +# --prop errBars=stddev \ +# --prop legend=bottom +# +# Features: errBars=stddev (standard deviation), multi-series with errBars +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Error Bars" --type chart' + f' --prop chartType=scatter' + f' --prop title="Standard Deviation Error Bars"' + f' --prop categories=0,1,2,3,4,5,6,7' + f' --prop series1="Trial 1:48,52,47,55,50,53,49,51"' + f' --prop series2="Trial 2:30,35,28,40,32,38,34,36"' + f' --prop colors=ED7D31,9DC3E6' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop marker=square --prop markerSize=6' + f' --prop lineWidth=1' + f' --prop errBars=stddev' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 4: Standard error with series styling +# +# officecli add charts-scatter.xlsx "/4-Error Bars" --type chart \ +# --prop chartType=scatter \ +# --prop title="Standard Error + Styled Series" \ +# --prop categories=2,4,6,8,10,12,14 \ +# --prop series1="Experiment:18,32,28,45,40,55,52" \ +# --prop colors=843C0B \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop marker=star --prop markerSize=8 \ +# --prop lineWidth=1.5 \ +# --prop errBars=stderr \ +# --prop series.shadow=000000-4-315-2-30 \ +# --prop gridlines=D9D9D9:0.5:dot \ +# --prop catTitle=Time (h) --prop axisTitle=Response +# +# Features: errBars=stderr, series.shadow, gridlines styling +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Error Bars" --type chart' + f' --prop chartType=scatter' + f' --prop title="Standard Error + Styled Series"' + f' --prop categories=2,4,6,8,10,12,14' + f' --prop series1="Experiment:18,32,28,45,40,55,52"' + f' --prop colors=843C0B' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop marker=star --prop markerSize=8' + f' --prop lineWidth=1.5' + f' --prop errBars=stderr' + f' --prop series.shadow=000000-4-315-2-30' + f' --prop gridlines=D9D9D9:0.5:dot' + f' --prop catTitle="Time (h)" --prop axisTitle=Response') + +# ========================================================================== +# Sheet: 5-Styling +# ========================================================================== +print("\n--- 5-Styling ---") +cli(f'add "{FILE}" / --type sheet --prop name="5-Styling"') + +# -------------------------------------------------------------------------- +# Chart 1: Title styling, series shadow, series outline +# +# officecli add charts-scatter.xlsx "/5-Styling" --type chart \ +# --prop chartType=scatter \ +# --prop title="Styled Title + Series Effects" \ +# --prop categories=10,20,30,40,50 \ +# --prop series1="Alpha:15,35,28,48,55" \ +# --prop series2="Beta:8,22,32,40,50" \ +# --prop colors=4472C4,ED7D31 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop marker=circle --prop markerSize=8 --prop lineWidth=2 \ +# --prop title.font=Georgia --prop title.size=16 \ +# --prop title.color=1F4E79 --prop title.bold=true \ +# --prop title.shadow=000000-3-315-2-30 \ +# --prop series.shadow=000000-4-315-2-30 \ +# --prop series.outline=333333:1.5 \ +# --prop legend=bottom +# +# Features: title.font, title.size, title.color, title.bold, title.shadow, +# series.shadow, series.outline +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Styling" --type chart' + f' --prop chartType=scatter' + f' --prop title="Styled Title + Series Effects"' + f' --prop categories=10,20,30,40,50' + f' --prop series1="Alpha:15,35,28,48,55"' + f' --prop series2="Beta:8,22,32,40,50"' + f' --prop colors=4472C4,ED7D31' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop marker=circle --prop markerSize=8 --prop lineWidth=2' + f' --prop title.font=Georgia --prop title.size=16' + f' --prop title.color=1F4E79 --prop title.bold=true' + f' --prop title.shadow=000000-3-315-2-30' + f' --prop series.shadow=000000-4-315-2-30' + f' --prop series.outline=333333:1.5' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 2: Gradients, transparency, plotFill, chartFill +# +# officecli add charts-scatter.xlsx "/5-Styling" --type chart \ +# --prop chartType=scatter \ +# --prop title="Gradients + Fills" \ +# --prop categories=5,15,25,35,45 \ +# --prop series1="Group 1:12,28,35,42,55" \ +# --prop series2="Group 2:8,18,22,38,48" \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop marker=diamond --prop markerSize=8 --prop lineWidth=1.5 \ +# --prop 'gradients=4472C4-BDD7EE:90;ED7D31-FBE5D6:90' \ +# --prop transparency=20 \ +# --prop plotFill=F5F5F5 \ +# --prop chartFill=FAFAFA \ +# --prop legend=bottom +# +# Features: gradients (per-series gradient), transparency, plotFill, chartFill +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Styling" --type chart' + f' --prop chartType=scatter' + f' --prop title="Gradients + Fills"' + f' --prop categories=5,15,25,35,45' + f' --prop series1="Group 1:12,28,35,42,55"' + f' --prop series2="Group 2:8,18,22,38,48"' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop marker=diamond --prop markerSize=8 --prop lineWidth=1.5' + f' --prop "gradients=4472C4-BDD7EE:90;ED7D31-FBE5D6:90"' + f' --prop transparency=20' + f' --prop plotFill=F5F5F5' + f' --prop chartFill=FAFAFA' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: Axis font, gridlines, minor gridlines, axis line +# +# officecli add charts-scatter.xlsx "/5-Styling" --type chart \ +# --prop chartType=scatter \ +# --prop title="Axis & Grid Styling" \ +# --prop categories=0,10,20,30,40,50 \ +# --prop series1="Readings:5,22,38,52,68,82" \ +# --prop colors=2E75B6 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop marker=circle --prop markerSize=7 --prop lineWidth=1.5 \ +# --prop axisfont=9:C00000:Arial \ +# --prop gridlines=BFBFBF:0.75:solid \ +# --prop minorGridlines=E0E0E0:0.25:dot \ +# --prop axisLine=333333:1 \ +# --prop catTitle=X Axis --prop axisTitle=Y Axis +# +# Features: axisfont (size:color:font), gridlines, minorGridlines, axisLine +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Styling" --type chart' + f' --prop chartType=scatter' + f' --prop title="Axis & Grid Styling"' + f' --prop categories=0,10,20,30,40,50' + f' --prop series1="Readings:5,22,38,52,68,82"' + f' --prop colors=2E75B6' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop marker=circle --prop markerSize=7 --prop lineWidth=1.5' + f' --prop axisfont=9:C00000:Arial' + f' --prop gridlines=BFBFBF:0.75:solid' + f' --prop minorGridlines=E0E0E0:0.25:dot' + f' --prop axisLine=333333:1' + f' --prop catTitle="X Axis" --prop axisTitle="Y Axis"') + +# -------------------------------------------------------------------------- +# Chart 4: Chart area border, plot area border, rounded corners +# +# officecli add charts-scatter.xlsx "/5-Styling" --type chart \ +# --prop chartType=scatter \ +# --prop title="Borders + Rounded Corners" \ +# --prop categories=1,3,5,7,9 \ +# --prop series1="Data:10,25,18,35,28" \ +# --prop colors=548235 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop marker=square --prop markerSize=8 --prop lineWidth=1.5 \ +# --prop chartArea.border=333333:1.5 \ +# --prop plotArea.border=999999:0.75 \ +# --prop roundedCorners=true \ +# --prop chartFill=FFFFFF \ +# --prop plotFill=F0F0F0 +# +# Features: chartArea.border, plotArea.border, roundedCorners +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/5-Styling" --type chart' + f' --prop chartType=scatter' + f' --prop title="Borders + Rounded Corners"' + f' --prop categories=1,3,5,7,9' + f' --prop series1="Data:10,25,18,35,28"' + f' --prop colors=548235' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop marker=square --prop markerSize=8 --prop lineWidth=1.5' + f' --prop chartArea.border=333333:1.5' + f' --prop plotArea.border=999999:0.75' + f' --prop roundedCorners=true' + f' --prop chartFill=FFFFFF' + f' --prop plotFill=F0F0F0') + +# ========================================================================== +# Sheet: 6-Advanced +# ========================================================================== +print("\n--- 6-Advanced ---") +cli(f'add "{FILE}" / --type sheet --prop name="6-Advanced"') + +# -------------------------------------------------------------------------- +# Chart 1: Secondary axis +# +# officecli add charts-scatter.xlsx "/6-Advanced" --type chart \ +# --prop chartType=scatter \ +# --prop title="Secondary Y-Axis" \ +# --prop categories=10,20,30,40,50,60 \ +# --prop series1="Temperature (C):15,20,28,32,38,42" \ +# --prop series2="Humidity (%):85,78,65,58,45,38" \ +# --prop colors=C00000,4472C4 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop marker=circle --prop markerSize=7 --prop lineWidth=1.5 \ +# --prop secondaryAxis=2 \ +# --prop legend=bottom \ +# --prop catTitle=Location +# +# Features: secondaryAxis=2 (series 2 on right Y-axis) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/6-Advanced" --type chart' + f' --prop chartType=scatter' + f' --prop title="Secondary Y-Axis"' + f' --prop categories=10,20,30,40,50,60' + f' --prop series1="Temperature (C):15,20,28,32,38,42"' + f' --prop series2="Humidity (%):85,78,65,58,45,38"' + f' --prop colors=C00000,4472C4' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop marker=circle --prop markerSize=7 --prop lineWidth=1.5' + f' --prop secondaryAxis=2' + f' --prop legend=bottom' + f' --prop catTitle=Location') + +# -------------------------------------------------------------------------- +# Chart 2: Reference line (horizontal target) +# +# officecli add charts-scatter.xlsx "/6-Advanced" --type chart \ +# --prop chartType=scatter \ +# --prop title="Reference Line (Target=75)" \ +# --prop categories=1,2,3,4,5,6,7,8 \ +# --prop series1="Score:60,68,72,78,80,74,82,88" \ +# --prop colors=70AD47 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop marker=diamond --prop markerSize=7 --prop lineWidth=1.5 \ +# --prop referenceLine=75:FF0000:Target:dash \ +# --prop catTitle=Week --prop axisTitle=Performance +# +# Features: referenceLine=value:color:label:dash (horizontal target line) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/6-Advanced" --type chart' + f' --prop chartType=scatter' + f' --prop title="Reference Line (Target=75)"' + f' --prop categories=1,2,3,4,5,6,7,8' + f' --prop series1="Score:60,68,72,78,80,74,82,88"' + f' --prop colors=70AD47' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop marker=diamond --prop markerSize=7 --prop lineWidth=1.5' + f' --prop referenceLine=75:FF0000:Target:dash' + f' --prop catTitle=Week --prop axisTitle=Performance') + +# -------------------------------------------------------------------------- +# Chart 3: Axis min/max and log scale +# +# officecli add charts-scatter.xlsx "/6-Advanced" --type chart \ +# --prop chartType=scatter \ +# --prop title="Log Scale (base 10)" \ +# --prop categories=1,10,100,1000,10000 \ +# --prop series1="Response:2,15,120,950,8500" \ +# --prop colors=1F4E79 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop marker=triangle --prop markerSize=8 --prop lineWidth=1.5 \ +# --prop logBase=10 \ +# --prop axisMin=1 --prop axisMax=10000 \ +# --prop catTitle=Concentration --prop axisTitle=Response +# +# Features: logBase=10 (logarithmic value axis), axisMin, axisMax +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/6-Advanced" --type chart' + f' --prop chartType=scatter' + f' --prop title="Log Scale (base 10)"' + f' --prop categories=1,10,100,1000,10000' + f' --prop series1="Response:2,15,120,950,8500"' + f' --prop colors=1F4E79' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop marker=triangle --prop markerSize=8 --prop lineWidth=1.5' + f' --prop logBase=10' + f' --prop axisMin=1 --prop axisMax=10000' + f' --prop catTitle=Concentration --prop axisTitle=Response') + +# -------------------------------------------------------------------------- +# Chart 4: Data labels and color rule +# +# officecli add charts-scatter.xlsx "/6-Advanced" --type chart \ +# --prop chartType=scatter \ +# --prop scatterStyle=marker \ +# --prop title="Data Labels + Color Rule" \ +# --prop categories=1,2,3,4,5,6,7,8 \ +# --prop series1="KPI:45,62,38,78,55,82,48,90" \ +# --prop colors=4472C4 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop markerSize=9 \ +# --prop dataLabels=true --prop labelPos=top \ +# --prop colorRule=60:C00000:00AA00 \ +# --prop catTitle=Quarter --prop axisTitle=KPI Score +# +# Features: dataLabels=true, labelPos=top, colorRule=threshold:below:above +# (points below 60 = red, above = green) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/6-Advanced" --type chart' + f' --prop chartType=scatter' + f' --prop scatterStyle=marker' + f' --prop title="Data Labels + Color Rule"' + f' --prop categories=1,2,3,4,5,6,7,8' + f' --prop series1="KPI:45,62,38,78,55,82,48,90"' + f' --prop colors=4472C4' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop markerSize=9' + f' --prop dataLabels=true --prop labelPos=top' + f' --prop colorRule=60:C00000:00AA00' + f' --prop catTitle=Quarter --prop axisTitle="KPI Score"') + +# Remove blank default Sheet1 (all data is inline) +cli(f'remove "{FILE}" /Sheet1') + +print(f"\nDone! Generated: {FILE}") +print(" 7 sheets (6 chart sheets, 24 charts total)") diff --git a/examples/excel/charts-scatter.xlsx b/examples/excel/charts-scatter.xlsx new file mode 100644 index 000000000..804ca2328 Binary files /dev/null and b/examples/excel/charts-scatter.xlsx differ diff --git a/examples/excel/charts-stock.md b/examples/excel/charts-stock.md new file mode 100644 index 000000000..26863736c --- /dev/null +++ b/examples/excel/charts-stock.md @@ -0,0 +1,117 @@ +# Stock Charts Showcase + +This demo consists of three files that work together: + +- **charts-stock.py** — Python script that calls `officecli` commands to generate the workbook. Each chart command is shown as a copyable shell command in the comments. +- **charts-stock.xlsx** — The generated workbook with 4 sheets (1 default + 3 chart sheets, 12 charts total). +- **charts-stock.md** — This file. Maps each sheet to the features it demonstrates. + +## Regenerate + +```bash +cd examples/excel +python3 charts-stock.py +# -> charts-stock.xlsx +``` + +## Chart Sheets + +### Sheet: 1-Stock Fundamentals + +Four OHLC stock charts covering basic rendering, gridlines, hi-low lines, and up-down bars. + +```bash +# Basic OHLC stock chart with axis titles +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=stock \ + --prop series1="Open:142,145,148,150,147,152" \ + --prop series2="High:148,151,155,156,153,158" \ + --prop series3="Low:139,142,145,147,144,149" \ + --prop series4="Close:145,148,150,147,152,155" \ + --prop catTitle=Week --prop axisTitle=Price ($) + +# Stock with gridlines and axis font +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=stock \ + --prop gridlines=D9D9D9:0.5 --prop axisfont=9:666666 + +# Hi-low lines connecting high to low per category +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=stock \ + --prop hiLowLines=true + +# Up-down bars showing open-to-close direction +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=stock \ + --prop updownbars=100:70AD47:C00000 +``` + +**Features:** `stock`, 4-series OHLC, `catTitle`, `axisTitle`, `gridlines`, `axisfont`, `hiLowLines`, `updownbars=gapWidth:upColor:downColor` + +### Sheet: 2-Stock Styling + +Four styled stock charts with title fonts, axis lines, custom ranges, and chart fills. + +```bash +# Title and legend styling +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=stock \ + --prop title.font=Georgia --prop title.size=16 \ + --prop title.color=1F4E79 --prop title.bold=true \ + --prop legend=right --prop legendfont=10:333333:Calibri + +# Axis line styling +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=stock \ + --prop axisLine=333333-1.5 --prop catAxisLine=333333-1.5 + +# Custom axis range with major unit +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=stock \ + --prop axisMin=110 --prop axisMax=150 --prop majorUnit=10 + +# Chart area fills and rounded corners +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=stock \ + --prop plotFill=F0F4F8 --prop chartFill=FAFAFA \ + --prop roundedCorners=true +``` + +**Features:** `title.font/size/color/bold`, `legend=right`, `legendfont`, `axisLine`, `catAxisLine`, `axisMin/Max`, `majorUnit`, `plotFill`, `chartFill`, `roundedCorners` + +### Sheet: 3-Stock Advanced + +Four advanced stock charts with data labels, reference lines, borders, and number formatting. + +```bash +# Data labels on stock chart +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=stock \ + --prop dataLabels=true --prop labelPos=top \ + --prop labelFont=8:666666:false + +# Reference line as support/resistance level +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=stock \ + --prop referenceLine=115:Resistance:C00000 + +# Chart and plot area borders +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=stock \ + --prop chartArea.border=333333-1.5 \ + --prop plotArea.border=999999-0.75 + +# Axis number format (dollar) +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=stock \ + --prop axisNumFmt=$#,##0 +``` + +**Features:** `dataLabels`, `labelPos`, `labelFont`, `referenceLine`, `chartArea.border`, `plotArea.border`, `axisNumFmt` + +## Inspect the Generated File + +```bash +officecli query charts-stock.xlsx chart +officecli get charts-stock.xlsx "/1-Stock Fundamentals/chart[1]" +``` diff --git a/examples/excel/charts-stock.py b/examples/excel/charts-stock.py new file mode 100644 index 000000000..ea209c005 --- /dev/null +++ b/examples/excel/charts-stock.py @@ -0,0 +1,429 @@ +#!/usr/bin/env python3 +""" +Stock Charts Showcase — OHLC with hi-low lines, up-down bars, and styling. + +Generates: charts-stock.xlsx + +Usage: + python3 charts-stock.py +""" + +import subprocess, sys, os, json, atexit + +FILE = "charts-stock.xlsx" + +def cli(cmd): + """Run: officecli """ + r = subprocess.run(f"officecli {cmd}", shell=True, capture_output=True, text=True) + out = (r.stdout or "").strip() + if out: + for line in out.split("\n"): + if line.strip(): + print(f" {line.strip()}") + if r.returncode != 0: + err = (r.stderr or "").strip() + if err and "UNSUPPORTED" not in err and "process cannot access" not in err: + print(f" ERROR: {err}") + +if os.path.exists(FILE): + os.remove(FILE) + +cli(f'create "{FILE}"') +cli(f'open "{FILE}"') +atexit.register(lambda: cli(f'close "{FILE}"')) + +# ========================================================================== +# Sheet: 1-Stock Fundamentals +# ========================================================================== +print("\n--- 1-Stock Fundamentals ---") +cli(f'add "{FILE}" / --type sheet --prop name="1-Stock Fundamentals"') + +# -------------------------------------------------------------------------- +# Chart 1: Basic OHLC stock chart +# +# officecli add charts-stock.xlsx "/1-Stock Fundamentals" --type chart \ +# --prop chartType=stock \ +# --prop title="ACME Corp Weekly OHLC" \ +# --prop series1="Open:142,145,148,150,147,152" \ +# --prop series2="High:148,151,155,156,153,158" \ +# --prop series3="Low:139,142,145,147,144,149" \ +# --prop series4="Close:145,148,150,147,152,155" \ +# --prop categories=Week 1,Week 2,Week 3,Week 4,Week 5,Week 6 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop catTitle=Week --prop axisTitle=Price ($) \ +# --prop legend=bottom +# +# Features: chartType=stock, 4 series (Open/High/Low/Close), catTitle, axisTitle +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Stock Fundamentals" --type chart' + f' --prop chartType=stock' + f' --prop title="ACME Corp Weekly OHLC"' + f' --prop series1=Open:142,145,148,150,147,152' + f' --prop series2=High:148,151,155,156,153,158' + f' --prop series3=Low:139,142,145,147,144,149' + f' --prop series4=Close:145,148,150,147,152,155' + f' --prop "categories=Week 1,Week 2,Week 3,Week 4,Week 5,Week 6"' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop catTitle=Week --prop "axisTitle=Price ($)"' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 2: Stock with gridlines and axisfont +# +# officecli add charts-stock.xlsx "/1-Stock Fundamentals" --type chart \ +# --prop chartType=stock \ +# --prop title="Tech Sector Daily" \ +# --prop series1="Open:210,215,212,218,220" \ +# --prop series2="High:218,222,219,225,228" \ +# --prop series3="Low:207,211,208,214,216" \ +# --prop series4="Close:215,212,218,220,225" \ +# --prop categories=Mon,Tue,Wed,Thu,Fri \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop gridlines=D9D9D9:0.5 \ +# --prop axisfont=9:666666 \ +# --prop legend=bottom +# +# Features: gridlines, axisfont on stock chart +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Stock Fundamentals" --type chart' + f' --prop chartType=stock' + f' --prop title="Tech Sector Daily"' + f' --prop series1=Open:210,215,212,218,220' + f' --prop series2=High:218,222,219,225,228' + f' --prop series3=Low:207,211,208,214,216' + f' --prop series4=Close:215,212,218,220,225' + f' --prop categories=Mon,Tue,Wed,Thu,Fri' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop gridlines=D9D9D9:0.5' + f' --prop axisfont=9:666666' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: Stock with hiLowLines +# +# officecli add charts-stock.xlsx "/1-Stock Fundamentals" --type chart \ +# --prop chartType=stock \ +# --prop title="Energy Sector with Hi-Low Lines" \ +# --prop series1="Open:78,80,82,79,83,85" \ +# --prop series2="High:84,86,88,85,89,91" \ +# --prop series3="Low:75,77,79,76,80,82" \ +# --prop series4="Close:80,82,79,83,85,88" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop hiLowLines=true \ +# --prop legend=bottom +# +# Features: hiLowLines=true (vertical lines connecting high to low) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Stock Fundamentals" --type chart' + f' --prop chartType=stock' + f' --prop title="Energy Sector with Hi-Low Lines"' + f' --prop series1=Open:78,80,82,79,83,85' + f' --prop series2=High:84,86,88,85,89,91' + f' --prop series3=Low:75,77,79,76,80,82' + f' --prop series4=Close:80,82,79,83,85,88' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop hiLowLines=true' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 4: Stock with updownbars +# +# officecli add charts-stock.xlsx "/1-Stock Fundamentals" --type chart \ +# --prop chartType=stock \ +# --prop title="Pharma Index with Up-Down Bars" \ +# --prop series1="Open:55,58,56,60,62,59" \ +# --prop series2="High:61,63,62,66,68,65" \ +# --prop series3="Low:52,55,53,57,59,56" \ +# --prop series4="Close:58,56,60,62,59,63" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop updownbars=100:70AD47:C00000 \ +# --prop legend=bottom +# +# Features: updownbars=gapWidth:upColor:downColor +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Stock Fundamentals" --type chart' + f' --prop chartType=stock' + f' --prop title="Pharma Index with Up-Down Bars"' + f' --prop series1=Open:55,58,56,60,62,59' + f' --prop series2=High:61,63,62,66,68,65' + f' --prop series3=Low:52,55,53,57,59,56' + f' --prop series4=Close:58,56,60,62,59,63' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop updownbars=100:70AD47:C00000' + f' --prop legend=bottom') + +# ========================================================================== +# Sheet: 2-Stock Styling +# ========================================================================== +print("\n--- 2-Stock Styling ---") +cli(f'add "{FILE}" / --type sheet --prop name="2-Stock Styling"') + +# -------------------------------------------------------------------------- +# Chart 1: Title styling, legend positioning +# +# officecli add charts-stock.xlsx "/2-Stock Styling" --type chart \ +# --prop chartType=stock \ +# --prop title="Styled Stock Chart" \ +# --prop series1="Open:165,170,168,172,175" \ +# --prop series2="High:175,178,176,180,183" \ +# --prop series3="Low:160,165,163,168,170" \ +# --prop series4="Close:170,168,172,175,180" \ +# --prop categories=Mon,Tue,Wed,Thu,Fri \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop title.font=Georgia --prop title.size=16 \ +# --prop title.color=1F4E79 --prop title.bold=true \ +# --prop legend=right --prop legendfont=10:333333:Calibri +# +# Features: title.font/size/color/bold, legend=right, legendfont +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Stock Styling" --type chart' + f' --prop chartType=stock' + f' --prop title="Styled Stock Chart"' + f' --prop series1=Open:165,170,168,172,175' + f' --prop series2=High:175,178,176,180,183' + f' --prop series3=Low:160,165,163,168,170' + f' --prop series4=Close:170,168,172,175,180' + f' --prop categories=Mon,Tue,Wed,Thu,Fri' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop title.font=Georgia --prop title.size=16' + f' --prop title.color=1F4E79 --prop title.bold=true' + f' --prop legend=right --prop legendfont=10:333333:Calibri') + +# -------------------------------------------------------------------------- +# Chart 2: Series effects, axisLine, catAxisLine +# +# officecli add charts-stock.xlsx "/2-Stock Styling" --type chart \ +# --prop chartType=stock \ +# --prop title="Axis Line Styling" \ +# --prop series1="Open:92,95,93,97,99" \ +# --prop series2="High:99,102,100,104,106" \ +# --prop series3="Low:88,91,89,93,95" \ +# --prop series4="Close:95,93,97,99,103" \ +# --prop categories=W1,W2,W3,W4,W5 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop hiLowLines=true \ +# --prop axisLine=333333:1.5 --prop catAxisLine=333333:1.5 \ +# --prop legend=bottom +# +# Features: axisLine, catAxisLine on stock chart +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Stock Styling" --type chart' + f' --prop chartType=stock' + f' --prop title="Axis Line Styling"' + f' --prop series1=Open:92,95,93,97,99' + f' --prop series2=High:99,102,100,104,106' + f' --prop series3=Low:88,91,89,93,95' + f' --prop series4=Close:95,93,97,99,103' + f' --prop categories=W1,W2,W3,W4,W5' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop hiLowLines=true' + f' --prop axisLine=333333:1.5 --prop catAxisLine=333333:1.5' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: axisMin/Max, majorUnit +# +# officecli add charts-stock.xlsx "/2-Stock Styling" --type chart \ +# --prop chartType=stock \ +# --prop title="Custom Axis Range" \ +# --prop series1="Open:120,125,122,128,130" \ +# --prop series2="High:132,138,135,140,142" \ +# --prop series3="Low:115,120,118,124,126" \ +# --prop series4="Close:125,122,128,130,135" \ +# --prop categories=Day 1,Day 2,Day 3,Day 4,Day 5 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop axisMin=110 --prop axisMax=150 \ +# --prop majorUnit=10 \ +# --prop updownbars=100:70AD47:C00000 \ +# --prop legend=bottom +# +# Features: axisMin/Max, majorUnit +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Stock Styling" --type chart' + f' --prop chartType=stock' + f' --prop title="Custom Axis Range"' + f' --prop series1=Open:120,125,122,128,130' + f' --prop series2=High:132,138,135,140,142' + f' --prop series3=Low:115,120,118,124,126' + f' --prop series4=Close:125,122,128,130,135' + f' --prop "categories=Day 1,Day 2,Day 3,Day 4,Day 5"' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop axisMin=110 --prop axisMax=150' + f' --prop majorUnit=10' + f' --prop updownbars=100:70AD47:C00000' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 4: plotFill, chartFill, roundedCorners +# +# officecli add charts-stock.xlsx "/2-Stock Styling" --type chart \ +# --prop chartType=stock \ +# --prop title="Styled Chart Area" \ +# --prop series1="Open:48,50,52,49,53" \ +# --prop series2="High:55,57,59,56,60" \ +# --prop series3="Low:44,46,48,45,49" \ +# --prop series4="Close:50,52,49,53,56" \ +# --prop categories=Mon,Tue,Wed,Thu,Fri \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop plotFill=F0F4F8 --prop chartFill=FAFAFA \ +# --prop roundedCorners=true \ +# --prop hiLowLines=true \ +# --prop legend=bottom +# +# Features: plotFill, chartFill, roundedCorners +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Stock Styling" --type chart' + f' --prop chartType=stock' + f' --prop title="Styled Chart Area"' + f' --prop series1=Open:48,50,52,49,53' + f' --prop series2=High:55,57,59,56,60' + f' --prop series3=Low:44,46,48,45,49' + f' --prop series4=Close:50,52,49,53,56' + f' --prop categories=Mon,Tue,Wed,Thu,Fri' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop plotFill=F0F4F8 --prop chartFill=FAFAFA' + f' --prop roundedCorners=true' + f' --prop hiLowLines=true' + f' --prop legend=bottom') + +# ========================================================================== +# Sheet: 3-Stock Advanced +# ========================================================================== +print("\n--- 3-Stock Advanced ---") +cli(f'add "{FILE}" / --type sheet --prop name="3-Stock Advanced"') + +# -------------------------------------------------------------------------- +# Chart 1: dataLabels, labelFont +# +# officecli add charts-stock.xlsx "/3-Stock Advanced" --type chart \ +# --prop chartType=stock \ +# --prop title="Stock with Data Labels" \ +# --prop series1="Open:185,190,188,192,195" \ +# --prop series2="High:195,198,196,200,203" \ +# --prop series3="Low:180,185,183,188,190" \ +# --prop series4="Close:190,188,192,195,200" \ +# --prop categories=W1,W2,W3,W4,W5 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop dataLabels=true --prop labelPos=top \ +# --prop labelFont=8:666666:false \ +# --prop legend=bottom +# +# Features: dataLabels, labelPos, labelFont on stock +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Stock Advanced" --type chart' + f' --prop chartType=stock' + f' --prop title="Stock with Data Labels"' + f' --prop series1=Open:185,190,188,192,195' + f' --prop series2=High:195,198,196,200,203' + f' --prop series3=Low:180,185,183,188,190' + f' --prop series4=Close:190,188,192,195,200' + f' --prop categories=W1,W2,W3,W4,W5' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop dataLabels=true --prop labelPos=top' + f' --prop labelFont=8:666666:false' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 2: referenceLine (support/resistance) +# +# officecli add charts-stock.xlsx "/3-Stock Advanced" --type chart \ +# --prop chartType=stock \ +# --prop title="Support & Resistance" \ +# --prop series1="Open:105,108,106,110,112,109" \ +# --prop series2="High:112,115,113,117,119,116" \ +# --prop series3="Low:101,104,102,106,108,105" \ +# --prop series4="Close:108,106,110,112,109,113" \ +# --prop categories=Jan,Feb,Mar,Apr,May,Jun \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop referenceLine=115:C00000:Resistance \ +# --prop hiLowLines=true \ +# --prop legend=bottom +# +# Features: referenceLine as support/resistance level +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Stock Advanced" --type chart' + f' --prop chartType=stock' + f' --prop title="Support & Resistance"' + f' --prop series1=Open:105,108,106,110,112,109' + f' --prop series2=High:112,115,113,117,119,116' + f' --prop series3=Low:101,104,102,106,108,105' + f' --prop series4=Close:108,106,110,112,109,113' + f' --prop categories=Jan,Feb,Mar,Apr,May,Jun' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop referenceLine=115:C00000:Resistance' + f' --prop hiLowLines=true' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: chartArea.border, plotArea.border +# +# officecli add charts-stock.xlsx "/3-Stock Advanced" --type chart \ +# --prop chartType=stock \ +# --prop title="Bordered Stock Chart" \ +# --prop series1="Open:72,75,73,77,79" \ +# --prop series2="High:79,82,80,84,86" \ +# --prop series3="Low:68,71,69,73,75" \ +# --prop series4="Close:75,73,77,79,83" \ +# --prop categories=Mon,Tue,Wed,Thu,Fri \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop chartArea.border=333333:1.5 \ +# --prop plotArea.border=999999:0.75 \ +# --prop updownbars=100:70AD47:C00000 \ +# --prop legend=bottom +# +# Features: chartArea.border, plotArea.border +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Stock Advanced" --type chart' + f' --prop chartType=stock' + f' --prop title="Bordered Stock Chart"' + f' --prop series1=Open:72,75,73,77,79' + f' --prop series2=High:79,82,80,84,86' + f' --prop series3=Low:68,71,69,73,75' + f' --prop series4=Close:75,73,77,79,83' + f' --prop categories=Mon,Tue,Wed,Thu,Fri' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop chartArea.border=333333:1.5' + f' --prop plotArea.border=999999:0.75' + f' --prop updownbars=100:70AD47:C00000' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 4: dispUnits, axisNumFmt +# +# officecli add charts-stock.xlsx "/3-Stock Advanced" --type chart \ +# --prop chartType=stock \ +# --prop title="Large Cap Stock" \ +# --prop series1="Open:2850,2900,2880,2920,2950" \ +# --prop series2="High:2950,2980,2960,3000,3020" \ +# --prop series3="Low:2800,2850,2830,2870,2900" \ +# --prop series4="Close:2900,2880,2920,2950,2990" \ +# --prop categories=Q1,Q2,Q3,Q4,Q5 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop axisNumFmt=$#,##0 \ +# --prop hiLowLines=true \ +# --prop legend=bottom +# +# Features: axisNumFmt (dollar format) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Stock Advanced" --type chart' + f' --prop chartType=stock' + f' --prop title="Large Cap Stock"' + f' --prop series1=Open:2850,2900,2880,2920,2950' + f' --prop series2=High:2950,2980,2960,3000,3020' + f' --prop series3=Low:2800,2850,2830,2870,2900' + f' --prop series4=Close:2900,2880,2920,2950,2990' + f' --prop categories=Q1,Q2,Q3,Q4,Q5' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop "axisNumFmt=$#,##0"' + f' --prop hiLowLines=true' + f' --prop legend=bottom') + +# Remove blank default Sheet1 (all data is inline) +cli(f'remove "{FILE}" /Sheet1') + +print(f"\nDone! Generated: {FILE}") +print(" 4 sheets (3 chart sheets, 12 charts total)") diff --git a/examples/excel/charts-stock.xlsx b/examples/excel/charts-stock.xlsx new file mode 100644 index 000000000..2782ffff9 Binary files /dev/null and b/examples/excel/charts-stock.xlsx differ diff --git a/examples/excel/charts-waterfall.md b/examples/excel/charts-waterfall.md new file mode 100644 index 000000000..6434cb488 --- /dev/null +++ b/examples/excel/charts-waterfall.md @@ -0,0 +1,189 @@ +# Waterfall Charts Showcase + +This demo consists of three files that work together: + +- **charts-waterfall.py** — Python script that calls `officecli` commands to generate the workbook. Each chart command is shown as a copyable shell command in the comments. +- **charts-waterfall.xlsx** — The generated workbook with 5 sheets (1 default + 4 chart sheets, 16 charts total). +- **charts-waterfall.md** — This file. Maps each sheet to the features it demonstrates. + +## Regenerate + +```bash +cd examples/excel +python3 charts-waterfall.py +# → charts-waterfall.xlsx +``` + +## Chart Sheets + +### Sheet: 1-Waterfall Fundamentals + +Four waterfall chart variants covering basic P&L, budget analysis, quarterly flow, and title styling. + +```bash +# Basic P&L waterfall with increase/decrease/total colors +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=waterfall \ + --prop data="Start:1000,Revenue:500,Costs:-300,Tax:-100,Net:1100" \ + --prop increaseColor=70AD47 \ + --prop decreaseColor=FF0000 \ + --prop totalColor=4472C4 \ + --prop dataLabels=true + +# Budget waterfall with blue/red/amber theme +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=waterfall \ + --prop data="Budget:5000,Sales:2000,Marketing:-800,Ops:-600,Net:5600" \ + --prop increaseColor=2E75B6 \ + --prop decreaseColor=C00000 \ + --prop totalColor=FFC000 \ + --prop legend=bottom + +# Quarterly cash flow with 10 data points +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=waterfall \ + --prop data="Opening:3000,Q1 Sales:1200,Q1 Costs:-500,...,Closing:6000" \ + --prop increaseColor=70AD47 --prop decreaseColor=ED7D31 --prop totalColor=4472C4 + +# Waterfall with styled title +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=waterfall \ + --prop title.font=Georgia --prop title.size=16 \ + --prop title.color=1F4E79 --prop title.bold=true +``` + +**Features:** `chartType=waterfall`, `data=` name:value pairs (positive=increase, negative=decrease), `increaseColor`, `decreaseColor`, `totalColor`, `dataLabels`, `legend=bottom`, `title.font/size/color/bold` + +### Sheet: 2-Waterfall Styling + +Four waterfall charts demonstrating visual styling options. + +```bash +# Title with font, size, color, bold, and shadow +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=waterfall \ + --prop title.font=Trebuchet MS --prop title.size=18 \ + --prop title.color=833C0B --prop title.bold=true \ + --prop title.shadow=000000-3-315-2-30 + +# Series shadow, plot/chart fills, rounded corners +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=waterfall \ + --prop series.shadow=000000-4-315-2-30 \ + --prop plotFill=F0F0F0 --prop chartFill=FAFAFA \ + --prop roundedCorners=true + +# Gridline color and axis font +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=waterfall \ + --prop gridlineColor=CCCCCC \ + --prop axisfont=10:333333:Calibri + +# Chart area and plot area borders +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=waterfall \ + --prop chartArea.border=4472C4-2 \ + --prop plotArea.border=A5A5A5-1 +``` + +**Features:** `title.shadow`, `series.shadow`, `plotFill`, `chartFill`, `roundedCorners`, `gridlineColor`, `axisfont`, `chartArea.border`, `plotArea.border` + +### Sheet: 3-Waterfall Labels & Axis + +Four waterfall charts demonstrating data labels, axis configuration, and layout control. + +```bash +# Data labels with font and number format +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=waterfall \ + --prop dataLabels=true \ + --prop labelFont=10:333333:true \ + --prop dataLabels.numFmt=#,##0 + +# Custom axis range and tick interval +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=waterfall \ + --prop axisMin=0 --prop axisMax=3500 --prop majorUnit=500 + +# Legend position and font +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=waterfall \ + --prop legend=right \ + --prop legendfont=10:1F4E79:Helvetica + +# Manual plot area layout +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=waterfall \ + --prop plotArea.x=0.15 --prop plotArea.y=0.15 \ + --prop plotArea.w=0.75 --prop plotArea.h=0.70 +``` + +**Features:** `dataLabels`, `labelFont`, `dataLabels.numFmt`, `axisMin`, `axisMax`, `majorUnit`, `legend=right`, `legendfont`, `plotArea.x/y/w/h` + +### Sheet: 4-Waterfall Advanced + +Four waterfall charts demonstrating advanced features and large datasets. + +```bash +# Reference line overlay +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=waterfall \ + --prop referenceLine=2000:Target-FF0000-dash-2 + +# Value axis and category axis line styling +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=waterfall \ + --prop axisLine=333333-2 \ + --prop catAxisLine=333333-2 + +# Title glow and shadow effects +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=waterfall \ + --prop title.glow=4472C4-8 \ + --prop title.shadow=000000-3-315-2-30 + +# Large dataset (12 categories) with small axis font +officecli add data.xlsx /Sheet --type chart \ + --prop chartType=waterfall \ + --prop data="Revenue:8500,COGS:-3400,...,Net Income:1050" \ + --prop dataLabels=true \ + --prop axisfont=8:333333:Calibri +``` + +**Features:** `referenceLine`, `axisLine`, `catAxisLine`, `title.glow`, `title.shadow`, large dataset (12 categories) + +## Property Coverage + +| Property | Sheet | +|---|---| +| `chartType=waterfall` | 1, 2, 3, 4 | +| `data=` (name:value pairs) | 1, 2, 3, 4 | +| `increaseColor` | 1, 2, 3, 4 | +| `decreaseColor` | 1, 2, 3, 4 | +| `totalColor` | 1, 2, 3, 4 | +| `dataLabels` | 1, 3, 4 | +| `legend` | 1, 3 | +| `title.font/size/color/bold` | 1, 2 | +| `title.shadow` | 2, 4 | +| `title.glow` | 4 | +| `series.shadow` | 2 | +| `plotFill`, `chartFill` | 2 | +| `roundedCorners` | 2 | +| `gridlineColor` | 2 | +| `axisfont` | 2, 4 | +| `chartArea.border` | 2 | +| `plotArea.border` | 2 | +| `labelFont` | 3 | +| `dataLabels.numFmt` | 3 | +| `axisMin/Max`, `majorUnit` | 3 | +| `legendfont` | 3 | +| `plotArea.x/y/w/h` | 3 | +| `referenceLine` | 4 | +| `axisLine`, `catAxisLine` | 4 | + +## Inspect the Generated File + +```bash +officecli query charts-waterfall.xlsx chart +officecli get charts-waterfall.xlsx "/1-Waterfall Fundamentals/chart[1]" +``` diff --git a/examples/excel/charts-waterfall.py b/examples/excel/charts-waterfall.py new file mode 100644 index 000000000..db74e765c --- /dev/null +++ b/examples/excel/charts-waterfall.py @@ -0,0 +1,502 @@ +#!/usr/bin/env python3 +""" +Waterfall Charts Showcase — waterfall chart type with all variations. + +Generates: charts-waterfall.xlsx + +Usage: + python3 charts-waterfall.py +""" + +import subprocess, sys, os, atexit + +FILE = "charts-waterfall.xlsx" + +def cli(cmd): + """Run: officecli """ + r = subprocess.run(f"officecli {cmd}", shell=True, capture_output=True, text=True) + out = (r.stdout or "").strip() + if out: + for line in out.split("\n"): + if line.strip(): + print(f" {line.strip()}") + if r.returncode != 0: + err = (r.stderr or "").strip() + if err and "UNSUPPORTED" not in err and "process cannot access" not in err: + print(f" ERROR: {err}") + +if os.path.exists(FILE): + os.remove(FILE) + +cli(f'create "{FILE}"') +cli(f'open "{FILE}"') +atexit.register(lambda: cli(f'close "{FILE}"')) + +# ========================================================================== +# Sheet: 1-Waterfall Fundamentals +# ========================================================================== +print("\n--- 1-Waterfall Fundamentals ---") +cli(f'add "{FILE}" / --type sheet --prop name="1-Waterfall Fundamentals"') + +# -------------------------------------------------------------------------- +# Chart 1: Basic P&L waterfall with increase/decrease/total colors +# +# officecli add charts-waterfall.xlsx "/1-Waterfall Fundamentals" --type chart \ +# --prop chartType=waterfall \ +# --prop title="P&L Summary" \ +# --prop data="Start:1000,Revenue:500,Costs:-300,Tax:-100,Net:1100" \ +# --prop increaseColor=70AD47 \ +# --prop decreaseColor=FF0000 \ +# --prop totalColor=4472C4 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop dataLabels=true +# +# Features: chartType=waterfall, data= name:value pairs, increaseColor, +# decreaseColor, totalColor, dataLabels +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Waterfall Fundamentals" --type chart' + f' --prop chartType=waterfall' + f' --prop title="P&L Summary"' + f' --prop data=Start:1000,Revenue:500,Costs:-300,Tax:-100,Net:1100' + f' --prop increaseColor=70AD47' + f' --prop decreaseColor=FF0000' + f' --prop totalColor=4472C4' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop dataLabels=true') + +# -------------------------------------------------------------------------- +# Chart 2: Budget waterfall with blue/red/amber theme and legend +# +# officecli add charts-waterfall.xlsx "/1-Waterfall Fundamentals" --type chart \ +# --prop chartType=waterfall \ +# --prop title="Budget vs Actual" \ +# --prop data="Budget:5000,Sales:2000,Marketing:-800,Ops:-600,Net:5600" \ +# --prop increaseColor=2E75B6 \ +# --prop decreaseColor=C00000 \ +# --prop totalColor=FFC000 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop legend=bottom +# +# Features: waterfall legend=bottom, alternative color palette (blue/red/amber) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Waterfall Fundamentals" --type chart' + f' --prop chartType=waterfall' + f' --prop title="Budget vs Actual"' + f' --prop data=Budget:5000,Sales:2000,Marketing:-800,Ops:-600,Net:5600' + f' --prop increaseColor=2E75B6' + f' --prop decreaseColor=C00000' + f' --prop totalColor=FFC000' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop legend=bottom') + +# -------------------------------------------------------------------------- +# Chart 3: Quarterly cash flow bridge with more data points +# +# officecli add charts-waterfall.xlsx "/1-Waterfall Fundamentals" --type chart \ +# --prop chartType=waterfall \ +# --prop title="Quarterly Cash Flow" \ +# --prop data="Opening:3000,Q1 Sales:1200,Q1 Costs:-500,Q2 Sales:1500,Q2 Costs:-700,Q3 Sales:800,Q3 Costs:-400,Q4 Sales:2000,Q4 Costs:-900,Closing:6000" \ +# --prop increaseColor=70AD47 \ +# --prop decreaseColor=ED7D31 \ +# --prop totalColor=4472C4 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop dataLabels=true +# +# Features: waterfall with 10 categories (extended data points), +# quarterly granularity +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Waterfall Fundamentals" --type chart' + f' --prop chartType=waterfall' + f' --prop title="Quarterly Cash Flow"' + f' --prop "data=Opening:3000,Q1 Sales:1200,Q1 Costs:-500,Q2 Sales:1500,Q2 Costs:-700,Q3 Sales:800,Q3 Costs:-400,Q4 Sales:2000,Q4 Costs:-900,Closing:6000"' + f' --prop increaseColor=70AD47' + f' --prop decreaseColor=ED7D31' + f' --prop totalColor=4472C4' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop dataLabels=true') + +# -------------------------------------------------------------------------- +# Chart 4: Waterfall with custom title styling +# +# officecli add charts-waterfall.xlsx "/1-Waterfall Fundamentals" --type chart \ +# --prop chartType=waterfall \ +# --prop title="Revenue Bridge" \ +# --prop data="Base:2500,New Clients:800,Upsell:400,Churn:-600,Total:3100" \ +# --prop increaseColor=548235 \ +# --prop decreaseColor=BF0000 \ +# --prop totalColor=2F5496 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop title.font=Georgia --prop title.size=16 \ +# --prop title.color=1F4E79 --prop title.bold=true +# +# Features: title.font, title.size, title.color, title.bold +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/1-Waterfall Fundamentals" --type chart' + f' --prop chartType=waterfall' + f' --prop title="Revenue Bridge"' + f' --prop "data=Base:2500,New Clients:800,Upsell:400,Churn:-600,Total:3100"' + f' --prop increaseColor=548235' + f' --prop decreaseColor=BF0000' + f' --prop totalColor=2F5496' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop title.font=Georgia --prop title.size=16' + f' --prop title.color=1F4E79 --prop title.bold=true') + +# ========================================================================== +# Sheet: 2-Waterfall Styling +# ========================================================================== +print("\n--- 2-Waterfall Styling ---") +cli(f'add "{FILE}" / --type sheet --prop name="2-Waterfall Styling"') + +# -------------------------------------------------------------------------- +# Chart 1: Title styling with font, size, color, bold, and shadow +# +# officecli add charts-waterfall.xlsx "/2-Waterfall Styling" --type chart \ +# --prop chartType=waterfall \ +# --prop title="Styled Title Demo" \ +# --prop data="Start:800,Income:300,Expenses:-200,Net:900" \ +# --prop increaseColor=70AD47 \ +# --prop decreaseColor=FF0000 \ +# --prop totalColor=4472C4 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop title.font=Trebuchet MS --prop title.size=18 \ +# --prop title.color=833C0B --prop title.bold=true \ +# --prop title.shadow=000000-3-315-2-30 +# +# Features: title.font, title.size, title.color, title.bold, title.shadow +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Waterfall Styling" --type chart' + f' --prop chartType=waterfall' + f' --prop title="Styled Title Demo"' + f' --prop data=Start:800,Income:300,Expenses:-200,Net:900' + f' --prop increaseColor=70AD47' + f' --prop decreaseColor=FF0000' + f' --prop totalColor=4472C4' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop "title.font=Trebuchet MS" --prop title.size=18' + f' --prop title.color=833C0B --prop title.bold=true' + f' --prop title.shadow=000000-3-315-2-30') + +# -------------------------------------------------------------------------- +# Chart 2: Series shadow, plotFill, chartFill, roundedCorners +# +# officecli add charts-waterfall.xlsx "/2-Waterfall Styling" --type chart \ +# --prop chartType=waterfall \ +# --prop title="Shadow & Fill Effects" \ +# --prop data="Baseline:1500,Growth:600,Decline:-400,Result:1700" \ +# --prop increaseColor=2E75B6 \ +# --prop decreaseColor=C00000 \ +# --prop totalColor=FFC000 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop series.shadow=000000-4-315-2-30 \ +# --prop plotFill=F0F0F0 \ +# --prop chartFill=FAFAFA \ +# --prop roundedCorners=true +# +# Features: series.shadow, plotFill, chartFill, roundedCorners +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Waterfall Styling" --type chart' + f' --prop chartType=waterfall' + f' --prop title="Shadow & Fill Effects"' + f' --prop data=Baseline:1500,Growth:600,Decline:-400,Result:1700' + f' --prop increaseColor=2E75B6' + f' --prop decreaseColor=C00000' + f' --prop totalColor=FFC000' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop series.shadow=000000-4-315-2-30' + f' --prop plotFill=F0F0F0' + f' --prop chartFill=FAFAFA' + f' --prop roundedCorners=true') + +# -------------------------------------------------------------------------- +# Chart 3: Gridlines styling and axis font +# +# officecli add charts-waterfall.xlsx "/2-Waterfall Styling" --type chart \ +# --prop chartType=waterfall \ +# --prop title="Gridlines & Axis Font" \ +# --prop data="Open:2000,Add:750,Remove:-350,Close:2400" \ +# --prop increaseColor=70AD47 \ +# --prop decreaseColor=FF0000 \ +# --prop totalColor=4472C4 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop gridlineColor=CCCCCC \ +# --prop axisfont=10:333333:Calibri +# +# Features: gridlineColor, axisfont (size:color:font) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Waterfall Styling" --type chart' + f' --prop chartType=waterfall' + f' --prop title="Gridlines & Axis Font"' + f' --prop data=Open:2000,Add:750,Remove:-350,Close:2400' + f' --prop increaseColor=70AD47' + f' --prop decreaseColor=FF0000' + f' --prop totalColor=4472C4' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop gridlineColor=CCCCCC' + f' --prop axisfont=10:333333:Calibri') + +# -------------------------------------------------------------------------- +# Chart 4: Chart area border and plot area border +# +# officecli add charts-waterfall.xlsx "/2-Waterfall Styling" --type chart \ +# --prop chartType=waterfall \ +# --prop title="Border Styling" \ +# --prop data="Initial:1200,Gain:500,Loss:-300,Final:1400" \ +# --prop increaseColor=548235 \ +# --prop decreaseColor=BF0000 \ +# --prop totalColor=2F5496 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop chartArea.border=4472C4:2 \ +# --prop plotArea.border=A5A5A5:1 +# +# Features: chartArea.border (color-width), plotArea.border +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/2-Waterfall Styling" --type chart' + f' --prop chartType=waterfall' + f' --prop title="Border Styling"' + f' --prop data=Initial:1200,Gain:500,Loss:-300,Final:1400' + f' --prop increaseColor=548235' + f' --prop decreaseColor=BF0000' + f' --prop totalColor=2F5496' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop chartArea.border=4472C4:2' + f' --prop plotArea.border=A5A5A5:1') + +# ========================================================================== +# Sheet: 3-Waterfall Labels & Axis +# ========================================================================== +print("\n--- 3-Waterfall Labels & Axis ---") +cli(f'add "{FILE}" / --type sheet --prop name="3-Waterfall Labels & Axis"') + +# -------------------------------------------------------------------------- +# Chart 1: Data labels with labelFont and numFmt +# +# officecli add charts-waterfall.xlsx "/3-Waterfall Labels & Axis" --type chart \ +# --prop chartType=waterfall \ +# --prop title="Labels with NumFmt" \ +# --prop data="Start:4500,Revenue:1800,COGS:-1200,SGA:-600,Net:4500" \ +# --prop increaseColor=70AD47 \ +# --prop decreaseColor=FF0000 \ +# --prop totalColor=4472C4 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop dataLabels=true \ +# --prop labelFont=10:333333:true \ +# --prop dataLabels.numFmt=#,##0 +# +# Features: dataLabels, labelFont (size:color:bold), dataLabels.numFmt +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Waterfall Labels & Axis" --type chart' + f' --prop chartType=waterfall' + f' --prop title="Labels with NumFmt"' + f' --prop data=Start:4500,Revenue:1800,COGS:-1200,SGA:-600,Net:4500' + f' --prop increaseColor=70AD47' + f' --prop decreaseColor=FF0000' + f' --prop totalColor=4472C4' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop dataLabels=true' + f' --prop labelFont=10:333333:true' + f' --prop dataLabels.numFmt=#,##0') + +# -------------------------------------------------------------------------- +# Chart 2: Axis min/max and majorUnit +# +# officecli add charts-waterfall.xlsx "/3-Waterfall Labels & Axis" --type chart \ +# --prop chartType=waterfall \ +# --prop title="Custom Axis Range" \ +# --prop data="Base:2000,Up:800,Down:-500,Total:2300" \ +# --prop increaseColor=2E75B6 \ +# --prop decreaseColor=C00000 \ +# --prop totalColor=FFC000 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop axisMin=0 --prop axisMax=3500 --prop majorUnit=500 +# +# Features: axisMin, axisMax, majorUnit +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Waterfall Labels & Axis" --type chart' + f' --prop chartType=waterfall' + f' --prop title="Custom Axis Range"' + f' --prop data=Base:2000,Up:800,Down:-500,Total:2300' + f' --prop increaseColor=2E75B6' + f' --prop decreaseColor=C00000' + f' --prop totalColor=FFC000' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop axisMin=0 --prop axisMax=3500 --prop majorUnit=500') + +# -------------------------------------------------------------------------- +# Chart 3: Legend positioning and legendfont +# +# officecli add charts-waterfall.xlsx "/3-Waterfall Labels & Axis" --type chart \ +# --prop chartType=waterfall \ +# --prop title="Legend Styling" \ +# --prop data="Begin:3000,Earned:1100,Spent:-700,End:3400" \ +# --prop increaseColor=70AD47 \ +# --prop decreaseColor=FF0000 \ +# --prop totalColor=4472C4 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop legend=right \ +# --prop legendfont=10:1F4E79:Helvetica +# +# Features: legend=right, legendfont (size:color:font) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Waterfall Labels & Axis" --type chart' + f' --prop chartType=waterfall' + f' --prop title="Legend Styling"' + f' --prop data=Begin:3000,Earned:1100,Spent:-700,End:3400' + f' --prop increaseColor=70AD47' + f' --prop decreaseColor=FF0000' + f' --prop totalColor=4472C4' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop legend=right' + f' --prop legendfont=10:1F4E79:Helvetica') + +# -------------------------------------------------------------------------- +# Chart 4: Manual layout with plotArea.x/y/w/h +# +# officecli add charts-waterfall.xlsx "/3-Waterfall Labels & Axis" --type chart \ +# --prop chartType=waterfall \ +# --prop title="Manual Plot Layout" \ +# --prop data="Start:1800,Add:600,Sub:-400,End:2000" \ +# --prop increaseColor=548235 \ +# --prop decreaseColor=BF0000 \ +# --prop totalColor=2F5496 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop plotArea.x=0.15 --prop plotArea.y=0.15 \ +# --prop plotArea.w=0.75 --prop plotArea.h=0.70 +# +# Features: plotArea.x/y/w/h (manual layout, fractional coordinates) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/3-Waterfall Labels & Axis" --type chart' + f' --prop chartType=waterfall' + f' --prop title="Manual Plot Layout"' + f' --prop data=Start:1800,Add:600,Sub:-400,End:2000' + f' --prop increaseColor=548235' + f' --prop decreaseColor=BF0000' + f' --prop totalColor=2F5496' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop plotArea.x=0.15 --prop plotArea.y=0.15' + f' --prop plotArea.w=0.75 --prop plotArea.h=0.70') + +# ========================================================================== +# Sheet: 4-Waterfall Advanced +# ========================================================================== +print("\n--- 4-Waterfall Advanced ---") +cli(f'add "{FILE}" / --type sheet --prop name="4-Waterfall Advanced"') + +# -------------------------------------------------------------------------- +# Chart 1: Waterfall with referenceLine +# +# officecli add charts-waterfall.xlsx "/4-Waterfall Advanced" --type chart \ +# --prop chartType=waterfall \ +# --prop title="Reference Line" \ +# --prop data="Start:2000,Revenue:900,Refunds:-300,Fees:-200,Net:2400" \ +# --prop increaseColor=70AD47 \ +# --prop decreaseColor=FF0000 \ +# --prop totalColor=4472C4 \ +# --prop x=0 --prop y=0 --prop width=12 --prop height=18 \ +# --prop referenceLine=2000:FF0000:Target:dash +# +# Features: referenceLine (value:label-color-dash-width) +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Waterfall Advanced" --type chart' + f' --prop chartType=waterfall' + f' --prop title="Reference Line"' + f' --prop data=Start:2000,Revenue:900,Refunds:-300,Fees:-200,Net:2400' + f' --prop increaseColor=70AD47' + f' --prop decreaseColor=FF0000' + f' --prop totalColor=4472C4' + f' --prop x=0 --prop y=0 --prop width=12 --prop height=18' + f' --prop referenceLine=2000:FF0000:Target:dash') + +# -------------------------------------------------------------------------- +# Chart 2: Axis line and category axis line styling +# +# officecli add charts-waterfall.xlsx "/4-Waterfall Advanced" --type chart \ +# --prop chartType=waterfall \ +# --prop title="Axis Line Styling" \ +# --prop data="Open:1500,Deposit:700,Withdraw:-400,Close:1800" \ +# --prop increaseColor=2E75B6 \ +# --prop decreaseColor=C00000 \ +# --prop totalColor=FFC000 \ +# --prop x=13 --prop y=0 --prop width=12 --prop height=18 \ +# --prop axisLine=333333:2 \ +# --prop catAxisLine=333333:2 +# +# Features: axisLine (color-width), catAxisLine +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Waterfall Advanced" --type chart' + f' --prop chartType=waterfall' + f' --prop title="Axis Line Styling"' + f' --prop data=Open:1500,Deposit:700,Withdraw:-400,Close:1800' + f' --prop increaseColor=2E75B6' + f' --prop decreaseColor=C00000' + f' --prop totalColor=FFC000' + f' --prop x=13 --prop y=0 --prop width=12 --prop height=18' + f' --prop axisLine=333333:2' + f' --prop catAxisLine=333333:2') + +# -------------------------------------------------------------------------- +# Chart 3: Title glow and shadow effects +# +# officecli add charts-waterfall.xlsx "/4-Waterfall Advanced" --type chart \ +# --prop chartType=waterfall \ +# --prop title="Glow & Shadow Effects" \ +# --prop data="Base:3000,Inflow:1200,Outflow:-800,Balance:3400" \ +# --prop increaseColor=70AD47 \ +# --prop decreaseColor=FF0000 \ +# --prop totalColor=4472C4 \ +# --prop x=0 --prop y=19 --prop width=12 --prop height=18 \ +# --prop title.glow=4472C4-8 \ +# --prop title.shadow=000000-3-315-2-30 \ +# --prop title.size=16 --prop title.bold=true +# +# Features: title.glow (color-radius), title.shadow +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Waterfall Advanced" --type chart' + f' --prop chartType=waterfall' + f' --prop title="Glow & Shadow Effects"' + f' --prop data=Base:3000,Inflow:1200,Outflow:-800,Balance:3400' + f' --prop increaseColor=70AD47' + f' --prop decreaseColor=FF0000' + f' --prop totalColor=4472C4' + f' --prop x=0 --prop y=19 --prop width=12 --prop height=18' + f' --prop title.glow=4472C4-8' + f' --prop title.shadow=000000-3-315-2-30' + f' --prop title.size=16 --prop title.bold=true') + +# -------------------------------------------------------------------------- +# Chart 4: Large dataset waterfall (8+ categories) +# +# officecli add charts-waterfall.xlsx "/4-Waterfall Advanced" --type chart \ +# --prop chartType=waterfall \ +# --prop title="Annual P&L Detail" \ +# --prop data="Revenue:8500,COGS:-3400,Gross Profit:5100,R&D:-1200,Sales:-900,Marketing:-600,G&A:-500,EBITDA:1900,Depreciation:-300,Interest:-200,Tax:-350,Net Income:1050" \ +# --prop increaseColor=548235 \ +# --prop decreaseColor=C00000 \ +# --prop totalColor=2F5496 \ +# --prop x=13 --prop y=19 --prop width=12 --prop height=18 \ +# --prop dataLabels=true \ +# --prop axisfont=8:333333:Calibri +# +# Features: large dataset (12 categories), axisfont with smaller size +# for readability +# -------------------------------------------------------------------------- +cli(f'add "{FILE}" "/4-Waterfall Advanced" --type chart' + f' --prop chartType=waterfall' + f' --prop title="Annual P&L Detail"' + f' --prop "data=Revenue:8500,COGS:-3400,Gross Profit:5100,R&D:-1200,Sales:-900,Marketing:-600,G&A:-500,EBITDA:1900,Depreciation:-300,Interest:-200,Tax:-350,Net Income:1050"' + f' --prop increaseColor=548235' + f' --prop decreaseColor=C00000' + f' --prop totalColor=2F5496' + f' --prop x=13 --prop y=19 --prop width=12 --prop height=18' + f' --prop dataLabels=true' + f' --prop axisfont=8:333333:Calibri') + +# Remove blank default Sheet1 (all data is inline) +cli(f'remove "{FILE}" /Sheet1') + +print(f"\nDone! Generated: {FILE}") +print(" 4 sheets (16 charts total)") +print(" Sheet 1: Waterfall Fundamentals (4 charts)") +print(" Sheet 2: Waterfall Styling (4 charts)") +print(" Sheet 3: Waterfall Labels & Axis (4 charts)") +print(" Sheet 4: Waterfall Advanced (4 charts)") diff --git a/examples/excel/charts-waterfall.xlsx b/examples/excel/charts-waterfall.xlsx new file mode 100644 index 000000000..26a2288d5 Binary files /dev/null and b/examples/excel/charts-waterfall.xlsx differ diff --git a/examples/excel/charts.md b/examples/excel/charts.md new file mode 100644 index 000000000..67fd7295b --- /dev/null +++ b/examples/excel/charts.md @@ -0,0 +1,6 @@ +# charts + +TODO: rewrite script with high-level chart API, add annotated officecli commands. + +See [charts.sh](charts.sh) and [charts.xlsx](charts.xlsx). + diff --git a/examples/excel/gen-beautiful-charts.sh b/examples/excel/charts.sh similarity index 100% rename from examples/excel/gen-beautiful-charts.sh rename to examples/excel/charts.sh diff --git a/examples/excel/outputs/beautiful_charts.xlsx b/examples/excel/charts.xlsx similarity index 100% rename from examples/excel/outputs/beautiful_charts.xlsx rename to examples/excel/charts.xlsx diff --git a/examples/excel/outputs/sales_report.xlsx b/examples/excel/outputs/sales_report.xlsx deleted file mode 100644 index d7768ccb2..000000000 Binary files a/examples/excel/outputs/sales_report.xlsx and /dev/null differ diff --git a/examples/excel/pivot-tables.md b/examples/excel/pivot-tables.md new file mode 100644 index 000000000..854d67962 --- /dev/null +++ b/examples/excel/pivot-tables.md @@ -0,0 +1,249 @@ +# Pivot Table Showcase + +This demo consists of three files that work together: + +- **pivot-tables.py** — Python script that calls `officecli` commands to generate the workbook. Each pivot table command is shown as a copyable shell command in the comments, then executed by the script. Read this to learn the exact `officecli add --type pivottable --prop ...` syntax. +- **pivot-tables.xlsx** — The generated workbook with 13 sheets. Open in Excel to see the rendered pivot tables. Use `officecli get` or `officecli query` to inspect programmatically. +- **pivot-tables.md** — This file. Maps each sheet in the xlsx to the feature it demonstrates and the command that created it. + +## Regenerate + +```bash +cd examples/excel +python3 pivot-tables.py +# → pivot-tables.xlsx +``` + +## Source Data + +| Sheet | Rows | Columns | Purpose | +|-------|------|---------|---------| +| Sheet1 | 50 | Region, Category, Product, Quarter, Sales, Quantity, Cost, Channel, Priority, Date | English sales data spanning 2024-2025 | +| CNData | 12 | 地区, 品类, 销售额 | Chinese sales data for locale sort demo | + +## Pivot Tables + +### Sheet: 1-Sales Overview + +The most feature-rich pivot. Tabular layout with 2-level row hierarchy crossed against quarterly columns. Three value fields where Cost is shown as percentage of row total. Dual page filters let users slice by Channel and Priority. Outer row labels repeat on every row. + +```bash +officecli add pivot-tables.xlsx "/1-Sales Overview" --type pivottable \ + --prop source=Sheet1!A1:J51 \ + --prop rows=Region,Category \ + --prop cols=Quarter \ + --prop 'values=Sales:sum,Quantity:sum,Cost:sum:percent_of_row' \ + --prop 'filters=Channel,Priority' \ + --prop layout=tabular \ + --prop repeatlabels=true \ + --prop grandtotals=both \ + --prop subtotals=on \ + --prop sort=desc \ + --prop style=PivotStyleDark2 +``` + +**Features:** `layout=tabular`, `repeatlabels=true`, dual `filters`, `values` with `percent_of_row`, `sort=desc` + +### Sheet: 2-Market Share + +Each region's share within each category, shown as column percentages. Outline layout provides expand/collapse grouping. + +```bash +officecli add pivot-tables.xlsx "/2-Market Share" --type pivottable \ + --prop source=Sheet1!A1:J51 \ + --prop rows=Region \ + --prop cols=Category \ + --prop 'values=Sales:sum:percent_of_col' \ + --prop filters=Channel \ + --prop layout=outline \ + --prop grandtotals=both \ + --prop style=PivotStyleMedium4 +``` + +**Features:** `layout=outline`, `values` with `percent_of_col` + +### Sheet: 3-Product Deep Dive + +Five value fields with three different aggregation functions on the same source column (Sales:sum, Sales:average, Sales:max). No column axis — values become column headers automatically. + +```bash +officecli add pivot-tables.xlsx "/3-Product Deep Dive" --type pivottable \ + --prop source=Sheet1!A1:J51 \ + --prop rows=Category,Product \ + --prop 'values=Sales:sum,Sales:average,Sales:max,Quantity:sum,Cost:sum' \ + --prop filters=Region \ + --prop layout=tabular \ + --prop grandtotals=rows \ + --prop subtotals=on \ + --prop sort=desc \ + --prop style=PivotStyleMedium9 +``` + +**Features:** 5 `values` fields, no `cols` (synthetic Values axis), `grandtotals=rows` + +### Sheet: 4-Channel Analysis + +Sales shown as percentage of the grand total — reveals each channel's global share across quarters. No page filters. + +```bash +officecli add pivot-tables.xlsx "/4-Channel Analysis" --type pivottable \ + --prop source=Sheet1!A1:J51 \ + --prop rows=Channel \ + --prop cols=Quarter \ + --prop 'values=Sales:sum:percent_of_total,Quantity:sum' \ + --prop layout=outline \ + --prop grandtotals=both \ + --prop style=PivotStyleLight21 +``` + +**Features:** `values` with `percent_of_total`, no `filters` + +### Sheet: 5-Priority Matrix + +Blank rows inserted after each outer group (Priority) for visual separation. Ascending sort puts High first. + +```bash +officecli add pivot-tables.xlsx "/5-Priority Matrix" --type pivottable \ + --prop source=Sheet1!A1:J51 \ + --prop rows=Priority,Region \ + --prop cols=Category \ + --prop 'values=Sales:sum,Cost:sum:percent_of_row' \ + --prop filters=Channel \ + --prop layout=tabular \ + --prop blankrows=true \ + --prop grandtotals=both \ + --prop subtotals=on \ + --prop sort=asc \ + --prop style=PivotStyleDark6 +``` + +**Features:** `blankrows=true`, `sort=asc` + +### Sheet: 6-Compact 3-Level + +Three-level row hierarchy (Region > Category > Product) in compact layout — all labels share one column with progressive indentation. + +```bash +officecli add pivot-tables.xlsx "/6-Compact 3-Level" --type pivottable \ + --prop source=Sheet1!A1:J51 \ + --prop rows=Region,Category,Product \ + --prop 'values=Sales:sum,Quantity:sum' \ + --prop filters=Priority \ + --prop layout=compact \ + --prop grandtotals=both \ + --prop subtotals=on \ + --prop sort=desc \ + --prop style=PivotStyleMedium14 +``` + +**Features:** `layout=compact`, 3-level `rows` + +### Sheet: 7-No Subtotals + +Flat tabular view with subtotals disabled. Only the bottom grand total row remains. Outer labels are repeated on every row since there are no subtotal rows to carry them. + +```bash +officecli add pivot-tables.xlsx "/7-No Subtotals" --type pivottable \ + --prop source=Sheet1!A1:J51 \ + --prop rows=Region,Category \ + --prop cols=Quarter \ + --prop values=Sales:sum \ + --prop layout=tabular \ + --prop repeatlabels=true \ + --prop grandtotals=cols \ + --prop subtotals=off \ + --prop sort=asc \ + --prop style=PivotStyleLight1 +``` + +**Features:** `subtotals=off`, `grandtotals=cols`, `repeatlabels=true` + +### Sheet: 8-Date Grouping + +Automatic date grouping from a date column. `Date:year` creates year buckets ("2024", "2025"), `Date:quarter` creates quarter sub-buckets ("2024-Q1", ...). Uses native Excel fieldGroup XML. + +```bash +officecli add pivot-tables.xlsx "/8-Date Grouping" --type pivottable \ + --prop source=Sheet1!A1:J51 \ + --prop 'rows=Date:year,Date:quarter' \ + --prop 'values=Sales:sum,Cost:sum' \ + --prop filters=Region \ + --prop layout=outline \ + --prop grandtotals=both \ + --prop subtotals=on \ + --prop style=PivotStyleMedium7 +``` + +**Features:** `rows` with `Date:year,Date:quarter` date grouping syntax + +### Sheet: 9-Top 5 Products + +Only the top 5 products by sales are shown. Grand totals are hidden entirely. + +```bash +officecli add pivot-tables.xlsx "/9-Top 5 Products" --type pivottable \ + --prop source=Sheet1!A1:J51 \ + --prop rows=Product \ + --prop 'values=Sales:sum,Quantity:sum,Cost:sum' \ + --prop layout=tabular \ + --prop grandtotals=none \ + --prop topN=5 \ + --prop sort=desc \ + --prop style=PivotStyleDark1 +``` + +**Features:** `topN=5`, `grandtotals=none` + +### Sheet: 10-Ultimate + +Every feature combined in one pivot table — the kitchen sink. + +```bash +officecli add pivot-tables.xlsx "/10-Ultimate" --type pivottable \ + --prop source=Sheet1!A1:J51 \ + --prop rows=Region,Category \ + --prop cols=Quarter \ + --prop 'values=Sales:sum,Quantity:average,Cost:sum:percent_of_row' \ + --prop 'filters=Channel,Priority' \ + --prop layout=tabular \ + --prop repeatlabels=true \ + --prop blankrows=true \ + --prop grandtotals=rows \ + --prop subtotals=on \ + --prop sort=desc \ + --prop style=PivotStyleDark11 +``` + +**Features:** `repeatlabels=true` + `blankrows=true` + dual `filters` + mixed aggregations + `grandtotals=rows` + +### Sheet: 11-Chinese Locale + +Chinese data with pinyin-order sorting and a custom grand total label. Demonstrates that field names, filter values, and captions all work with non-ASCII text. + +```bash +officecli add pivot-tables.xlsx "/11-Chinese Locale" --type pivottable \ + --prop source=CNData!A1:C13 \ + --prop rows=地区,品类 \ + --prop values=销售额:sum \ + --prop layout=tabular \ + --prop grandtotals=both \ + --prop subtotals=on \ + --prop sort=locale \ + --prop grandTotalCaption=合计 \ + --prop style=PivotStyleMedium2 +``` + +**Features:** `sort=locale` (pinyin: 华北 < 华东 < 华南 < 西南), `grandTotalCaption` + +## Inspect the Generated File + +```bash +# List all pivot tables +officecli query pivot-tables.xlsx pivottable + +# Get details of a specific pivot +officecli get pivot-tables.xlsx "/1-Sales Overview/pivottable[1]" + +# View rendered data as text +officecli view pivot-tables.xlsx text --sheet "1-Sales Overview" +``` diff --git a/examples/excel/pivot-tables.py b/examples/excel/pivot-tables.py new file mode 100644 index 000000000..485f6c5bd --- /dev/null +++ b/examples/excel/pivot-tables.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python3 +""" +Pivot Table Showcase — generates pivot-tables.xlsx with 11 pivot tables. + +Each pivot table demonstrates different officecli features. +See pivot-tables.md for a guide to each sheet in the generated file. + +Usage: + python3 pivot-tables.py +""" + +import subprocess, sys, os, json, atexit + +FILE = "pivot-tables.xlsx" + +def cli(cmd): + """Run: officecli """ + r = subprocess.run(f"officecli {cmd}", shell=True, capture_output=True, text=True) + out = (r.stdout or "").strip() + if out: + for line in out.split("\n"): + if line.strip(): + print(f" {line.strip()}") + if r.returncode != 0: + err = (r.stderr or "").strip() + if err and "UNSUPPORTED" not in err and "process cannot access" not in err: + print(f" ERROR: {err}") + +if os.path.exists(FILE): + os.remove(FILE) + +cli(f'create "{FILE}"') +cli(f'open "{FILE}"') +atexit.register(lambda: cli(f'close "{FILE}"')) + +# ========================================================================== +# Source data — batch is used here only for speed (500+ cell writes). +# ========================================================================== +print("\n--- Populating source data ---") + +data_cmds = [] +for j, h in enumerate(["Region","Category","Product","Quarter","Sales","Quantity","Cost","Channel","Priority","Date"]): + data_cmds.append({"command":"set","path":f"/Sheet1/{'ABCDEFGHIJ'[j]}1","props":{"text":h}}) + +rows = [ + ("North","Electronics","Laptop","Q1",12500,45,7500,"Online","High","2025-01-15"), + ("North","Electronics","Phone","Q1",8900,120,5340,"Retail","High","2025-02-10"), + ("North","Electronics","Tablet","Q2",6200,38,3720,"Online","Medium","2025-04-20"), + ("North","Electronics","Laptop","Q2",15800,55,9480,"Retail","High","2025-05-08"), + ("North","Electronics","Phone","Q3",11200,150,6720,"Online","High","2025-07-12"), + ("North","Electronics","Tablet","Q4",9500,62,5700,"Retail","Medium","2025-10-05"), + ("North","Clothing","Jacket","Q1",4200,85,2100,"Retail","Low","2025-01-22"), + ("North","Clothing","Shoes","Q2",5600,70,2800,"Online","Medium","2025-04-15"), + ("North","Clothing","Hat","Q3",1800,110,900,"Retail","Low","2025-08-03"), + ("North","Clothing","Jacket","Q4",7800,95,3900,"Online","High","2025-11-18"), + ("North","Food","Coffee","Q1",2400,200,1200,"Retail","Low","2025-03-01"), + ("North","Food","Snacks","Q2",1500,180,750,"Online","Low","2025-06-10"), + ("North","Food","Juice","Q3",1900,160,950,"Retail","Medium","2025-09-20"), + ("North","Food","Coffee","Q4",3200,220,1600,"Online","Medium","2025-12-01"), + ("South","Electronics","Phone","Q1",18500,200,11100,"Online","High","2024-01-20"), + ("South","Electronics","Laptop","Q2",22000,72,13200,"Retail","High","2024-05-15"), + ("South","Electronics","Tablet","Q3",7800,48,4680,"Online","Medium","2024-08-22"), + ("South","Electronics","Phone","Q4",14200,165,8520,"Retail","High","2024-11-30"), + ("South","Clothing","Shoes","Q1",9200,110,4600,"Retail","Medium","2024-02-14"), + ("South","Clothing","Jacket","Q2",6500,78,3250,"Online","Low","2024-06-01"), + ("South","Clothing","Hat","Q3",3100,130,1550,"Retail","Low","2024-09-10"), + ("South","Clothing","Shoes","Q4",8800,98,4400,"Online","Medium","2024-12-20"), + ("South","Food","Juice","Q1",1800,240,900,"Retail","Low","2024-03-08"), + ("South","Food","Coffee","Q2",3500,280,1750,"Online","Medium","2024-04-25"), + ("South","Food","Snacks","Q3",2200,190,1100,"Retail","Low","2024-07-14"), + ("South","Food","Juice","Q4",2800,210,1400,"Online","Medium","2024-10-18"), + ("East","Electronics","Tablet","Q1",5400,35,3240,"Online","Medium","2025-02-28"), + ("East","Electronics","Laptop","Q2",19500,65,11700,"Retail","High","2025-05-20"), + ("East","Electronics","Phone","Q3",13800,180,8280,"Online","High","2025-08-15"), + ("East","Electronics","Tablet","Q4",8200,52,4920,"Retail","Medium","2025-11-02"), + ("East","Clothing","Hat","Q1",2800,140,1400,"Retail","Low","2025-01-05"), + ("East","Clothing","Jacket","Q2",7200,60,3600,"Online","Medium","2025-06-18"), + ("East","Clothing","Shoes","Q3",5500,88,2750,"Retail","Medium","2025-09-25"), + ("East","Clothing","Hat","Q4",3600,105,1800,"Online","Low","2025-12-10"), + ("East","Food","Snacks","Q1",1200,300,600,"Retail","Low","2025-03-15"), + ("East","Food","Juice","Q2",2100,170,1050,"Online","Medium","2025-04-30"), + ("East","Food","Coffee","Q3",2800,230,1400,"Retail","Medium","2025-07-22"), + ("East","Food","Snacks","Q4",1600,250,800,"Online","Low","2025-10-28"), + ("West","Electronics","Laptop","Q1",20500,68,12300,"Online","High","2024-01-10"), + ("West","Electronics","Phone","Q2",16800,190,10080,"Retail","High","2024-04-05"), + ("West","Electronics","Tablet","Q3",8900,55,5340,"Online","Medium","2024-08-12"), + ("West","Electronics","Laptop","Q4",25000,82,15000,"Retail","High","2024-11-15"), + ("West","Clothing","Jacket","Q1",11000,88,5500,"Retail","Medium","2024-02-22"), + ("West","Clothing","Shoes","Q2",7500,95,3750,"Online","Medium","2024-05-30"), + ("West","Clothing","Hat","Q3",4200,120,2100,"Retail","Low","2024-09-08"), + ("West","Clothing","Jacket","Q4",13500,105,6750,"Online","High","2024-12-01"), + ("West","Food","Coffee","Q1",4500,350,2250,"Online","Medium","2024-03-18"), + ("West","Food","Snacks","Q2",2800,280,1400,"Online","Medium","2024-06-22"), + ("West","Food","Juice","Q3",3200,260,1600,"Retail","Low","2024-07-30"), + ("West","Food","Coffee","Q4",5800,400,2900,"Online","High","2024-10-25"), +] +C = "ABCDEFGHIJ" +for i, row in enumerate(rows): + for j, val in enumerate(row): + data_cmds.append({"command":"set","path":f"/Sheet1/{C[j]}{i+2}","props":{"text":str(val)}}) + +data_cmds.append({"command":"add","parent":"/","type":"sheet","props":{"name":"CNData"}}) +for j, h in enumerate(["地区","品类","销售额"]): + data_cmds.append({"command":"set","path":f"/CNData/{C[j]}1","props":{"text":h}}) +for i, (r, c, s) in enumerate([ + ("华东","电子产品",18000),("华东","服装",9500),("华东","食品",4200), + ("华南","电子产品",22000),("华南","服装",12000),("华南","食品",5800), + ("华北","电子产品",15000),("华北","服装",7800),("华北","食品",3600), + ("西南","电子产品",11000),("西南","服装",6500),("西南","食品",2900), +]): + for j, val in enumerate([r, c, s]): + data_cmds.append({"command":"set","path":f"/CNData/{C[j]}{i+2}","props":{"text":str(val)}}) + +cli(f'batch "{FILE}" --force --commands \'{json.dumps(data_cmds)}\'') + +# ========================================================================== +# 11 Pivot Tables +# +# Each section below shows the exact officecli command in a comment block, +# then executes it. You can copy any command block and run it in a terminal. +# ========================================================================== + +# -------------------------------------------------------------------------- +# Sheet: 1-Sales Overview +# +# officecli add pivot-tables.xlsx "/1-Sales Overview" --type pivottable \ +# --prop source=Sheet1!A1:J51 \ +# --prop rows=Region,Category \ +# --prop cols=Quarter \ +# --prop 'values=Sales:sum,Quantity:sum,Cost:sum:percent_of_row' \ +# --prop 'filters=Channel,Priority' \ +# --prop layout=tabular \ +# --prop repeatlabels=true \ +# --prop grandtotals=both \ +# --prop subtotals=on \ +# --prop sort=desc \ +# --prop name=SalesOverview \ +# --prop style=PivotStyleDark2 +# +# Features: tabular layout, 2-level rows, column axis, 3 value fields, +# Cost as percent_of_row, dual page filters, repeat item labels, desc sort +# -------------------------------------------------------------------------- +print("\n--- 1-Sales Overview ---") +cli(f'add "{FILE}" / --type sheet --prop name="1-Sales Overview"') +cli(f'add "{FILE}" "/1-Sales Overview" --type pivottable' + f' --prop source=Sheet1!A1:J51' + f' --prop rows=Region,Category' + f' --prop cols=Quarter' + f' --prop values=Sales:sum,Quantity:sum,Cost:sum:percent_of_row' + f' --prop filters=Channel,Priority' + f' --prop layout=tabular' + f' --prop repeatlabels=true' + f' --prop grandtotals=both' + f' --prop subtotals=on' + f' --prop sort=desc' + f' --prop name=SalesOverview' + f' --prop style=PivotStyleDark2') + +# -------------------------------------------------------------------------- +# Sheet: 2-Market Share +# +# officecli add pivot-tables.xlsx "/2-Market Share" --type pivottable \ +# --prop source=Sheet1!A1:J51 \ +# --prop rows=Region \ +# --prop cols=Category \ +# --prop 'values=Sales:sum:percent_of_col' \ +# --prop filters=Channel \ +# --prop layout=outline \ +# --prop grandtotals=both \ +# --prop name=MarketShare \ +# --prop style=PivotStyleMedium4 +# +# Features: outline layout, percent_of_col (each region's share per category) +# -------------------------------------------------------------------------- +print("\n--- 2-Market Share ---") +cli(f'add "{FILE}" / --type sheet --prop name="2-Market Share"') +cli(f'add "{FILE}" "/2-Market Share" --type pivottable' + f' --prop source=Sheet1!A1:J51' + f' --prop rows=Region' + f' --prop cols=Category' + f' --prop values=Sales:sum:percent_of_col' + f' --prop filters=Channel' + f' --prop layout=outline' + f' --prop grandtotals=both' + f' --prop name=MarketShare' + f' --prop style=PivotStyleMedium4') + +# -------------------------------------------------------------------------- +# Sheet: 3-Product Deep Dive +# +# officecli add pivot-tables.xlsx "/3-Product Deep Dive" --type pivottable \ +# --prop source=Sheet1!A1:J51 \ +# --prop rows=Category,Product \ +# --prop 'values=Sales:sum,Sales:average,Sales:max,Quantity:sum,Cost:sum' \ +# --prop filters=Region \ +# --prop layout=tabular \ +# --prop grandtotals=rows \ +# --prop subtotals=on \ +# --prop sort=desc \ +# --prop name=ProductDeepDive \ +# --prop style=PivotStyleMedium9 +# +# Features: 5 value fields (sum, average, max), no column axis — values +# become column headers via synthetic "Values" axis, row grand totals only +# -------------------------------------------------------------------------- +print("\n--- 3-Product Deep Dive ---") +cli(f'add "{FILE}" / --type sheet --prop name="3-Product Deep Dive"') +cli(f'add "{FILE}" "/3-Product Deep Dive" --type pivottable' + f' --prop source=Sheet1!A1:J51' + f' --prop rows=Category,Product' + f' --prop values=Sales:sum,Sales:average,Sales:max,Quantity:sum,Cost:sum' + f' --prop filters=Region' + f' --prop layout=tabular' + f' --prop grandtotals=rows' + f' --prop subtotals=on' + f' --prop sort=desc' + f' --prop name=ProductDeepDive' + f' --prop style=PivotStyleMedium9') + +# -------------------------------------------------------------------------- +# Sheet: 4-Channel Analysis +# +# officecli add pivot-tables.xlsx "/4-Channel Analysis" --type pivottable \ +# --prop source=Sheet1!A1:J51 \ +# --prop rows=Channel \ +# --prop cols=Quarter \ +# --prop 'values=Sales:sum:percent_of_total,Quantity:sum' \ +# --prop layout=outline \ +# --prop grandtotals=both \ +# --prop name=ChannelTrend \ +# --prop style=PivotStyleLight21 +# +# Features: percent_of_total (global share), no filters +# -------------------------------------------------------------------------- +print("\n--- 4-Channel Analysis ---") +cli(f'add "{FILE}" / --type sheet --prop name="4-Channel Analysis"') +cli(f'add "{FILE}" "/4-Channel Analysis" --type pivottable' + f' --prop source=Sheet1!A1:J51' + f' --prop rows=Channel' + f' --prop cols=Quarter' + f' --prop values=Sales:sum:percent_of_total,Quantity:sum' + f' --prop layout=outline' + f' --prop grandtotals=both' + f' --prop name=ChannelTrend' + f' --prop style=PivotStyleLight21') + +# -------------------------------------------------------------------------- +# Sheet: 5-Priority Matrix +# +# officecli add pivot-tables.xlsx "/5-Priority Matrix" --type pivottable \ +# --prop source=Sheet1!A1:J51 \ +# --prop rows=Priority,Region \ +# --prop cols=Category \ +# --prop 'values=Sales:sum,Cost:sum:percent_of_row' \ +# --prop filters=Channel \ +# --prop layout=tabular \ +# --prop blankrows=true \ +# --prop grandtotals=both \ +# --prop subtotals=on \ +# --prop sort=asc \ +# --prop name=PriorityMatrix \ +# --prop style=PivotStyleDark6 +# +# Features: blankRows — empty line after each outer group for visual separation +# -------------------------------------------------------------------------- +print("\n--- 5-Priority Matrix ---") +cli(f'add "{FILE}" / --type sheet --prop name="5-Priority Matrix"') +cli(f'add "{FILE}" "/5-Priority Matrix" --type pivottable' + f' --prop source=Sheet1!A1:J51' + f' --prop rows=Priority,Region' + f' --prop cols=Category' + f' --prop values=Sales:sum,Cost:sum:percent_of_row' + f' --prop filters=Channel' + f' --prop layout=tabular' + f' --prop blankrows=true' + f' --prop grandtotals=both' + f' --prop subtotals=on' + f' --prop sort=asc' + f' --prop name=PriorityMatrix' + f' --prop style=PivotStyleDark6') + +# -------------------------------------------------------------------------- +# Sheet: 6-Compact 3-Level +# +# officecli add pivot-tables.xlsx "/6-Compact 3-Level" --type pivottable \ +# --prop source=Sheet1!A1:J51 \ +# --prop rows=Region,Category,Product \ +# --prop 'values=Sales:sum,Quantity:sum' \ +# --prop filters=Priority \ +# --prop layout=compact \ +# --prop grandtotals=both \ +# --prop subtotals=on \ +# --prop sort=desc \ +# --prop name=Compact3Level \ +# --prop style=PivotStyleMedium14 +# +# Features: compact layout — 3-level hierarchy in one indented column +# -------------------------------------------------------------------------- +print("\n--- 6-Compact 3-Level ---") +cli(f'add "{FILE}" / --type sheet --prop name="6-Compact 3-Level"') +cli(f'add "{FILE}" "/6-Compact 3-Level" --type pivottable' + f' --prop source=Sheet1!A1:J51' + f' --prop rows=Region,Category,Product' + f' --prop values=Sales:sum,Quantity:sum' + f' --prop filters=Priority' + f' --prop layout=compact' + f' --prop grandtotals=both' + f' --prop subtotals=on' + f' --prop sort=desc' + f' --prop name=Compact3Level' + f' --prop style=PivotStyleMedium14') + +# -------------------------------------------------------------------------- +# Sheet: 7-No Subtotals +# +# officecli add pivot-tables.xlsx "/7-No Subtotals" --type pivottable \ +# --prop source=Sheet1!A1:J51 \ +# --prop rows=Region,Category \ +# --prop cols=Quarter \ +# --prop values=Sales:sum \ +# --prop layout=tabular \ +# --prop repeatlabels=true \ +# --prop grandtotals=cols \ +# --prop subtotals=off \ +# --prop sort=asc \ +# --prop name=FlatView \ +# --prop style=PivotStyleLight1 +# +# Features: subtotals=off (flat view), grandtotals=cols (bottom row only), +# repeatlabels=true (essential when subtotals off — otherwise outer labels vanish) +# -------------------------------------------------------------------------- +print("\n--- 7-No Subtotals ---") +cli(f'add "{FILE}" / --type sheet --prop name="7-No Subtotals"') +cli(f'add "{FILE}" "/7-No Subtotals" --type pivottable' + f' --prop source=Sheet1!A1:J51' + f' --prop rows=Region,Category' + f' --prop cols=Quarter' + f' --prop values=Sales:sum' + f' --prop layout=tabular' + f' --prop repeatlabels=true' + f' --prop grandtotals=cols' + f' --prop subtotals=off' + f' --prop sort=asc' + f' --prop name=FlatView' + f' --prop style=PivotStyleLight1') + +# -------------------------------------------------------------------------- +# Sheet: 8-Date Grouping +# +# officecli add pivot-tables.xlsx "/8-Date Grouping" --type pivottable \ +# --prop source=Sheet1!A1:J51 \ +# --prop 'rows=Date:year,Date:quarter' \ +# --prop 'values=Sales:sum,Cost:sum' \ +# --prop filters=Region \ +# --prop layout=outline \ +# --prop grandtotals=both \ +# --prop subtotals=on \ +# --prop name=DateGrouping \ +# --prop style=PivotStyleMedium7 +# +# Features: automatic date grouping — Date:year creates "2024","2025" buckets, +# Date:quarter creates "2024-Q1",... sub-buckets. Uses native Excel fieldGroup XML. +# -------------------------------------------------------------------------- +print("\n--- 8-Date Grouping ---") +cli(f'add "{FILE}" / --type sheet --prop name="8-Date Grouping"') +cli(f'add "{FILE}" "/8-Date Grouping" --type pivottable' + f' --prop source=Sheet1!A1:J51' + f' --prop rows=Date:year,Date:quarter' + f' --prop values=Sales:sum,Cost:sum' + f' --prop filters=Region' + f' --prop layout=outline' + f' --prop grandtotals=both' + f' --prop subtotals=on' + f' --prop name=DateGrouping' + f' --prop style=PivotStyleMedium7') + +# -------------------------------------------------------------------------- +# Sheet: 9-Top 5 Products +# +# officecli add pivot-tables.xlsx "/9-Top 5 Products" --type pivottable \ +# --prop source=Sheet1!A1:J51 \ +# --prop rows=Product \ +# --prop 'values=Sales:sum,Quantity:sum,Cost:sum' \ +# --prop layout=tabular \ +# --prop grandtotals=none \ +# --prop topN=5 \ +# --prop sort=desc \ +# --prop name=Top5Products \ +# --prop style=PivotStyleDark1 +# +# Features: topN=5 (only top 5 products by first value field), grandtotals=none +# -------------------------------------------------------------------------- +print("\n--- 9-Top 5 Products ---") +cli(f'add "{FILE}" / --type sheet --prop name="9-Top 5 Products"') +cli(f'add "{FILE}" "/9-Top 5 Products" --type pivottable' + f' --prop source=Sheet1!A1:J51' + f' --prop rows=Product' + f' --prop values=Sales:sum,Quantity:sum,Cost:sum' + f' --prop layout=tabular' + f' --prop grandtotals=none' + f' --prop topN=5' + f' --prop sort=desc' + f' --prop name=Top5Products' + f' --prop style=PivotStyleDark1') + +# -------------------------------------------------------------------------- +# Sheet: 10-Ultimate +# +# officecli add pivot-tables.xlsx "/10-Ultimate" --type pivottable \ +# --prop source=Sheet1!A1:J51 \ +# --prop rows=Region,Category \ +# --prop cols=Quarter \ +# --prop 'values=Sales:sum,Quantity:average,Cost:sum:percent_of_row' \ +# --prop 'filters=Channel,Priority' \ +# --prop layout=tabular \ +# --prop repeatlabels=true \ +# --prop blankrows=true \ +# --prop grandtotals=rows \ +# --prop subtotals=on \ +# --prop sort=desc \ +# --prop name=UltimatePivot \ +# --prop style=PivotStyleDark11 +# +# Features: ALL features combined — tabular + repeatLabels + blankRows + +# dual filters + 3 mixed-aggregation values + row-only grand totals +# -------------------------------------------------------------------------- +print("\n--- 10-Ultimate ---") +cli(f'add "{FILE}" / --type sheet --prop name="10-Ultimate"') +cli(f'add "{FILE}" "/10-Ultimate" --type pivottable' + f' --prop source=Sheet1!A1:J51' + f' --prop rows=Region,Category' + f' --prop cols=Quarter' + f' --prop values=Sales:sum,Quantity:average,Cost:sum:percent_of_row' + f' --prop filters=Channel,Priority' + f' --prop layout=tabular' + f' --prop repeatlabels=true' + f' --prop blankrows=true' + f' --prop grandtotals=rows' + f' --prop subtotals=on' + f' --prop sort=desc' + f' --prop name=UltimatePivot' + f' --prop style=PivotStyleDark11') + +# -------------------------------------------------------------------------- +# Sheet: 11-Chinese Locale +# +# officecli add pivot-tables.xlsx "/11-Chinese Locale" --type pivottable \ +# --prop source=CNData!A1:C13 \ +# --prop rows=地区,品类 \ +# --prop values=销售额:sum \ +# --prop layout=tabular \ +# --prop grandtotals=both \ +# --prop subtotals=on \ +# --prop sort=locale \ +# --prop grandTotalCaption=合计 \ +# --prop name=ChineseLocale \ +# --prop style=PivotStyleMedium2 +# +# Features: sort=locale (Chinese pinyin: 华北 < 华东 < 华南 < 西南), +# grandTotalCaption=合计 (custom grand total label) +# -------------------------------------------------------------------------- +print("\n--- 11-Chinese Locale ---") +cli(f'add "{FILE}" / --type sheet --prop name="11-Chinese Locale"') +cli(f'add "{FILE}" "/11-Chinese Locale" --type pivottable' + f' --prop source=CNData!A1:C13' + f' --prop rows=地区,品类' + f' --prop values=销售额:sum' + f' --prop layout=tabular' + f' --prop grandtotals=both' + f' --prop subtotals=on' + f' --prop sort=locale' + f' --prop grandTotalCaption=合计' + f' --prop name=ChineseLocale' + f' --prop style=PivotStyleMedium2') + +print(f"\nDone! Generated: {FILE}") +print(" 13 sheets (Sheet1 + CNData + 11 pivot tables)") diff --git a/examples/excel/pivot-tables.xlsx b/examples/excel/pivot-tables.xlsx new file mode 100644 index 000000000..9eb3a35df Binary files /dev/null and b/examples/excel/pivot-tables.xlsx differ diff --git a/examples/ppt/3d-model.md b/examples/ppt/3d-model.md new file mode 100644 index 000000000..9f810a627 --- /dev/null +++ b/examples/ppt/3d-model.md @@ -0,0 +1,6 @@ +# 3d-model + +TODO: rewrite script with annotated officecli commands. + +See [3d-model.sh](3d-model.sh) and [3d-model.pptx](3d-model.pptx). + diff --git a/examples/ppt/outputs/3d-sun.pptx b/examples/ppt/3d-model.pptx similarity index 100% rename from examples/ppt/outputs/3d-sun.pptx rename to examples/ppt/3d-model.pptx diff --git a/examples/ppt/gen-3d-sun-pptx.sh b/examples/ppt/3d-model.sh similarity index 100% rename from examples/ppt/gen-3d-sun-pptx.sh rename to examples/ppt/3d-model.sh diff --git a/examples/ppt/README.md b/examples/ppt/README.md deleted file mode 100644 index c1592e35d..000000000 --- a/examples/ppt/README.md +++ /dev/null @@ -1,373 +0,0 @@ -# PowerPoint (.pptx) Examples & Templates - -Examples and professional style templates for PowerPoint presentation automation. - -## 📂 Structure - -``` -ppt/ -├── README.md # This file -├── gen-beautiful-pptx.sh # Basic examples -├── gen-animations-pptx.sh -├── gen-video-pptx.py -├── outputs/ # Generated examples -└── templates/ # 35 professional style templates ⭐ - ├── README.md # Style index and guide - └── styles/ # Individual style directories - ├── dark--*/ (14 dark styles, 8 available) - ├── light--*/ (8 light styles, 6 available) - ├── warm--*/ (5 warm styles) - ├── vivid--*/ (2 vivid styles) - ├── bw--*/ (3 black & white, 1 available) - └── mixed--*/ (1 mixed style) -``` - ---- - -## 🚀 Quick Start - -### Basic Examples - -```bash -# Beautiful presentation with morph transitions -bash gen-beautiful-pptx.sh - -# Animation effects -bash gen-animations-pptx.sh - -# Video embedding (Python) -python gen-video-pptx.py -``` - -### Professional Style Templates - -```bash -cd templates/styles/dark--investor-pitch -# View pre-generated PPT -open template.pptx - -# Or regenerate -bash build.sh -``` - -👉 **[Browse all 35 styles →](templates/)** (15 with pre-generated PPTs) - ---- - -## 🎨 Basic Scripts - -### [gen-beautiful-pptx.sh](gen-beautiful-pptx.sh) -**Create a beautiful presentation with morph transitions** - -```bash -bash gen-beautiful-pptx.sh -``` - -**Demonstrates:** -- Morph transitions between slides -- Shape creation and positioning -- Text styling and alignment -- Color palettes and gradients -- Layout design patterns - -**Output:** [`outputs/beautiful_presentation.pptx`](outputs/beautiful_presentation.pptx) - ---- - -### [gen-animations-pptx.sh](gen-animations-pptx.sh) -**Comprehensive animation examples** - -```bash -bash gen-animations-pptx.sh -``` - -**Demonstrates:** -- Entrance animations (fade, fly, zoom, etc.) -- Emphasis animations (pulse, grow, spin) -- Exit animations (disappear, fly out) -- Animation timing and sequencing -- Multiple animations per object - -**Output:** [`outputs/gen-animations-pptx.pptx`](outputs/gen-animations-pptx.pptx) - ---- - -### [gen-video-pptx.py](gen-video-pptx.py) -**Embed video in PowerPoint (Python)** - -```bash -python gen-video-pptx.py -``` - -**Demonstrates:** -- Video embedding -- Media positioning -- Python integration with OfficeCLI - -**Output:** [`outputs/gen-video-pptx.pptx`](outputs/gen-video-pptx.pptx) - ---- - -## 📈 Sample Outputs - -Pre-generated examples in [`outputs/`](outputs/): -- `beautiful_presentation.pptx` - Professional presentation with morph -- `data_presentation.pptx` - Data visualization deck -- `gen-animations-pptx.pptx` - Animation showcase -- `gen-video-pptx.pptx` - Video embedding example - ---- - -## 🎨 Professional Style Templates - -**35 design styles organized by color palette:** - -### 🌑 Dark Palette (14 styles, 8 available) -Perfect for tech, corporate, and futuristic themes. - -**Available (✅):** -- `dark--investor-pitch` - Investor pitches, fundraising decks -- `dark--cosmic-neon` - Science talks, futuristic topics -- `dark--editorial-story` - Brand storytelling, editorial magazines -- `dark--tech-cosmos` - Tech talks, architecture reviews -- `dark--cyber-future` - Futuristic topics, cyberpunk, AI -- `dark--luxury-minimal` - Luxury brands, premium products -- `dark--space-odyssey` - Space/astronomy, science education -- `dark--neon-productivity` - Productivity talks, motivation - -**Reference-Only (⚙️):** -- `dark--liquid-flow`, `dark--premium-navy`, `dark--blueprint-grid`, -- `dark--diagonal-cut`, `dark--spotlight-stage`, `dark--circle-digital` - -### ☀️ Light Palette (8 styles, 6 available) -Clean and professional for business and product showcases. - -**Available (✅):** -- `light--minimal-corporate` - Annual reports, business proposals -- `light--minimal-product` - Product launches, brand introductions -- `light--project-proposal` - Project kickoffs, bid presentations -- `light--spring-launch` - Spring launches, seasonal marketing -- `light--training-interactive` - Corporate training, online courses - -### 🧡 Warm Palette (5 styles) -Warm and friendly for lifestyle and organic brands. (Reference-only) - -### 🌈 Vivid Palette (2 styles) -Energetic and youthful for marketing campaigns. (Reference-only) - -### ⬛ Black & White (3 styles, 1 available) -Minimalist and sophisticated. - -**Available (✅):** -- `bw--swiss-bauhaus` - Design agencies, architecture firms - -### 🎨 Mixed Palette (1 style) -Bold architectural designs. (Reference-only) - -👉 **[Full style index with mood, use cases →](templates/README.md)** - ---- - -## 📖 Quick Lookup by Use Case - -| Use Case | Recommended Styles | -|----------|-------------------| -| **Tech / AI / SaaS** | ✅ dark--tech-cosmos, ✅ dark--cyber-future | -| **Investment / Pitch** | ✅ dark--investor-pitch, ✅ light--project-proposal | -| **Corporate / Business** | ✅ light--minimal-corporate, ✅ light--minimal-product | -| **Education / Training** | ✅ light--training-interactive | -| **Sci-Fi / Space / Future** | ✅ dark--space-odyssey, ✅ dark--cosmic-neon | -| **Luxury / Premium** | ✅ dark--luxury-minimal | -| **Design / Architecture** | ✅ bw--swiss-bauhaus | - ---- - -## 🎓 Key Concepts - -### Presentation Structure -``` -/Presentation - /slide[1] # First slide - /shape[1] # First shape - /shape[2] - /slide[2] - /master[1] # Slide master -``` - -### Common Commands - -**Add a slide:** -```bash -officecli add deck.pptx / --type slide \ - --prop layout=blank \ - --prop background=1A1A2E -``` - -**Add a shape:** -```bash -officecli add deck.pptx /slide[1] --type shape \ - --prop text="Hello World" \ - --prop x=5cm \ - --prop y=5cm \ - --prop width=10cm \ - --prop height=3cm \ - --prop size=48 \ - --prop bold=true \ - --prop color=FFFFFF -``` - -**Set transition:** -```bash -officecli set deck.pptx /slide[1] \ - --prop transition=morph \ - --prop advanceTime=3000 -``` - -**Copy slide:** -```bash -officecli add deck.pptx / --from /slide[1] -``` - ---- - -## 🎨 Shape Types - -### Available Presets - -| Preset | Description | -|--------|-------------| -| `rect` | Rectangle | -| `roundRect` | Rounded rectangle | -| `ellipse` | Circle/Ellipse | -| `triangle` | Triangle | -| `diamond` | Diamond | -| `pentagon` | Pentagon | -| `hexagon` | Hexagon | -| `star5` | 5-point star | -| `arrow` | Arrow | -| `callout` | Callout bubble | - -**View all presets:** -```bash -officecli pptx add -``` - ---- - -## 📊 Available Properties - -### Slide -- `layout` - Slide layout (blank, title, titleContent, etc.) -- `background` - Background color (hex) -- `transition` - Transition effect (fade, push, wipe, morph, etc.) -- `advanceTime` - Auto-advance time in milliseconds -- `notes` - Speaker notes - -### Shape -- `name` - Shape name/identifier -- `preset` - Shape preset (rect, ellipse, arrow, etc.) -- `text` - Text content -- `x`, `y` - Position (cm, in, pt, px, EMU) -- `width`, `height` - Size -- `rotation` - Rotation angle (degrees) -- `fill` - Fill color (hex) -- `line` - Line color (hex or "none") -- `opacity` - Opacity (0.0 to 1.0) - -### Text Formatting -- `font` - Font name -- `size` - Font size in points -- `bold` - true/false -- `italic` - true/false -- `color` - Text color (hex) -- `align` - left, center, right, justify -- `valign` - top, middle, bottom - -### Animations -- `animation` - Animation effect (fade, fly, zoom, etc.) -- `animDelay` - Delay before animation starts (ms) -- `animDuration` - Animation duration (ms) -- `animTrigger` - click, afterPrev, withPrev - -**For complete property list:** -```bash -officecli pptx set -officecli pptx set slide -officecli pptx set shape -``` - ---- - -## 🎬 Transitions & Animations - -### Popular Transitions -- `morph` - Seamless object morphing -- `fade` - Fade in/out -- `push` - Push from side -- `wipe` - Wipe across -- `zoom` - Zoom in/out -- `cube` - 3D cube rotation - -### Animation Types -- **Entrance:** fade, fly, zoom, appear, split -- **Emphasis:** pulse, grow, spin, teeter -- **Exit:** disappear, fly, fade, zoom - -**Morph Transition Tips:** -- Name objects consistently across slides (e.g., `name="!!title"`) -- Keep object hierarchy the same -- Change position, size, or color for smooth morphing - ---- - -## 🔧 Tips - -1. **View presentation structure:** - ```bash - officecli view deck.pptx outline - ``` - -2. **Check statistics:** - ```bash - officecli view deck.pptx stats - ``` - -3. **Query shapes:** - ```bash - officecli query deck.pptx "shape[fill=FF0000]" - ``` - -4. **Batch slide building:** - ```bash - cat << EOF | officecli batch deck.pptx - [ - {"command":"add","parent":"/","type":"slide","props":{"background":"000000"}}, - {"command":"add","parent":"/slide[1]","type":"shape","props":{"text":"Title","x":"5cm","y":"5cm"}} - ] - EOF - ``` - -5. **Resident mode for multi-slide decks:** - ```bash - officecli open deck.pptx - officecli add deck.pptx / --type slide - officecli add deck.pptx / --type slide - officecli close deck.pptx - ``` - -6. **Position units:** - - `cm` - Centimeters (recommended) - - `in` - Inches - - `pt` - Points - - `px` - Pixels - - EMU - Raw units (914400 = 1 inch) - ---- - -## 📚 More Resources - -- **[Style Templates](templates/)** - 35 professional styles (19 ready-to-use) -- **[PowerPoint documentation](../../SKILL.md#powerpoint-pptx)** - Complete reference -- **[All examples](../)** - Word, Excel, PowerPoint -- **[Word examples](../word/)** - Document automation -- **[Excel examples](../excel/)** - Spreadsheet automation diff --git a/examples/ppt/animations.md b/examples/ppt/animations.md new file mode 100644 index 000000000..a26113222 --- /dev/null +++ b/examples/ppt/animations.md @@ -0,0 +1,6 @@ +# animations + +TODO: rewrite script with annotated officecli commands. + +See [animations.sh](animations.sh) and [animations.pptx](animations.pptx). + diff --git a/examples/ppt/outputs/gen-animations-pptx.pptx b/examples/ppt/animations.pptx similarity index 100% rename from examples/ppt/outputs/gen-animations-pptx.pptx rename to examples/ppt/animations.pptx diff --git a/examples/ppt/gen-animations-pptx.sh b/examples/ppt/animations.sh similarity index 100% rename from examples/ppt/gen-animations-pptx.sh rename to examples/ppt/animations.sh diff --git a/examples/ppt/outputs/claude-morph-template-v39.pptx b/examples/ppt/outputs/claude-morph-template-v39.pptx deleted file mode 100644 index 53b6a7f81..000000000 Binary files a/examples/ppt/outputs/claude-morph-template-v39.pptx and /dev/null differ diff --git a/examples/ppt/outputs/claude-morph-template-v40.pptx b/examples/ppt/outputs/claude-morph-template-v40.pptx deleted file mode 100644 index 1e6f7f593..000000000 Binary files a/examples/ppt/outputs/claude-morph-template-v40.pptx and /dev/null differ diff --git a/examples/ppt/outputs/creative-marketing.pptx b/examples/ppt/outputs/creative-marketing.pptx deleted file mode 100644 index 696f3f33e..000000000 Binary files a/examples/ppt/outputs/creative-marketing.pptx and /dev/null differ diff --git a/examples/ppt/outputs/data_presentation.pptx b/examples/ppt/outputs/data_presentation.pptx deleted file mode 100644 index 62db325c0..000000000 Binary files a/examples/ppt/outputs/data_presentation.pptx and /dev/null differ diff --git a/examples/ppt/presentation.md b/examples/ppt/presentation.md new file mode 100644 index 000000000..205fb1e91 --- /dev/null +++ b/examples/ppt/presentation.md @@ -0,0 +1,6 @@ +# presentation + +TODO: rewrite script with annotated officecli commands. + +See [presentation.sh](presentation.sh) and [presentation.pptx](presentation.pptx). + diff --git a/examples/ppt/outputs/beautiful_presentation.pptx b/examples/ppt/presentation.pptx similarity index 100% rename from examples/ppt/outputs/beautiful_presentation.pptx rename to examples/ppt/presentation.pptx diff --git a/examples/ppt/gen-beautiful-pptx.sh b/examples/ppt/presentation.sh similarity index 100% rename from examples/ppt/gen-beautiful-pptx.sh rename to examples/ppt/presentation.sh diff --git a/examples/ppt/video.md b/examples/ppt/video.md new file mode 100644 index 000000000..3d0ab515a --- /dev/null +++ b/examples/ppt/video.md @@ -0,0 +1,6 @@ +# video + +TODO: rewrite script with annotated officecli commands. + +See [video.py](video.py) and [video.pptx](video.pptx). + diff --git a/examples/ppt/outputs/gen-video-pptx.pptx b/examples/ppt/video.pptx similarity index 100% rename from examples/ppt/outputs/gen-video-pptx.pptx rename to examples/ppt/video.pptx diff --git a/examples/ppt/gen-video-pptx.py b/examples/ppt/video.py similarity index 99% rename from examples/ppt/gen-video-pptx.py rename to examples/ppt/video.py index 38bd03cad..e88f64d5e 100755 --- a/examples/ppt/gen-video-pptx.py +++ b/examples/ppt/video.py @@ -124,7 +124,7 @@ def main(): run(f'set "{out_pptx}" /slide[2] --prop background=0D1B2A') run(f'set "{out_pptx}" /slide[2]/shape[1] --prop color=FFFFFF') run(f'add "{out_pptx}" /slide[2] --type video ' - f'--prop path="{video_path}" ' + f'--prop src="{video_path}" ' f'--prop poster="{cover_path}" ' f'--prop x=2cm --prop y=4cm --prop width=22cm --prop height=12.5cm ' f'--prop volume=80 --prop autoplay=true') diff --git a/examples/word/README.md b/examples/word/README.md deleted file mode 100644 index 1f95f990c..000000000 --- a/examples/word/README.md +++ /dev/null @@ -1,174 +0,0 @@ -# Word (.docx) Examples - -Examples demonstrating OfficeCLI capabilities for Word document automation. - -## 📄 Scripts - -### [gen-formulas.sh](gen-formulas.sh) -**Insert mathematical formulas and equations** - -```bash -bash gen-formulas.sh -``` - -**Demonstrates:** -- LaTeX math formula support -- Equation insertion -- Formula formatting - -**Output:** [`outputs/complex_formulas.docx`](outputs/complex_formulas.docx) - ---- - -### [gen-complex-tables.sh](gen-complex-tables.sh) -**Generate complex tables with styling** - -```bash -bash gen-complex-tables.sh -``` - -**Demonstrates:** -- Table creation and formatting -- Cell styling (borders, shading, alignment) -- Row and column manipulation -- Table properties (width, height, spacing) - -**Output:** [`outputs/complex_tables.docx`](outputs/complex_tables.docx) - ---- - -### [gen-complex-textbox.sh](gen-complex-textbox.sh) -**Create styled text boxes** - -```bash -bash gen-complex-textbox.sh -``` - -**Demonstrates:** -- Text box creation -- Font styling (bold, italic, size, color) -- Text alignment and formatting -- Paragraph properties - -**Output:** Generated dynamically - ---- - -## 🎓 Key Concepts - -### Document Structure -``` -/document - /body - /p[1] # Paragraph 1 - /r[1] # Run 1 - /p[2] - /tbl[1] # Table 1 - /tr[1] # Row 1 - /tc[1] # Cell 1 -``` - -### Common Commands - -**Create a paragraph:** -```bash -officecli add report.docx /body --type paragraph \ - --prop text="Hello World" \ - --prop style=Heading1 -``` - -**Modify text formatting:** -```bash -officecli set report.docx /body/p[1]/r[1] \ - --prop bold=true \ - --prop color=FF0000 \ - --prop size=24 -``` - -**Add a table:** -```bash -officecli add report.docx /body --type table \ - --prop rows=3 \ - --prop cols=4 -``` - ---- - -## 📊 Available Properties - -### Paragraph -- `text` - Paragraph text content -- `style` - Paragraph style (Normal, Heading1-9, etc.) -- `alignment` - left, center, right, justify -- `lineSpacing` - Line spacing (e.g., 1.5, 2.0) -- `indent` - Indentation in points - -### Run (Text Formatting) -- `text` - Text content -- `bold` - true/false -- `italic` - true/false -- `underline` - true/false -- `strike` - true/false -- `font` - Font name -- `size` - Font size in points -- `color` - Hex color (e.g., FF0000) -- `highlight` - Highlight color - -### Table -- `rows` - Number of rows -- `cols` - Number of columns -- `width` - Table width -- `border.color` - Border color -- `border.width` - Border width -- `border.style` - Border style - -**For complete property list:** -```bash -officecli docx set -officecli docx set paragraph -officecli docx set run -officecli docx set table -``` - ---- - -## 🔧 Tips - -1. **View structure first:** - ```bash - officecli view report.docx outline - ``` - -2. **Check content:** - ```bash - officecli view report.docx text - ``` - -3. **Query elements:** - ```bash - officecli query report.docx "paragraph[style=Heading1]" - ``` - -4. **Batch operations:** - ```bash - cat << EOF | officecli batch report.docx - [ - {"command":"add","parent":"/body","type":"paragraph","props":{"text":"Para 1"}}, - {"command":"add","parent":"/body","type":"paragraph","props":{"text":"Para 2"}} - ] - EOF - ``` - -5. **Validate after changes:** - ```bash - officecli validate report.docx - ``` - ---- - -## 📚 More Resources - -- [Complete Word documentation](../../SKILL.md#word-docx) -- [All examples](../) -- [PowerPoint examples](../powerpoint/) -- [Excel examples](../excel/) diff --git a/examples/word/outputs/complex_formulas.docx b/examples/word/formulas.docx similarity index 100% rename from examples/word/outputs/complex_formulas.docx rename to examples/word/formulas.docx diff --git a/examples/word/formulas.md b/examples/word/formulas.md new file mode 100644 index 000000000..28ac1c73b --- /dev/null +++ b/examples/word/formulas.md @@ -0,0 +1,6 @@ +# formulas + +TODO: rewrite script with annotated officecli commands. + +See [formulas.sh](formulas.sh) and [formulas.docx](formulas.docx). + diff --git a/examples/word/gen-formulas.sh b/examples/word/formulas.sh similarity index 100% rename from examples/word/gen-formulas.sh rename to examples/word/formulas.sh diff --git a/examples/word/outputs/complex_tables.docx b/examples/word/tables.docx similarity index 100% rename from examples/word/outputs/complex_tables.docx rename to examples/word/tables.docx diff --git a/examples/word/tables.md b/examples/word/tables.md new file mode 100644 index 000000000..7ad30d6c6 --- /dev/null +++ b/examples/word/tables.md @@ -0,0 +1,6 @@ +# tables + +TODO: rewrite script with annotated officecli commands. + +See [tables.sh](tables.sh) and [tables.docx](tables.docx). + diff --git a/examples/word/gen-complex-tables.sh b/examples/word/tables.sh similarity index 100% rename from examples/word/gen-complex-tables.sh rename to examples/word/tables.sh diff --git a/examples/word/textbox.md b/examples/word/textbox.md new file mode 100644 index 000000000..4837da834 --- /dev/null +++ b/examples/word/textbox.md @@ -0,0 +1,6 @@ +# textbox + +TODO: rewrite script with annotated officecli commands. + +See [textbox.sh](textbox.sh) and [textbox.docx](textbox.docx). + diff --git a/examples/word/gen-complex-textbox.sh b/examples/word/textbox.sh similarity index 100% rename from examples/word/gen-complex-textbox.sh rename to examples/word/textbox.sh diff --git a/install.sh b/install.sh index c83052d43..54513f60c 100755 --- a/install.sh +++ b/install.sh @@ -119,15 +119,22 @@ else fi mkdir -p "$INSTALL_DIR" -cp "$SOURCE" "$INSTALL_DIR/$BINARY_NAME" -chmod +x "$INSTALL_DIR/$BINARY_NAME" +# Atomic replace: stage as .new alongside the target, sign there, then rename. +# Overwriting the binary in place would trash the text segment of any +# running officecli process (macOS does not block ETXTBSY), leaving it +# stuck in uninterruptible `UE` state on the next code page fault. +cp "$SOURCE" "$INSTALL_DIR/$BINARY_NAME.new" +chmod +x "$INSTALL_DIR/$BINARY_NAME.new" # macOS: remove quarantine flag and ad-hoc codesign (required by AppleSystemPolicy) +# Done on the staged .new copy so the live binary is never mutated in place. if [ "$(uname -s)" = "Darwin" ]; then - xattr -d com.apple.quarantine "$INSTALL_DIR/$BINARY_NAME" 2>/dev/null || true - codesign -s - -f "$INSTALL_DIR/$BINARY_NAME" 2>/dev/null || true + xattr -d com.apple.quarantine "$INSTALL_DIR/$BINARY_NAME.new" 2>/dev/null || true + codesign -s - -f "$INSTALL_DIR/$BINARY_NAME.new" 2>/dev/null || true fi +mv -f "$INSTALL_DIR/$BINARY_NAME.new" "$INSTALL_DIR/$BINARY_NAME" + # Auto-add to PATH if needed case ":$PATH:" in *":$INSTALL_DIR:"*) ;; diff --git a/skills/morph-ppt-3d/SKILL.md b/skills/morph-ppt-3d/SKILL.md index d3d641664..f4a8e0105 100644 --- a/skills/morph-ppt-3d/SKILL.md +++ b/skills/morph-ppt-3d/SKILL.md @@ -32,18 +32,18 @@ When the user gives a topic but no `.glb` file, **proactively help them find a m Based on the user's topic, suggest what kind of 3D model would work: -| Topic type | Model suggestion | Example | -| ------------------ | ----------------------------------- | ---------------------------------------------------- | -| Product/brand | The actual product or a similar one | "咖啡品牌" → coffee cup, coffee machine, coffee bean | -| Animal/character | The animal or mascot | "柴犬介绍" → shiba inu dog model | -| Architecture/space | Building, room, or structure | "新办公室" → office building, interior | -| Vehicle/transport | The vehicle itself | "电动车发布" → car, motorcycle, bicycle | -| Food/cooking | The dish or ingredient | "日料介绍" → sushi platter, ramen bowl | -| Tech/gadget | The device | "新手机发布" → phone, tablet, laptop | -| Nature/science | The subject | "太阳系" → planet, sun, earth | -| Abstract concept | A symbolic object | "团队合作" → puzzle pieces, gears, bridge | - -Tell the user: "你的主题是 [X],建议用 [具体模型描述] 的 3D 模型。我推荐几个免费下载来源:" +| Topic type | Model suggestion | Example | +| ------------------ | ----------------------------------- | ----------------------------------------------------- | +| Product/brand | The actual product or a similar one | "coffee brand" → coffee cup, coffee machine, bean | +| Animal/character | The animal or mascot | "fox mascot" → fox 3D model | +| Architecture/space | Building, room, or structure | "new office" → office building, interior | +| Vehicle/transport | The vehicle itself | "EV launch" → car, motorcycle, bicycle | +| Food/cooking | The dish or ingredient | "Japanese food" → sushi platter, ramen bowl | +| Tech/gadget | The device | "phone launch" → phone, tablet, laptop | +| Nature/science | The subject | "solar system" → planet, sun, earth | +| Abstract concept | A symbolic object | "teamwork" → puzzle pieces, gears, bridge | + +Tell the user: "Your topic is [X]. I suggest using a 3D model of [description]. Here are some free sources to find one:" ### Step 2: Search for models (agent-driven) @@ -55,7 +55,7 @@ Tell the user: "你的主题是 [X],建议用 [具体模型描述] 的 3D 模 ``` Search: "[topic keyword] 3d model glb free download" - Example: "shiba dog 3d model glb free download" + Example: "fox 3d model glb free download" ``` 2. **Sketchfab API** (no auth needed for search): @@ -99,23 +99,23 @@ Show the user 2-3 model options with: Example response: ``` -根据你的主题"柴犬品牌",我找到了这些模型: +Based on your topic "fox mascot", here are some models I found: -1. 🐕 Shiba Inu (Sketchfab) - 链接:https://sketchfab.com/3d-models/shiba-xxx - 授权:CC BY 4.0(免费可商用) - 推荐理由:高质量柴犬模型,表情可爱 +1. Fox (Khronos sample) + Direct download, guaranteed compatible + Why: clean fox model, good for mascot/character decks -2. 🐶 Low Poly Dog (Poly Pizza) - 链接:https://poly.pizza/m/xxx - 授权:CC0(完全免费) - 推荐理由:低多边形风格,适合简洁设计 +2. Low Poly Fox (Poly Pizza) + URL: https://poly.pizza/m/xxx + License: CC0 (completely free) + Why: low-poly style, good fit for clean minimal design -3. 🦊 Fox (Khronos 官方样例) - 可直接下载,保证兼容 - 推荐理由:狐狸和柴犬外形相似,作为备选 +3. Cartoon Fox (Sketchfab) + URL: https://sketchfab.com/3d-models/fox-xxx + License: CC BY 4.0 (free, commercial use ok) + Why: expressive face, high detail -你选哪个?确认后我直接下载开始做。 +Which one do you want? I'll download it and start building. ``` **Wait for user confirmation before downloading.** Do not download without asking. @@ -140,90 +140,69 @@ After download, verify: If Sketchfab requires login to download, tell the user: -> "这个模型需要在 Sketchfab 登录后下载。你可以去页面下载 .glb 文件,然后上传给我。或者我用 Khronos 官方样例先做一版演示?" +> "This model requires a Sketchfab login to download. You can grab the .glb file from the page and share it with me. Or I can use a Khronos sample model for a demo version first?" -### Step 5: When user says "随便" / "你定" / "先做个演示" +### Step 5: When user says "anything" / "you decide" / "just make a demo" **Don't just grab a random model.** First guide the user to clarify their PPT topic: -> 好的!模型我来搞定,但先确认一下你的 PPT 主题方向,这样我找的模型才能配合内容: +> Sure! I'll handle the model — but let me confirm the topic direction first so the model matches the content: > -> 1. 🎮 科技/产品 — 耳机、手机、机器人... -> 2. 🐾 动物/角色 — 可爱宠物、卡通人物... -> 3. 🏗️ 建筑/空间 — 房屋、室内、城市... -> 4. 🍕 食物/生活 — 美食、日用品... -> 5. 🚀 其他 — 告诉我你的想法 +> 1. Tech/Product — headphones, phone, robot... +> 2. Animal/Character — cute pet, cartoon character... +> 3. Architecture/Space — building, interior, city... +> 4. Food/Lifestyle — dishes, everyday objects... +> 5. Other — just tell me your idea > -> 选一个方向,或者直接说个主题词也行。 +> Pick a direction, or just give me a topic keyword. After user confirms a direction, THEN search and recommend models. -Only if user explicitly says "真的随便" / "什么都行" / insists on no preference, use a built-in model: - -**Built-in models** (bundled with the skill, no download needed): - -| Model | Path | Best for | -| ---------------- | ------------------ | ----------------------- | -| Shiba Inu (柴犬) | `models/shiba.glb` | 可爱/宠物/品牌/通用演示 | - -```bash -# Copy built-in model to working directory -# The model is bundled at: skills/morph-ppt-3d/models/shiba.glb (relative to officecli repo root) -cp "$(dirname "$0")/models/shiba.glb" ./model.glb 2>/dev/null || \ - curl -L -o model.glb "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/Duck/glTF-Binary/Duck.glb" -``` - -If the built-in model doesn't fit the user's topic, fall back to Khronos samples: - -```bash -curl -L -o model.glb "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/Duck/glTF-Binary/Duck.glb" -``` - ### Step 6: When user wants to find models themselves Give specific website links with step-by-step guidance: -> **推荐的 3D 模型网站:** +> **Recommended 3D model websites:** > -> 1. **Sketchfab** (最大的 3D 模型平台) -> - 链接:https://sketchfab.com/search?q=[关键词]&type=models&downloadable=true -> - 筛选步骤:搜索关键词 → 勾选 "Downloadable" → 格式选 "glTF" → 按 "Likes" 排序 -> - 下载时选 **glTF (.glb)** 格式 -> - 注意:部分模型需要免费注册后才能下载 -> 2. **Poly Pizza** (全免费低多边形) -> - 链接:https://poly.pizza/ -> - 特点:全部免费 CC0 授权,直接点 Download 就是 .glb -> - 适合:简约风格、卡通风格的 PPT -> 3. **Sketchfab 热门分类直达** -> - 动物:https://sketchfab.com/search?q=animal&type=models&downloadable=true -> - 食物:https://sketchfab.com/search?q=food&type=models&downloadable=true -> - 科技:https://sketchfab.com/search?q=gadget&type=models&downloadable=true -> - 建筑:https://sketchfab.com/search?q=architecture&type=models&downloadable=true -> 4. **Free3D** (综合免费模型站) -> - 链接:https://free3d.com/3d-models/glb -> - 注意:需确认授权类型 -> 5. **TurboSquid Free** (专业模型站免费区) -> - 链接:https://www.turbosquid.com/Search/3D-Models/free/glb +> 1. **Sketchfab** (largest 3D model platform) +> - Link: https://sketchfab.com/search?q=[keyword]&type=models&downloadable=true +> - Filter steps: search keyword → check "Downloadable" → format "glTF" → sort by "Likes" +> - When downloading, select **glTF (.glb)** format +> - Note: some models require free registration to download +> 2. **Poly Pizza** (all free low-poly) +> - Link: https://poly.pizza/ +> - All CC0 licensed — click Download to get .glb directly +> - Best for: minimalist or cartoon-style presentations +> 3. **Sketchfab popular categories** +> - Animals: https://sketchfab.com/search?q=animal&type=models&downloadable=true +> - Food: https://sketchfab.com/search?q=food&type=models&downloadable=true +> - Tech: https://sketchfab.com/search?q=gadget&type=models&downloadable=true +> - Architecture: https://sketchfab.com/search?q=architecture&type=models&downloadable=true +> 4. **Free3D** (general free model site) +> - Link: https://free3d.com/3d-models/glb +> - Note: check the license type before use +> 5. **TurboSquid Free** (pro model site free section) +> - Link: https://www.turbosquid.com/Search/3D-Models/free/glb > -> 下载后把 .glb 文件发给我就行。如果下载的是 .gltf(文件夹),需要用 Blender 转成 .glb。 +> After downloading, share the .glb file with me. If the download is a .gltf folder, use Blender to convert it to .glb. ### Step 7: When user gives keywords and asks agent to search **Remind about token cost before searching:** -> 我可以帮你搜索,不过在线搜索会消耗一些额外的对话额度 (token)。你想: +> I can search for you, but web searches use extra tokens. Would you prefer: > -> A. 我来搜 — 我用 Sketchfab API 搜索并推荐 2-3 个(消耗少量 token) -> B. 你自己找 — 我给你搜索链接和筛选教程,你挑好发给我(不消耗额外 token) +> A. I search — I use the Sketchfab API and recommend 2-3 options (uses a few tokens) +> B. Self-service — I give you search links and filter steps, you pick and share with me (no extra tokens) > -> 选 A 还是 B? +> A or B? If user chooses A, proceed with Step 2 (agent-driven search). If user chooses B, proceed with Step 6 (self-service guidance). ### License reminder -Always remind before confirming download: "下载前请确认模型授权。CC0 / CC BY 可免费使用;CC BY-NC 仅限非商用。" +Always remind before confirming download: "Please check the model license before downloading. CC0 / CC BY = free to use; CC BY-NC = non-commercial only." --- @@ -463,7 +442,7 @@ Bleed does NOT work for: - ❌ Small detailed models — cropping loses the detail you want to show - ❌ When the cropped part is the most recognizable feature -**For character/animal models (like shiba, fox, duck):** keep the full model visible on all slides. Use size changes (L→M→S) for rhythm instead of bleed cropping. Use `rotx` for angle variety instead. +**For character/animal models (like fox, duck, avocado):** keep the full model visible on all slides. Use size changes (L→M→S) for rhythm instead of bleed cropping. Use `rotx` for angle variety instead. --- diff --git a/skills/morph-ppt-3d/models/shiba.glb b/skills/morph-ppt-3d/models/shiba.glb deleted file mode 100644 index 5484d56e7..000000000 Binary files a/skills/morph-ppt-3d/models/shiba.glb and /dev/null differ diff --git a/skills/morph-ppt/SKILL.md b/skills/morph-ppt/SKILL.md index 63f50b996..84aada440 100644 --- a/skills/morph-ppt/SKILL.md +++ b/skills/morph-ppt/SKILL.md @@ -105,9 +105,9 @@ For every morph transition, plan the slide pair BEFORE writing any code. Use a t - Do **not** click "Open with system app" during generation, to avoid file lock / write conflicts. - Use clear, direct language and make this a concrete warning, not an optional suggestion. -**FIRST: Ensure latest officecli version** +**FIRST: Install `officecli` if needed** -Follow the installation check in `reference/officecli-pptx-min.md` section 0 (checks version and upgrades only if needed). +Follow the install section in `reference/officecli-pptx-min.md` section 0. **IMPORTANT: Use morph-helpers for reliable workflow** @@ -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/morph-ppt/reference/officecli-pptx-min.md b/skills/morph-ppt/reference/officecli-pptx-min.md index c40bfdd10..8d8d559aa 100644 --- a/skills/morph-ppt/reference/officecli-pptx-min.md +++ b/skills/morph-ppt/reference/officecli-pptx-min.md @@ -3,37 +3,31 @@ name: officecli-commands description: OfficeCli Command Reference — PPT generation and validation commands --- -# OfficeCli PPT Command Reference +# OfficeCLI PPT Command Reference ## 0) BEFORE YOU START (CRITICAL) -**Every time before using officecli, run this check:** +**If `officecli` is not installed:** + +`macOS / Linux` ```bash -# Check if installed -if ! command -v officecli &> /dev/null; then - echo "Installing officecli..." - # macOS/Linux - curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.sh | bash - # Windows: irm https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.ps1 | iex -else - # Check if update needed - CURRENT=$(officecli --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) - LATEST=$(curl -fsSL https://api.github.com/repos/iOfficeAI/OfficeCLI/releases/latest | grep '"tag_name"' | sed -E 's/.*"v?([0-9.]+)".*/\1/') - - if [ "$CURRENT" != "$LATEST" ]; then - echo "Upgrading officecli $CURRENT → $LATEST..." - curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.sh | bash - else - echo "officecli $CURRENT is up to date" - fi +if ! command -v officecli >/dev/null 2>&1; then + curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh | bash fi +``` -# Verify version -officecli --version +`Windows (PowerShell)` + +```powershell +if (-not (Get-Command officecli -ErrorAction SilentlyContinue)) { + irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex +} ``` -**Why:** This ensures you have the latest features and bug fixes before starting work. +Verify: `officecli --version` + +If `officecli` is still not found after first install, open a new terminal and run the verify command again. --- @@ -77,7 +71,7 @@ officecli set demo.pptx '/slide[1]/shape[1]' --prop gradient="linear:90:FF0000,0 officecli validate demo.pptx ``` -**For comprehensive reference:** https://github.com/iOfficeAI/OfficeCli/wiki/agent-guide +**For comprehensive reference:** https://github.com/iOfficeAI/OfficeCLI/wiki/agent-guide --- @@ -124,11 +118,11 @@ officecli close deck.pptx ### More Element Types (when needed, check syntax with `officecli pptx add`) ```bash -officecli add deck.pptx '/slide[1]' --type picture --prop path=photo.jpg --prop width=12cm +officecli add deck.pptx '/slide[1]' --type picture --prop src=photo.jpg --prop width=12cm officecli add deck.pptx '/slide[1]' --type chart --prop chartType=column --prop categories="Q1,Q2" --prop data="Sales:100,200" officecli add deck.pptx '/slide[1]' --type table --prop rows=3 --prop cols=4 officecli add deck.pptx '/slide[1]' --type connector --prop preset=straight --prop line=FF0000 -officecli add deck.pptx '/slide[1]' --type video --prop path=demo.mp4 --prop autoplay=true +officecli add deck.pptx '/slide[1]' --type video --prop src=demo.mp4 --prop autoplay=true ``` --- diff --git a/skills/officecli-academic-paper/SKILL.md b/skills/officecli-academic-paper/SKILL.md index 4a17c3eac..19152c308 100644 --- a/skills/officecli-academic-paper/SKILL.md +++ b/skills/officecli-academic-paper/SKILL.md @@ -12,26 +12,28 @@ Create formally structured Word documents with Table of Contents, equations (LaT ## BEFORE YOU START (CRITICAL) -**Every time before using officecli, run this check:** +**If `officecli` is not installed:** + +`macOS / Linux` ```bash -if ! command -v officecli &> /dev/null; then - echo "Installing officecli..." - curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.sh | bash - # Windows: irm https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.ps1 | iex -else - CURRENT=$(officecli --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) - LATEST=$(curl -fsSL https://api.github.com/repos/iOfficeAI/OfficeCLI/releases/latest | grep '"tag_name"' | sed -E 's/.*"v?([0-9.]+)".*/\1/') - if [ "$CURRENT" != "$LATEST" ]; then - echo "Upgrading officecli $CURRENT -> $LATEST..." - curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.sh | bash - else - echo "officecli $CURRENT is up to date" - fi +if ! command -v officecli >/dev/null 2>&1; then + curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh | bash fi -officecli --version ``` +`Windows (PowerShell)` + +```powershell +if (-not (Get-Command officecli -ErrorAction SilentlyContinue)) { + irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex +} +``` + +Verify: `officecli --version` + +If `officecli` is still not found after first install, open a new terminal and run the verify command again. + --- ## Use When @@ -185,8 +187,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..beedfaa39 100644 --- a/skills/officecli-data-dashboard/SKILL.md +++ b/skills/officecli-data-dashboard/SKILL.md @@ -12,26 +12,28 @@ Create professional, formula-driven Excel dashboards from CSV or tabular data. T ## BEFORE YOU START (CRITICAL) -**Every time before using officecli, run this check:** +**If `officecli` is not installed:** + +`macOS / Linux` ```bash -if ! command -v officecli &> /dev/null; then - echo "Installing officecli..." - curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.sh | bash - # Windows: irm https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.ps1 | iex -else - CURRENT=$(officecli --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) - LATEST=$(curl -fsSL https://api.github.com/repos/iOfficeAI/OfficeCLI/releases/latest | grep '"tag_name"' | sed -E 's/.*"v?([0-9.]+)".*/\1/') - if [ "$CURRENT" != "$LATEST" ]; then - echo "Upgrading officecli $CURRENT -> $LATEST..." - curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.sh | bash - else - echo "officecli $CURRENT is up to date" - fi +if ! command -v officecli >/dev/null 2>&1; then + curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh | bash fi -officecli --version ``` +`Windows (PowerShell)` + +```powershell +if (-not (Get-Command officecli -ErrorAction SilentlyContinue)) { + irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex +} +``` + +Verify: `officecli --version` + +If `officecli` is still not found after first install, open a new terminal and run the verify command again. + --- ## Use When @@ -124,7 +126,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..20e9cf408 100644 --- a/skills/officecli-docx/SKILL.md +++ b/skills/officecli-docx/SKILL.md @@ -9,26 +9,28 @@ description: "Use this skill any time a .docx file is involved -- as input, outp ## BEFORE YOU START (CRITICAL) -**Every time before using officecli, run this check:** +**If `officecli` is not installed:** + +`macOS / Linux` ```bash -if ! command -v officecli &> /dev/null; then - echo "Installing officecli..." - curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.sh | bash - # Windows: irm https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.ps1 | iex -else - CURRENT=$(officecli --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) - LATEST=$(curl -fsSL https://api.github.com/repos/iOfficeAI/OfficeCLI/releases/latest | grep '"tag_name"' | sed -E 's/.*"v?([0-9.]+)".*/\1/') - if [ "$CURRENT" != "$LATEST" ]; then - echo "Upgrading officecli $CURRENT → $LATEST..." - curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.sh | bash - else - echo "officecli $CURRENT is up to date" - fi +if ! command -v officecli >/dev/null 2>&1; then + curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh | bash fi -officecli --version ``` +`Windows (PowerShell)` + +```powershell +if (-not (Get-Command officecli -ErrorAction SilentlyContinue)) { + irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex +} +``` + +Verify: `officecli --version` + +If `officecli` is still not found after first install, open a new terminal and run the verify command again. + --- # officecli: v1.0.23 @@ -368,11 +370,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..43f3a71b5 100644 --- a/skills/officecli-docx/creating.md +++ b/skills/officecli-docx/creating.md @@ -620,22 +620,22 @@ officecli set doc.docx "/body/tbl[1]/tr[1]/tc[1]" --prop "border.bottom=single;6 ```bash # Inline image in body -officecli add doc.docx /body --type picture --prop path=photo.jpg --prop width=15cm --prop height=10cm --prop alt="Team photo" +officecli add doc.docx /body --type picture --prop src=photo.jpg --prop width=15cm --prop height=10cm --prop alt="Team photo" # Image in paragraph (inline with text) -officecli add doc.docx "/body/p[3]" --type picture --prop path=icon.png --prop width=1cm --prop height=1cm --prop alt="Check icon" +officecli add doc.docx "/body/p[3]" --type picture --prop src=icon.png --prop width=1cm --prop height=1cm --prop alt="Check icon" # Image from URL -officecli add doc.docx /body --type picture --prop path=https://example.com/logo.png --prop width=5cm --prop height=3cm --prop alt="Company logo" +officecli add doc.docx /body --type picture --prop src=https://example.com/logo.png --prop width=5cm --prop height=3cm --prop alt="Company logo" # Floating/anchored image -officecli add doc.docx /body --type picture --prop path=sidebar.png --prop width=5cm --prop height=8cm --prop anchor=true --prop wrap=square --prop alt="Sidebar graphic" +officecli add doc.docx /body --type picture --prop src=sidebar.png --prop width=5cm --prop height=8cm --prop anchor=true --prop wrap=square --prop alt="Sidebar graphic" # Image in table cell -officecli add doc.docx "/body/tbl[1]/tr[1]/tc[1]" --type picture --prop path=avatar.jpg --prop width=2cm --prop height=2cm --prop alt="User avatar" +officecli add doc.docx "/body/tbl[1]/tr[1]/tc[1]" --type picture --prop src=avatar.jpg --prop width=2cm --prop height=2cm --prop alt="User avatar" # Replace existing image -officecli set doc.docx "/body/p[5]/r[1]" --prop path=new-photo.jpg +officecli set doc.docx "/body/p[5]/r[1]" --prop src=new-photo.jpg ``` ### Charts @@ -876,23 +876,20 @@ officecli add doc.docx "/body/p[10]" --type break --prop type=column ### Fields ```bash -# Page number field +# Zero-param: pagenum, numpages, date, time, author, title, subject, filename, +# createdate, savedate, printdate, edittime, lastsavedby, numwords, numchars, +# sectionpages, section, revnum, template, comments, keywords officecli add doc.docx "/body/p[1]" --type pagenum - -# Total pages field -officecli add doc.docx "/body/p[1]" --type numpages - -# Date field officecli add doc.docx "/body/p[1]" --type date -# Custom date format -officecli add doc.docx "/body/p[1]" --type field --prop instruction=" DATE \\@ \"yyyy-MM-dd\" " --prop text="2026-01-01" - -# Author field -officecli add doc.docx "/body/p[1]" --type field --prop fieldType=author +# Parameterized fields +officecli add doc.docx "/body/p[1]" --type mergefield --prop fieldName=CustomerName +officecli add doc.docx "/body/p[1]" --type seq --prop identifier=Figure +officecli add doc.docx "/body/p[1]" --type ref --prop bookmarkName=MyBookmark +officecli add doc.docx "/body/p[1]" --type styleref --prop styleName="Heading 1" -# Field at body level (creates paragraph) -officecli add doc.docx /body --type pagenum --prop alignment=center +# Custom instruction (any field code) +officecli add doc.docx "/body/p[1]" --type field --prop instruction=" DATE \\@ \"yyyy-MM-dd\" " ``` ### Comments @@ -963,14 +960,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..24323e5c9 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]" ``` @@ -195,7 +198,7 @@ officecli remove doc.docx "/body/tbl[1]/tr[5]" ```bash # Replace image file -officecli set doc.docx "/body/p[5]/r[1]" --prop path=new-image.jpg +officecli set doc.docx "/body/p[5]/r[1]" --prop src=new-image.jpg # Update image dimensions officecli set doc.docx "/body/p[5]/r[1]" --prop width=12cm --prop height=8cm @@ -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..33404a5f0 100644 --- a/skills/officecli-financial-model/SKILL.md +++ b/skills/officecli-financial-model/SKILL.md @@ -20,26 +20,28 @@ Build formula-driven, multi-sheet financial models from scratch in Excel. Every ## BEFORE YOU START (CRITICAL) -**Every time before using officecli, run this check:** +**If `officecli` is not installed:** + +`macOS / Linux` ```bash -if ! command -v officecli &> /dev/null; then - echo "Installing officecli..." - curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.sh | bash - # Windows: irm https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.ps1 | iex -else - CURRENT=$(officecli --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) - LATEST=$(curl -fsSL https://api.github.com/repos/iOfficeAI/OfficeCLI/releases/latest | grep '"tag_name"' | sed -E 's/.*"v?([0-9.]+)".*/\1/') - if [ "$CURRENT" != "$LATEST" ]; then - echo "Upgrading officecli $CURRENT -> $LATEST..." - curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.sh | bash - else - echo "officecli $CURRENT is up to date" - fi +if ! command -v officecli >/dev/null 2>&1; then + curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh | bash fi -officecli --version ``` +`Windows (PowerShell)` + +```powershell +if (-not (Get-Command officecli -ErrorAction SilentlyContinue)) { + irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex +} +``` + +Verify: `officecli --version` + +If `officecli` is still not found after first install, open a new terminal and run the verify command again. + --- ## Use When @@ -171,6 +173,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..40a980093 100644 --- a/skills/officecli-pitch-deck/SKILL.md +++ b/skills/officecli-pitch-deck/SKILL.md @@ -12,26 +12,28 @@ Create professional pitch presentations from scratch -- investor decks, product ## BEFORE YOU START (CRITICAL) -**Every time before using officecli, run this check:** +**If `officecli` is not installed:** + +`macOS / Linux` ```bash -if ! command -v officecli &> /dev/null; then - echo "Installing officecli..." - curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.sh | bash - # Windows: irm https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.ps1 | iex -else - CURRENT=$(officecli --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) - LATEST=$(curl -fsSL https://api.github.com/repos/iOfficeAI/OfficeCLI/releases/latest | grep '"tag_name"' | sed -E 's/.*"v?([0-9.]+)".*/\1/') - if [ "$CURRENT" != "$LATEST" ]; then - echo "Upgrading officecli $CURRENT -> $LATEST..." - curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.sh | bash - else - echo "officecli $CURRENT is up to date" - fi +if ! command -v officecli >/dev/null 2>&1; then + curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh | bash fi -officecli --version ``` +`Windows (PowerShell)` + +```powershell +if (-not (Get-Command officecli -ErrorAction SilentlyContinue)) { + irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex +} +``` + +Verify: `officecli --version` + +If `officecli` is still not found after first install, open a new terminal and run the verify command again. + --- ## Use When @@ -268,6 +270,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..b2b62798b 100644 --- a/skills/officecli-pptx/SKILL.md +++ b/skills/officecli-pptx/SKILL.md @@ -19,26 +19,28 @@ description: "Use this skill any time a .pptx file is involved -- as input, outp > ``` > 如果看到 `no matches found`,说明引号缺失。 -**Every time before using officecli, run this check:** +**If `officecli` is not installed:** + +`macOS / Linux` ```bash -if ! command -v officecli &> /dev/null; then - echo "Installing officecli..." - curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.sh | bash - # Windows: irm https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.ps1 | iex -else - CURRENT=$(officecli --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) - LATEST=$(curl -fsSL https://api.github.com/repos/iOfficeAI/OfficeCLI/releases/latest | grep '"tag_name"' | sed -E 's/.*"v?([0-9.]+)".*/\1/') - if [ "$CURRENT" != "$LATEST" ]; then - echo "Upgrading officecli $CURRENT → $LATEST..." - curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.sh | bash - else - echo "officecli $CURRENT is up to date" - fi +if ! command -v officecli >/dev/null 2>&1; then + curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh | bash fi -officecli --version ``` +`Windows (PowerShell)` + +```powershell +if (-not (Get-Command officecli -ErrorAction SilentlyContinue)) { + irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex +} +``` + +Verify: `officecli --version` + +If `officecli` is still not found after first install, open a new terminal and run the verify command again. + --- ## Quick Reference @@ -651,13 +653,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/creating.md b/skills/officecli-pptx/creating.md index ddbd06949..897f0bdd9 100644 --- a/skills/officecli-pptx/creating.md +++ b/skills/officecli-pptx/creating.md @@ -145,7 +145,7 @@ officecli add slides.pptx /slide[3] --type shape --prop "text=Our market positio officecli add slides.pptx /slide[3] --type shape --prop preset=roundRect --prop x=18cm --prop y=5cm --prop width=14cm --prop height=12cm --prop fill=E8EDF3 --prop line=CADCFC --prop lineWidth=1pt # Or add an actual image: -# officecli add slides.pptx /slide[3] --type picture --prop path=market-chart.png --prop x=18cm --prop y=5cm --prop width=14cm --prop height=12cm --prop alt="Market share chart" +# officecli add slides.pptx /slide[3] --type picture --prop src=market-chart.png --prop x=18cm --prop y=5cm --prop width=14cm --prop height=12cm --prop alt="Market share chart" ``` ### Stats / Callout Slide (Big Numbers) @@ -304,7 +304,7 @@ officecli add slides.pptx /slide[1] --type shape --prop preset=ellipse --prop fi officecli add slides.pptx /slide[1] --type shape --prop text="01" --prop x=2cm --prop y=5cm --prop width=2.5cm --prop height=2.5cm --prop fill=none --prop color=FFFFFF --prop size=16 --prop bold=true --prop align=center --prop valign=center --prop font=Calibri # Or use an SVG icon file -officecli add slides.pptx /slide[1] --type picture --prop path=icon.svg --prop x=2.3cm --prop y=5.3cm --prop width=1.9cm --prop height=1.9cm --prop alt="Settings icon" +officecli add slides.pptx /slide[1] --type picture --prop src=icon.svg --prop x=2.3cm --prop y=5.3cm --prop width=1.9cm --prop height=1.9cm --prop alt="Settings icon" ``` For icon + text rows, repeat the pattern at consistent vertical intervals (e.g., y=5cm, y=8.5cm, y=12cm) with a bold label and description text box to the right of each circle. @@ -377,22 +377,22 @@ officecli add slides.pptx /slide[1]/shape[1]/paragraph[1] --type run --prop text ```bash # Local file -officecli add slides.pptx /slide[1] --type picture --prop path=photo.jpg --prop x=2cm --prop y=4cm --prop width=14cm --prop height=10cm --prop alt="Team photo" +officecli add slides.pptx /slide[1] --type picture --prop src=photo.jpg --prop x=2cm --prop y=4cm --prop width=14cm --prop height=10cm --prop alt="Team photo" # HTTP URL -officecli add slides.pptx /slide[1] --type picture --prop path=https://example.com/logo.png --prop x=28cm --prop y=16cm --prop width=4cm --prop height=2cm --prop alt="Company logo" +officecli add slides.pptx /slide[1] --type picture --prop src=https://example.com/logo.png --prop x=28cm --prop y=16cm --prop width=4cm --prop height=2cm --prop alt="Company logo" # Base64 data URI officecli add slides.pptx /slide[1] --type picture --prop "path=data:image/png;base64,iVBORw0KGgo..." --prop width=10cm --prop height=8cm # Clipped to circle (for avatars) -officecli add slides.pptx /slide[1] --type picture --prop path=avatar.jpg --prop geometry=ellipse --prop width=5cm --prop height=5cm --prop alt="Profile photo" +officecli add slides.pptx /slide[1] --type picture --prop src=avatar.jpg --prop geometry=ellipse --prop width=5cm --prop height=5cm --prop alt="Profile photo" # Clipped to rounded rectangle -officecli add slides.pptx /slide[1] --type picture --prop path=screenshot.png --prop shape=roundRect --prop x=2cm --prop y=4cm --prop width=14cm --prop height=10cm +officecli add slides.pptx /slide[1] --type picture --prop src=screenshot.png --prop shape=roundRect --prop x=2cm --prop y=4cm --prop width=14cm --prop height=10cm # SVG image (native support, no rasterization needed) -officecli add slides.pptx /slide[1] --type picture --prop path=icon.svg --prop x=2cm --prop y=2cm --prop width=2cm --prop height=2cm --prop alt="Settings icon" +officecli add slides.pptx /slide[1] --type picture --prop src=icon.svg --prop x=2cm --prop y=2cm --prop width=2cm --prop height=2cm --prop alt="Settings icon" ``` Supported formats: png, jpg, gif, bmp, tiff, emf, wmf, svg. HTTP URLs have 30s timeout. @@ -888,10 +888,10 @@ Pattern: Each trapezoid is progressively narrower (x inset increases, width decr ```bash # Embed video with autoplay -officecli add slides.pptx /slide[1] --type video --prop path=demo.mp4 --prop x=3cm --prop y=3cm --prop width=18cm --prop height=10cm --prop autoplay=true +officecli add slides.pptx /slide[1] --type video --prop src=demo.mp4 --prop x=3cm --prop y=3cm --prop width=18cm --prop height=10cm --prop autoplay=true # Background audio -officecli add slides.pptx /slide[1] --type audio --prop path=bgm.mp3 --prop volume=50 --prop autoplay=true +officecli add slides.pptx /slide[1] --type audio --prop src=bgm.mp3 --prop volume=50 --prop autoplay=true ``` ### Equations @@ -912,7 +912,7 @@ officecli add slides.pptx /slide[1] --type zoom --prop target=3 --prop x=2cm --p ```bash # Insert .glb 3D model -officecli add slides.pptx /slide[1] --type 3dmodel --prop path=model.glb --prop x=5cm --prop y=3cm --prop width=12cm --prop height=12cm --prop rotx=30 --prop roty=45 +officecli add slides.pptx /slide[1] --type 3dmodel --prop src=model.glb --prop x=5cm --prop y=3cm --prop width=12cm --prop height=12cm --prop rotx=30 --prop roty=45 ``` ### Groups diff --git a/skills/officecli-pptx/editing.md b/skills/officecli-pptx/editing.md index ca751deea..2bca30a50 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 @@ -242,13 +248,13 @@ officecli set template.pptx /slide[3]/shape[2] --prop "text=Point one\\nPoint tw ```bash # Replace image source -officecli set template.pptx /slide[1]/picture[1] --prop path=new-photo.jpg +officecli set template.pptx /slide[1]/picture[1] --prop src=new-photo.jpg # Update alt text officecli set template.pptx /slide[1]/picture[1] --prop alt="Updated product photo" # Resize and reposition -officecli set template.pptx /slide[2]/picture[1] --prop path=chart-screenshot.png --prop width=14cm --prop height=10cm --prop x=2cm --prop y=5cm +officecli set template.pptx /slide[2]/picture[1] --prop src=chart-screenshot.png --prop width=14cm --prop height=10cm --prop x=2cm --prop y=5cm ``` ### Update Charts @@ -323,7 +329,7 @@ officecli set template.pptx /slide[1] --prop notes="Opening remarks: welcome the officecli add template.pptx /slide[2] --type shape --prop text="Additional Note" --prop x=2cm --prop y=15cm --prop width=20cm --prop height=2cm --prop font=Calibri --prop size=12 --prop color=888888 # Add picture to slide -officecli add template.pptx /slide[3] --type picture --prop path=infographic.png --prop x=18cm --prop y=4cm --prop width=13cm --prop height=12cm --prop alt="Q4 infographic" +officecli add template.pptx /slide[3] --type picture --prop src=infographic.png --prop x=18cm --prop y=4cm --prop width=13cm --prop height=12cm --prop alt="Q4 infographic" # Add chart to slide officecli add template.pptx /slide[5] --type chart --prop chartType=pie --prop categories="A,B,C" --prop data="Share:40,35,25" --prop x=18cm --prop y=4cm --prop width=12cm --prop height=10cm diff --git a/skills/officecli-xlsx/SKILL.md b/skills/officecli-xlsx/SKILL.md index 5bbbfa737..6fb5c3526 100644 --- a/skills/officecli-xlsx/SKILL.md +++ b/skills/officecli-xlsx/SKILL.md @@ -8,26 +8,28 @@ description: "Use this skill any time a .xlsx file is involved -- as input, outp ## BEFORE YOU START (CRITICAL) -**Every time before using officecli, run this check:** +**If `officecli` is not installed:** + +`macOS / Linux` ```bash -if ! command -v officecli &> /dev/null; then - echo "Installing officecli..." - curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.sh | bash - # Windows: irm https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.ps1 | iex -else - CURRENT=$(officecli --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) - LATEST=$(curl -fsSL https://api.github.com/repos/iOfficeAI/OfficeCLI/releases/latest | grep '"tag_name"' | sed -E 's/.*"v?([0-9.]+)".*/\1/') - if [ "$CURRENT" != "$LATEST" ]; then - echo "Upgrading officecli $CURRENT → $LATEST..." - curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCli/main/install.sh | bash - else - echo "officecli $CURRENT is up to date" - fi +if ! command -v officecli >/dev/null 2>&1; then + curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh | bash fi -officecli --version ``` +`Windows (PowerShell)` + +```powershell +if (-not (Get-Command officecli -ErrorAction SilentlyContinue)) { + irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex +} +``` + +Verify: `officecli --version` + +If `officecli` is still not found after first install, open a new terminal and run the verify command again. + --- ## Quick Reference @@ -407,11 +409,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/creating.md b/skills/officecli-xlsx/creating.md index 533a49b67..3003f2151 100644 --- a/skills/officecli-xlsx/creating.md +++ b/skills/officecli-xlsx/creating.md @@ -981,7 +981,7 @@ officecli add data.xlsx / --type namedrange --prop name="DataRange" --prop ref=" ### Pictures ```bash -officecli add data.xlsx /Sheet1 --type picture --prop path=logo.png --prop x=1 --prop y=1 --prop width=3 --prop height=2 --prop alt="Company logo" +officecli add data.xlsx /Sheet1 --type picture --prop src=logo.png --prop x=1 --prop y=1 --prop width=3 --prop height=2 --prop alt="Company logo" ``` ### Comments 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/Core/BatchTypes.cs b/src/officecli/BatchTypes.cs similarity index 95% rename from src/officecli/Core/BatchTypes.cs rename to src/officecli/BatchTypes.cs index a52de1c2b..4419f92b8 100644 --- a/src/officecli/Core/BatchTypes.cs +++ b/src/officecli/BatchTypes.cs @@ -4,7 +4,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace OfficeCli.Core; +namespace OfficeCli; internal class LenientStringDictionaryConverter : JsonConverter> { @@ -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; @@ -189,7 +195,7 @@ internal class BatchResultConverter : JsonConverter if (root.TryGetProperty("success", out var suc)) result.Success = suc.GetBoolean(); if (root.TryGetProperty("output", out var outp)) result.Output = outp.ValueKind == JsonValueKind.String ? outp.GetString() : outp.GetRawText(); if (root.TryGetProperty("error", out var err)) result.Error = err.GetString(); - if (root.TryGetProperty("item", out var itm)) result.Item = JsonSerializer.Deserialize(itm.GetRawText(), BatchJsonContext.Default.Options); + if (root.TryGetProperty("item", out var itm)) result.Item = JsonSerializer.Deserialize(itm.GetRawText(), BatchJsonContext.Default.BatchItem); return result; } @@ -218,7 +224,7 @@ public override void Write(Utf8JsonWriter writer, BatchResult value, JsonSeriali if (value.Item != null) { writer.WritePropertyName("item"); - JsonSerializer.Serialize(writer, value.Item, BatchJsonContext.Default.Options); + JsonSerializer.Serialize(writer, value.Item, BatchJsonContext.Default.BatchItem); } } writer.WriteEndObject(); 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..4211f903c 100644 --- a/src/officecli/CommandBuilder.Add.cs +++ b/src/officecli/CommandBuilder.Add.cs @@ -3,6 +3,7 @@ using System.CommandLine; using OfficeCli.Core; +using OfficeCli.Handlers; namespace OfficeCli; @@ -12,10 +13,12 @@ private static Command BuildAddCommand(Option jsonOption) { var addFileArg = new Argument("file") { Description = "Office document path (required even with open/close mode)" }; var addParentPathArg = new Argument("parent") { Description = "Parent DOM path (e.g. /body, /Sheet1, /slide[1])" }; - var addTypeOpt = new Option("--type") { Description = "Element type to add (e.g. paragraph, run, table, sheet, row, cell, slide, shape)" }; + var addTypeOpt = new Option("--type") { Description = "Element type to add (e.g. paragraph, run, table, sheet, row, cell, slide, shape, picture, ole, video)" }; 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 addPropsOpt = new Option("--prop") { Description = "Property to set (key=value)", AllowMultipleArgumentsPerToken = true }; + 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, e.g. --prop src=image.png --prop width=6in)", AllowMultipleArgumentsPerToken = true }; var forceOption = new Option("--force") { Description = "Force write even if document is protected" }; var addCommand = new Command("add", "Add a new element to the document") { TreatUnmatchedTokensAsErrors = false }; @@ -24,6 +27,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 +40,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 @@ -65,7 +86,7 @@ private static Command BuildAddCommand(Option jsonOption) { foreach (var kv in unmatchedKvWarnings) Console.Error.WriteLine($"WARNING: Bare property '{kv}' ignored. Did you mean: --prop {kv}"); - Console.Error.WriteLine("Hint: Properties must be passed with --prop flag, e.g. officecli add --type --prop key=value"); + Console.Error.WriteLine("Hint: Properties must be passed with --prop flag, e.g. officecli add --type picture --prop src=image.png"); } } @@ -75,7 +96,7 @@ private static Command BuildAddCommand(Option jsonOption) { Code = "missing_argument", Suggestion = "Use --type to specify element type, or --from to copy an existing element.", - Help = "officecli add --type --prop key=value" + Help = "officecli add --type --prop src=" }; } @@ -87,12 +108,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,24 +129,22 @@ 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); - var properties = new Dictionary(); - foreach (var prop in props ?? Array.Empty()) - { - var eqIdx = prop.IndexOf('='); - if (eqIdx > 0) - { - properties[prop[..eqIdx]] = prop[(eqIdx + 1)..]; - } - } + // CONSISTENCY(prop-key-case): --prop keys are case-insensitive + // so "SRC=x" and "src=x" both resolve to the same handler key. + // Reuse ParsePropsArray so the inline and resident-server paths + // stay in sync. + var properties = ParsePropsArray(props); 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 message = $"Added {type} at {resultPath}"; + var resultPath = handler.Add(parentPath, type!, position, properties); + var message = $"Added {type!.ToLowerInvariant()} at {resultPath}"; var spatialLine = GetPptSpatialLine(handler, resultPath); var overlapNames = spatialLine != null ? CheckPositionOverlap(handler, resultPath) : new(); var addWarnings = new List(); @@ -214,12 +235,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 +253,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 +291,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..604c1e1a7 100644 --- a/src/officecli/CommandBuilder.Batch.cs +++ b/src/officecli/CommandBuilder.Batch.cs @@ -3,6 +3,7 @@ using System.CommandLine; using OfficeCli.Core; +using OfficeCli.Handlers; namespace OfficeCli; @@ -76,42 +77,71 @@ private static Command BuildBatchCommand(Option jsonOption) return 0; } - // If a resident process is running, forward each command to it - if (ResidentClient.TryConnect(file.FullName, out _)) + // 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)) { - var results = new List(); - for (int bi = 0; bi < items.Count; bi++) + foreach (var batchItem in items) { - var item = items[bi]; - var req = item.ToResidentRequest(); - req.Json = json; - var response = ResidentClient.TrySend(file.FullName, req); - if (response == null) - { - results.Add(new BatchResult { Index = bi, Success = false, Item = item, Error = "Failed to send to resident" }); - if (stopOnError) break; + // 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; - } - var success = response.ExitCode == 0; - var output = response.Stdout; - // Unwrap resident envelope: extract "data" or "message" from {"success":...,"data":...} / {"success":...,"message":"..."} - if (output != null && json) + // 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, send the entire batch as a + // single "batch" command so it executes in one open/save cycle + // inside the resident process (same semantics as non-resident mode). + if (ResidentClient.TryConnect(file.FullName, out _)) + { + var req = new ResidentRequest + { + Command = "batch", + Json = json, + Args = { - try - { - using var envDoc = System.Text.Json.JsonDocument.Parse(output); - if (envDoc.RootElement.TryGetProperty("data", out var data)) - output = data.GetRawText(); - else if (envDoc.RootElement.TryGetProperty("message", out var msg)) - output = msg.GetString(); - } - catch { /* not JSON envelope, use as-is */ } + ["batchJson"] = jsonText, + ["force"] = force.ToString() } - results.Add(new BatchResult { Index = bi, Success = success, Item = !success ? item : null, Output = output, Error = response.Stderr }); - if (!success && stopOnError) break; + }; + // CONSISTENCY(resident-two-step): long connectTimeoutMs so the + // batch waits for its turn in the main-pipe queue instead of + // silently timing out under load. Matches TryResident in + // CommandBuilder.cs. + var response = ResidentClient.TrySend(file.FullName, req, maxRetries: 3, connectTimeoutMs: 30000); + if (response == null) + { + Console.Error.WriteLine($"Resident for {file.Name} is running but the batch could not be delivered (main pipe busy or unresponsive). Retry, or run 'officecli close {file.Name}' and try again."); + return 3; } - PrintBatchResults(results, json, items.Count); - return results.Any(r => !r.Success) ? 1 : 0; + // The resident returns the formatted batch output directly + if (!string.IsNullOrEmpty(response.Stdout)) + Console.Write(response.Stdout); + if (!string.IsNullOrEmpty(response.Stderr)) + Console.Error.Write(response.Stderr); + return response.ExitCode; } // Non-resident: open file once, execute all commands, save once diff --git a/src/officecli/CommandBuilder.Check.cs b/src/officecli/CommandBuilder.Check.cs index 1b9cf5cb6..43799b00a 100644 --- a/src/officecli/CommandBuilder.Check.cs +++ b/src/officecli/CommandBuilder.Check.cs @@ -3,6 +3,7 @@ using System.CommandLine; using OfficeCli.Core; +using OfficeCli.Handlers; namespace OfficeCli; @@ -53,77 +54,4 @@ private static Command BuildValidateCommand(Option jsonOption) return validateCommand; } - - private static Command BuildCheckCommand(Option jsonOption) - { - var checkFileArg = new Argument("file") { Description = "Office document path (.pptx)" }; - var checkCommand = new Command("check", "Scan document for layout issues (text overflow, etc.)"); - checkCommand.Add(checkFileArg); - checkCommand.Add(jsonOption); - checkCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() => - { - var file = result.GetValue(checkFileArg)!; - var ext = file.Extension.ToLowerInvariant(); - if (ext != ".pptx") - throw new OfficeCli.Core.CliException("The 'check' command currently supports .pptx files only. Provide a .pptx file path."); - - using var handler = DocumentHandlerFactory.Open(file.FullName, editable: false); - var pptHandler = handler as OfficeCli.Handlers.PowerPointHandler - ?? throw new OfficeCli.Core.CliException("Failed to open file as PowerPoint document."); - - var issues = new List<(string Path, string Message)>(); - var root = pptHandler.Get("/"); - int slideCount = root?.Children?.Count ?? 0; - for (int s = 1; s <= slideCount; s++) - { - var slideNode = pptHandler.Get($"/slide[{s}]"); - int shapeCount = slideNode?.Children?.Count ?? 0; - for (int sh = 1; sh <= shapeCount; sh++) - { - var shapePath = $"/slide[{s}]/shape[{sh}]"; - var warning = pptHandler.CheckShapeTextOverflow(shapePath); - if (warning != null) - issues.Add((shapePath, warning)); - } - } - - if (json) - { - var arr = new System.Text.Json.Nodes.JsonArray(); - foreach (var (path, msg) in issues) - { - arr.Add((System.Text.Json.Nodes.JsonNode)new System.Text.Json.Nodes.JsonObject - { - ["path"] = path, - ["issue"] = msg - }); - } - var envelope = new System.Text.Json.Nodes.JsonObject - { - ["success"] = true, - ["file"] = file.FullName, - ["issueCount"] = issues.Count, - ["issues"] = arr - }; - Console.WriteLine(envelope.ToJsonString(OutputFormatter.PublicJsonOptions)); - } - else - { - Console.WriteLine($"Checking layout: {file.FullName}"); - if (issues.Count == 0) - { - Console.WriteLine("No layout issues found."); - } - else - { - foreach (var (path, msg) in issues) - Console.WriteLine($" {path}: {msg}"); - Console.WriteLine($"Found {issues.Count} layout issue(s)."); - } - } - return issues.Count > 0 ? 2 : 0; - }, json); }); - - return checkCommand; - } } diff --git a/src/officecli/CommandBuilder.GetQuery.cs b/src/officecli/CommandBuilder.GetQuery.cs index 5d5c619f8..ce973eb78 100644 --- a/src/officecli/CommandBuilder.GetQuery.cs +++ b/src/officecli/CommandBuilder.GetQuery.cs @@ -3,6 +3,7 @@ using System.CommandLine; using OfficeCli.Core; +using OfficeCli.Handlers; namespace OfficeCli; @@ -11,15 +12,17 @@ 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; + var saveOpt = new Option("--save") { Description = "Extract the backing binary payload (picture/ole/media) to this file path" }; var getCommand = new Command("get", "Get a document node by path"); getCommand.Add(getFileArg); getCommand.Add(pathArg); getCommand.Add(depthOpt); + getCommand.Add(saveOpt); getCommand.Add(jsonOption); getCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() => @@ -27,6 +30,14 @@ private static Command BuildGetCommand(Option jsonOption) var file = result.GetValue(getFileArg)!; var path = result.GetValue(pathArg)!; var depth = result.GetValue(depthOpt); + var savePath = result.GetValue(saveOpt); + + // 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 => { @@ -34,10 +45,34 @@ private static Command BuildGetCommand(Option jsonOption) req.Json = json; req.Args["path"] = path; req.Args["depth"] = depth.ToString(); + if (!string.IsNullOrEmpty(savePath)) req.Args["save"] = savePath; }, json) is {} rc) return rc; using var handler = DocumentHandlerFactory.Open(file.FullName); var node = handler.Get(path, depth); + + // --save : extract the binary payload backing an OLE / + // picture / media node to disk. The handler exposes this via + // TryExtractBinary which looks up the node's relId and copies + // the part's stream. When the node has no backing binary, we + // surface a clear error instead of silently succeeding. + if (!string.IsNullOrEmpty(savePath)) + { + if (!handler.TryExtractBinary(path, savePath, out var contentType, out var byteCount)) + { + var err = $"Node at '{path}' has no binary payload to extract (only ole/picture/media/embedded nodes can be saved)."; + if (json) + Console.WriteLine(OutputFormatter.WrapEnvelopeError(err)); + else + Console.Error.WriteLine($"Error: {err}"); + return 1; + } + node.Format["savedTo"] = savePath; + node.Format["savedBytes"] = byteCount; + if (!string.IsNullOrEmpty(contentType)) + node.Format["savedContentType"] = contentType!; + } + if (json) Console.WriteLine(OutputFormatter.WrapEnvelope( OutputFormatter.FormatNode(node, OutputFormat.Json))); @@ -49,6 +84,62 @@ 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 + } + } + } + + // Flatten row/column nodes into their children so text output is + // grep-friendly (one cell per line instead of a single "/Sheet1/col[C]" line). + var flat = new List(); + foreach (var n in nodes) + { + if (n.Children.Count > 0 && n.Type is "column" or "row") + flat.AddRange(n.Children); + else + flat.Add(n); + } + + if (json) + { + Console.WriteLine(OutputFormatter.WrapEnvelope( + OutputFormatter.FormatNodes(flat, OutputFormat.Json))); + } + else + { + Console.WriteLine(OutputFormatter.FormatNodes(flat, 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)" }; @@ -93,7 +184,14 @@ private static Command BuildQueryCommand(Option jsonOption) else { foreach (var w in warnings) Console.Error.WriteLine(w); - Console.WriteLine(OutputFormatter.FormatNodes(results, OutputFormat.Text)); + var output = OutputFormatter.FormatNodes(results, OutputFormat.Text); + if (!string.IsNullOrEmpty(output)) + Console.WriteLine(output); + if (results.Count == 0) + { + var ext = file.Extension.ToLowerInvariant().TrimStart('.'); + Console.Error.WriteLine($"No matches. Run 'officecli {ext} query' for selector syntax."); + } } return 0; }, json); }); diff --git a/src/officecli/CommandBuilder.Import.cs b/src/officecli/CommandBuilder.Import.cs index 1bcec670d..1006a23d9 100644 --- a/src/officecli/CommandBuilder.Import.cs +++ b/src/officecli/CommandBuilder.Import.cs @@ -104,6 +104,8 @@ private static Command BuildImportCommand(Option jsonOption) delimiter = '\t'; } + // Release any running resident's file lock before direct-open (import bypasses resident) + ResidentClient.SendClose(file.FullName); using var handler = new OfficeCli.Handlers.ExcelHandler(file.FullName, editable: true); var msg = handler.Import(parentPath, csvContent, delimiter, header, startCell); if (json) @@ -151,13 +153,34 @@ private static Command BuildCreateCommand(Option jsonOption) OfficeCli.BlankDocCreator.Create(file); var fullCreatedPath = Path.GetFullPath(file); + + // Best-effort: auto-start a short-lived resident process so + // follow-up commands on this freshly-created file hit the + // in-memory handler instead of re-opening from disk each time. + // Uses a 60s idle timeout (much shorter than `open`'s default + // 12min) so a stray `create` with no follow-up exits quickly. + // Failure here does NOT fail the command — the file is already + // on disk and all other commands still work via direct open. + var noAuto = Environment.GetEnvironmentVariable("OFFICECLI_NO_AUTO_RESIDENT"); + string? residentErr = null; + var residentStarted = noAuto == "1" || string.Equals(noAuto, "true", StringComparison.OrdinalIgnoreCase) + ? false + : TryStartResidentProcess(fullCreatedPath, idleSeconds: 60, out residentErr); + var residentSuffix = residentStarted + ? " (kept open in background for faster subsequent commands)" + : ""; + if (json) { - Console.WriteLine(OutputFormatter.WrapEnvelopeText($"Created: {fullCreatedPath}")); + Console.WriteLine(OutputFormatter.WrapEnvelopeText($"Created: {fullCreatedPath}{residentSuffix}")); } else { - Console.WriteLine($"Created: {file}"); + Console.WriteLine($"Created: {file}{residentSuffix}"); + if (!residentStarted && !string.IsNullOrEmpty(residentErr)) + { + Console.Error.WriteLine($"Note: resident auto-start failed ({residentErr}); falling back to direct file access."); + } if (Path.GetExtension(file).Equals(".pptx", StringComparison.OrdinalIgnoreCase)) { Console.WriteLine($" totalSlides: 0"); 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..539bc987f 100644 --- a/src/officecli/CommandBuilder.Raw.cs +++ b/src/officecli/CommandBuilder.Raw.cs @@ -3,6 +3,7 @@ using System.CommandLine; using OfficeCli.Core; +using OfficeCli.Handlers; namespace OfficeCli; @@ -11,7 +12,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..75fa3abe2 100644 --- a/src/officecli/CommandBuilder.Set.cs +++ b/src/officecli/CommandBuilder.Set.cs @@ -3,6 +3,7 @@ using System.CommandLine; using OfficeCli.Core; +using OfficeCli.Handlers; namespace OfficeCli; @@ -29,6 +30,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; @@ -71,19 +103,26 @@ private static Command BuildSetCommand(Option jsonOption) req.Props = ParsePropsArray(props); }, json) is {} rc) return rc; - var properties = new Dictionary(); - foreach (var prop in props ?? Array.Empty()) - { - var eqIdx = prop.IndexOf('='); - if (eqIdx > 0) - { - properties[prop[..eqIdx]] = prop[(eqIdx + 1)..]; - } - } + // CONSISTENCY(prop-key-case): --prop keys are case-insensitive + // so "SRC=x" and "src=x" both resolve to the same handler key. + // Reuse ParsePropsArray so the inline and resident-server paths + // stay in sync. + var properties = ParsePropsArray(props); 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 +131,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,9 +155,23 @@ 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}"))}" - : $"No properties applied to {path}"; + + (findMatchCount.HasValue ? $" ({findMatchCount.Value} matched)" : "") + : $"Error: No properties applied to {path}"; // Check if position-related props were changed → show coordinates + overlap warning var positionChanged = applied.Any(kv => PositionKeys.Contains(kv.Key)); @@ -144,7 +197,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 +228,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 +242,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.View.cs b/src/officecli/CommandBuilder.View.cs index 4733e2b86..665e8bdac 100644 --- a/src/officecli/CommandBuilder.View.cs +++ b/src/officecli/CommandBuilder.View.cs @@ -3,6 +3,7 @@ using System.CommandLine; using OfficeCli.Core; +using OfficeCli.Handlers; namespace OfficeCli; @@ -84,7 +85,12 @@ private static Command BuildViewCommand(Option jsonOption) if (browser) { // --browser: write to temp file and open in browser - var htmlPath = Path.Combine(Path.GetTempPath(), $"officecli_preview_{Path.GetFileNameWithoutExtension(file.Name)}_{DateTime.Now:HHmmss}.html"); + // SECURITY: include a random token so the preview path is not predictable. + // A predictable path (HHmmss only) lets a local attacker pre-place a symlink + // at the expected location, causing File.WriteAllText to follow it and + // overwrite an arbitrary victim file with preview HTML. It also caused + // collisions between concurrent `view html` invocations of the same file. + var htmlPath = Path.Combine(Path.GetTempPath(), $"officecli_preview_{Path.GetFileNameWithoutExtension(file.Name)}_{DateTime.Now:HHmmss}_{Guid.NewGuid():N}.html"); File.WriteAllText(htmlPath, html); Console.WriteLine(htmlPath); try diff --git a/src/officecli/CommandBuilder.Watch.cs b/src/officecli/CommandBuilder.Watch.cs index 00cf3109c..70ba004c7 100644 --- a/src/officecli/CommandBuilder.Watch.cs +++ b/src/officecli/CommandBuilder.Watch.cs @@ -3,6 +3,7 @@ using System.CommandLine; using OfficeCli.Core; +using OfficeCli.Handlers; namespace OfficeCli; @@ -23,27 +24,68 @@ private static Command BuildWatchCommand() var file = result.GetValue(watchFileArg)!; var port = result.GetValue(watchPortOpt); - // Render initial HTML from existing file content + // Render initial HTML: ask the resident process if one is running, + // otherwise open the file directly as a fallback. string? initialHtml = null; if (file.Exists) { - try + // Try resident first — avoids file lock conflict. + // Json=true makes resident return raw HTML via Console.Write; + // the resident then wraps it in a JSON envelope { "success":true, "message":"..." }. + var resp = ResidentClient.TrySend(file.FullName, + new ResidentRequest { Command = "view", Args = new() { ["mode"] = "html" }, Json = true }, + connectTimeoutMs: 2000); + if (resp is { ExitCode: 0 } && !string.IsNullOrEmpty(resp.Stdout)) { - using var handler = DocumentHandlerFactory.Open(file.FullName, editable: false); - if (handler is OfficeCli.Handlers.PowerPointHandler ppt) - initialHtml = ppt.ViewAsHtml(); - else if (handler is OfficeCli.Handlers.ExcelHandler excel) - initialHtml = excel.ViewAsHtml(); - else if (handler is OfficeCli.Handlers.WordHandler word) - initialHtml = word.ViewAsHtml(); + try + { + using var doc = System.Text.Json.JsonDocument.Parse(resp.Stdout); + if (doc.RootElement.TryGetProperty("message", out var msg)) + initialHtml = msg.GetString(); + } + catch { /* parse failed — fall through to direct open */ } + } + else + { + // No resident — open directly + try + { + using var handler = DocumentHandlerFactory.Open(file.FullName, editable: false); + if (handler is OfficeCli.Handlers.PowerPointHandler ppt) + initialHtml = ppt.ViewAsHtml(); + else if (handler is OfficeCli.Handlers.ExcelHandler excel) + initialHtml = excel.ViewAsHtml(); + else if (handler is OfficeCli.Handlers.WordHandler word) + initialHtml = word.ViewAsHtml(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: initial render failed — preview will show 'Waiting for first update' until the next document change."); + Console.Error.WriteLine($" {ex.GetType().Name}: {ex.Message}"); + if (Environment.GetEnvironmentVariable("OFFICECLI_DEBUG") == "1" && ex.StackTrace != null) + Console.Error.WriteLine(ex.StackTrace); + } } - catch { /* ignore — will show waiting page */ } } using var cts = new CancellationTokenSource(); - Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; using var watch = new WatchServer(file.FullName, port, initialHtml: initialHtml); + // Signal handling (SIGTERM / SIGINT / SIGHUP / SIGQUIT) is + // now registered inside WatchServer.RunAsync via + // PosixSignalRegistration, which runs BEFORE the .NET runtime + // begins its shutdown sequence (on a healthy ThreadPool). + // That path runs StopAsync to completion — including + // TcpListener.Stop() (the only reliable way to unstick + // AcceptTcpClientAsync on macOS) and the CoreFxPipe_ socket + // cleanup (BUG-BT-003) — before calling Environment.Exit. + // + // The older Console.CancelKeyPress + ProcessExit combo was + // unreliable: SIGINT would cancel _cts but the TCP accept + // loop did not honour cancellation on macOS, hanging the + // process for 15+ seconds; ProcessExit ran during runtime + // teardown when ThreadPool was already unwinding, so the + // socket cleanup silently skipped. watch.RunAsync(cts.Token).GetAwaiter().GetResult(); return 0; })); diff --git a/src/officecli/CommandBuilder.cs b/src/officecli/CommandBuilder.cs index 0bb252642..ba47f3c50 100644 --- a/src/officecli/CommandBuilder.cs +++ b/src/officecli/CommandBuilder.cs @@ -3,8 +3,10 @@ using System.CommandLine; using System.Diagnostics; +using System.Runtime.InteropServices; using System.Text; using OfficeCli.Core; +using OfficeCli.Handlers; namespace OfficeCli; @@ -37,53 +39,31 @@ officecli pptx set shape.fill Specific property format and examples var file = result.GetValue(openFileArg)!; var filePath = file.FullName; - // If already running, reuse the existing resident + // If already running, reuse the existing resident. This covers + // two cases with the same code path: + // (a) user previously called `open` explicitly, or + // (b) `create` just auto-started a short-lived (60s) resident. + // In either case we upgrade the idle timeout to the default 12min + // via the __set-idle-timeout__ ping RPC. Failure is non-fatal — + // the resident is still usable, it'll just exit on its original + // schedule. `open` is idempotent, so repeated calls are safe. + const int DefaultOpenIdleSeconds = 12 * 60; if (ResidentClient.TryConnect(filePath, out _)) { - var msg = $"Opened {file.Name} (already running, do NOT call close)"; + ResidentClient.SendSetIdleTimeout(filePath, DefaultOpenIdleSeconds); + var msg = $"Opened {file.Name} (reusing running resident, idle timeout set to 12min)"; if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(msg)); else Console.WriteLine(msg); return 0; } - // Fork a background process running the resident server - var exePath = Environment.ProcessPath ?? Process.GetCurrentProcess().MainModule?.FileName; - if (exePath == null) - throw new InvalidOperationException("Cannot determine executable path."); + if (!TryStartResidentProcess(filePath, idleSeconds: null, out var startError)) + throw new InvalidOperationException(startError); - var startInfo = new ProcessStartInfo - { - FileName = exePath, - Arguments = $"__resident-serve__ \"{filePath}\"", - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true - }; - - var process = Process.Start(startInfo); - if (process == null) - throw new InvalidOperationException("Failed to start resident process."); - - // Wait briefly for the server to start accepting connections - for (int i = 0; i < 50; i++) // up to 5 seconds - { - Thread.Sleep(100); - if (ResidentClient.TryConnect(filePath, out _)) - { - var msg = $"Opened {file.Name} (remember to call close when done)"; - if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(msg)); - else Console.WriteLine(msg); - return 0; - } - if (process.HasExited) - { - var stderr = process.StandardError.ReadToEnd(); - throw new InvalidOperationException($"Resident process exited. {stderr}"); - } - } - - throw new InvalidOperationException("Resident process started but not responding."); + var startedMsg = $"Opened {file.Name} (remember to call close when done)"; + if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(startedMsg)); + else Console.WriteLine(startedMsg); + return 0; }, json); }); rootCommand.Add(openCommand); @@ -130,6 +110,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,11 +120,11 @@ 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)); rootCommand.Add(BuildValidateCommand(jsonOption)); - rootCommand.Add(BuildCheckCommand(jsonOption)); rootCommand.Add(BuildBatchCommand(jsonOption)); rootCommand.Add(BuildImportCommand(jsonOption)); rootCommand.Add(BuildCreateCommand(jsonOption)); @@ -152,16 +135,203 @@ officecli pptx set shape.fill Specific property format and examples return rootCommand; } + // ==================== Helper: fork a __resident-serve__ subprocess ==================== + // + // Used by both `open` (explicit) and `create` (auto-start after + // creating a blank file). Forks the current executable with the + // internal __resident-serve__ verb and waits up to 5s for the ping + // pipe to respond, so callers get a definitive success/fail answer. + // + // `idleSeconds` overrides the child's idle-exit timeout via the + // OFFICECLI_RESIDENT_IDLE_SECONDS env var (1..86400). Passing null + // inherits the server default (12 minutes). `create` passes 60 so + // an auto-started resident that nobody follows up on exits quickly. + // + // Caller must first verify no resident is already running for this + // file (e.g. via ResidentClient.TryConnect) — this helper always + // starts a fresh child. + internal static bool TryStartResidentProcess(string filePath, int? idleSeconds, out string? error) + { + error = null; + var exePath = Environment.ProcessPath ?? Process.GetCurrentProcess().MainModule?.FileName; + if (exePath == null) + { + error = "Cannot determine executable path."; + return false; + } + + // On Windows, .NET's UseShellExecute=false always calls CreateProcess + // with bInheritHandles=TRUE (even without explicit redirects), which + // leaks the caller's pipe handles into the resident child. When the + // caller's stdout is a pipe ($(), | cat, CI, SDK), the pipe never + // gets EOF until the resident exits (~60s idle), blocking the caller. + // + // Fix: temporarily mark our own std handles as non-inheritable before + // spawning, then restore. This prevents the shell's pipe handles + // from leaking into the resident while still allowing .NET's internal + // handle plumbing to work. + // + // On macOS/Linux, posix_spawn inherits fds unless the child's + // stdout/stderr are explicitly redirected. RedirectStandardOutput / + // RedirectStandardError = true makes .NET plumb a fresh pipe from + // parent to child, so the caller's shell pipe (e.g. `| tail -1`, + // $(...)) is NOT inherited and EOFs promptly when the client exits. + // See ResidentStdoutInheritanceTests for the regression lock-in. + var startInfo = new ProcessStartInfo + { + FileName = exePath, + Arguments = $"__resident-serve__ \"{filePath}\"", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + if (idleSeconds.HasValue) + startInfo.Environment["OFFICECLI_RESIDENT_IDLE_SECONDS"] = idleSeconds.Value.ToString(); + + // Prevent the shell's pipe handles from leaking into the resident. + bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + nint hStdOut = 0, hStdErr = 0, hStdIn = 0; + if (isWindows) + { + hStdOut = GetStdHandle(STD_OUTPUT_HANDLE); + hStdErr = GetStdHandle(STD_ERROR_HANDLE); + hStdIn = GetStdHandle(STD_INPUT_HANDLE); + SetHandleInformation(hStdOut, HANDLE_FLAG_INHERIT, 0); + SetHandleInformation(hStdErr, HANDLE_FLAG_INHERIT, 0); + SetHandleInformation(hStdIn, HANDLE_FLAG_INHERIT, 0); + } + + Process? process; + try { process = Process.Start(startInfo); } + finally + { + if (isWindows) + { + SetHandleInformation(hStdOut, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); + SetHandleInformation(hStdErr, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); + SetHandleInformation(hStdIn, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); + } + } + + if (process == null) + { + error = "Failed to start resident process."; + return false; + } + + // Wait briefly for the server to start accepting connections. + for (int i = 0; i < 50; i++) // up to 5 seconds + { + Thread.Sleep(100); + if (ResidentClient.TryConnect(filePath, out _)) + { + process.Dispose(); + return true; + } + if (process.HasExited) + { + var stderr = process.StandardError.ReadToEnd(); + error = $"Resident process exited. {stderr}"; + process.Dispose(); + return false; + } + } + + error = "Resident process started but not responding."; + process.Dispose(); + return false; + } + + // ==================== Win32 P/Invoke for handle inheritance control ========== + + private const int STD_INPUT_HANDLE = -10; + private const int STD_OUTPUT_HANDLE = -11; + private const int STD_ERROR_HANDLE = -12; + private const uint HANDLE_FLAG_INHERIT = 0x00000001; + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern nint GetStdHandle(int nStdHandle); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetHandleInformation(nint hObject, uint dwMask, uint dwFlags); + // ==================== Helper: try forwarding to resident ==================== + // + // Two-step protocol (CONSISTENCY(resident-two-step): same shape as + // CommandBuilder.Batch.cs's resident branch): + // 1. Ping-pipe probe via TryConnect — fast (100ms) and isolated from the + // main command queue, so it stays responsive even under flood. Tells + // us definitively whether a resident owns this file. + // 2. If yes, send the command on the main pipe with a generous connect + // timeout + a few retries. If the send STILL fails, surface a + // distinct "busy" error (exit code 3) instead of falling back to + // DocumentHandlerFactory.Open — the old silent fallback could race + // the live resident and lose writes. + // 3. If no resident, return null so the caller opens the file directly. + // + // Exit code 3 is reserved for "resident is alive but couldn't deliver the + // command" so callers can distinguish it from a command-level failure. + private const int ResidentBusyExitCode = 3; + private const int ResidentBusyConnectTimeoutMs = 30000; + private const int ResidentBusyMaxRetries = 3; + internal static int? TryResident(string filePath, Action configure, bool json = false) { + // Step 1: does a resident own this file? Probe via the -ping pipe, + // which is never serialized behind main-pipe commands. + if (!ResidentClient.TryConnect(filePath, out _)) + { + // No resident running — auto-start one to avoid file-lock conflicts + // when multiple commands hit the same file in parallel. + // Opt-out: OFFICECLI_NO_AUTO_RESIDENT=1 disables auto-start (e.g. + // sandbox environments where named pipes may not work reliably). + var noAuto = Environment.GetEnvironmentVariable("OFFICECLI_NO_AUTO_RESIDENT"); + if (noAuto == "1" || string.Equals(noAuto, "true", StringComparison.OrdinalIgnoreCase)) + return null; + + if (!TryStartResidentProcess(filePath, idleSeconds: 60, out _)) + { + // Startup failed — maybe another process just started a resident + // for the same file (parallel race). Re-probe before giving up. + if (!ResidentClient.TryConnect(filePath, out _)) + return null; // truly no resident → caller falls back to direct file access + } + // Intentionally no user-facing hint here. UX testing with an AI + // agent showed a standalone "background process" hint on a random + // mid-batch command (e.g. `get`) creates low-grade anxiety without + // giving the caller a concrete action — auto-close in 60s already + // handles the cleanup, and other officecli commands work normally + // through the resident regardless. The `create` command keeps a + // small inline suffix on its success line because it's contextual + // to a freshly-created file, not a nag fired from anywhere. + } + var request = new ResidentRequest(); configure(request); if (json) request.Json = true; - var response = ResidentClient.TrySend(filePath, request); + // Step 2: resident is confirmed alive — wait for our turn in the main + // pipe queue. Do NOT silently fall back on failure; letting a second + // writer touch the file while the resident holds it in memory loses + // data on the resident's eventual save. + var response = ResidentClient.TrySend( + filePath, request, + maxRetries: ResidentBusyMaxRetries, + connectTimeoutMs: ResidentBusyConnectTimeoutMs); + if (response == null) - return null; + { + var fileName = Path.GetFileName(filePath); + var msg = $"Resident for {fileName} is running but the command could not be delivered (main pipe busy or unresponsive). Retry, or run 'officecli close {fileName}' and try again."; + if (json) + Console.WriteLine(OutputFormatter.WrapEnvelopeError(msg)); + else + Console.Error.WriteLine($"Error: {msg}"); + return ResidentBusyExitCode; + } if (json) { @@ -271,9 +441,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 +476,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 +506,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 +585,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 +784,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 +882,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 +906,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)) { @@ -795,12 +1077,16 @@ private static List CheckPositionOverlap(IDocumentHandler handler, strin /// Check if a shape's text overflows its bounds using CJK-aware character measurement. /// Returns a warning message or null. /// - private static string? CheckTextOverflow(IDocumentHandler handler, string path) + internal static string? CheckTextOverflow(IDocumentHandler handler, string path) { - if (handler is not OfficeCli.Handlers.PowerPointHandler pptHandler) return null; try { - return pptHandler.CheckShapeTextOverflow(path); + return handler switch + { + OfficeCli.Handlers.PowerPointHandler ppt => ppt.CheckShapeTextOverflow(path), + OfficeCli.Handlers.ExcelHandler xl => xl.CheckCellOverflow(path), + _ => null + }; } catch { return null; } } @@ -811,6 +1097,8 @@ private static List CheckPositionOverlap(IDocumentHandler handler, strin /// private static void NotifyWatch(IDocumentHandler handler, string filePath, string? changedPath) { + if (!WatchServer.IsWatching(filePath)) return; + if (handler is OfficeCli.Handlers.ExcelHandler excel) { string? scrollTo = null; @@ -836,7 +1124,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; } } @@ -845,6 +1136,8 @@ private static void NotifyWatch(IDocumentHandler handler, string filePath, strin private static void NotifyWatchRoot(IDocumentHandler handler, string filePath, int oldSlideCount) { + if (!WatchServer.IsWatching(filePath)) return; + if (handler is OfficeCli.Handlers.ExcelHandler excel) { WatchNotifier.NotifyIfWatching(filePath, new WatchMessage { Action = "full", FullHtml = excel.ViewAsHtml() }); diff --git a/src/officecli/Core/AttributeFilter.cs b/src/officecli/Core/AttributeFilter.cs index b1ba2e991..90fd9ce32 100644 --- a/src/officecli/Core/AttributeFilter.cs +++ b/src/officecli/Core/AttributeFilter.cs @@ -11,7 +11,7 @@ namespace OfficeCli.Core; /// Supports operators: = (exact), != (not equal), ~= (contains), >= (greater or equal), <= (less or equal). /// Example: "shape[fill=#FF0000][size>=24pt][text~=报告]" /// -public static class AttributeFilter +internal static class AttributeFilter { public enum FilterOp { Equal, NotEqual, Contains, GreaterOrEqual, LessOrEqual, GreaterThan, LessThan, Exists } @@ -77,6 +77,19 @@ public static List Parse(string selector) _ => FilterOp.Equal }; + // BUG-R10-01: wildcard '*' in attribute value silently returned 0 + // matches. Users tried e.g. `ole[progId=Excel*]` expecting a + // contains-like match. Fail fast with a clear error pointing to + // the right operator rather than quietly mis-filtering. + if (val.Contains('*')) + throw new CliException( + $"Wildcards (*) are not supported in attribute filters. " + + $"Use ~= for contains, e.g. {key}~={val.Trim('*')}.") + { + Code = "invalid_selector", + Suggestion = $"Did you mean [{key}~={val.Trim('*')}]?" + }; + conditions.Add(new Condition(key, op, val)); matchedSpans.Add((m.Index, m.Index + m.Length)); } @@ -293,6 +306,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/CellPropHints.cs b/src/officecli/Core/CellPropHints.cs new file mode 100644 index 000000000..f028ee505 --- /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. +/// +internal 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/Chart/ChartExBuilder.Setter.cs b/src/officecli/Core/Chart/ChartExBuilder.Setter.cs new file mode 100644 index 000000000..4380bc8d5 --- /dev/null +++ b/src/officecli/Core/Chart/ChartExBuilder.Setter.cs @@ -0,0 +1,676 @@ +// Copyright 2025 OfficeCli (officecli.ai) +// SPDX-License-Identifier: Apache-2.0 + +using System.Globalization; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using Drawing = DocumentFormat.OpenXml.Drawing; +using CX = DocumentFormat.OpenXml.Office2016.Drawing.ChartDrawing; + +namespace OfficeCli.Core; + +/// +/// Set-side (mutate-in-place) implementation for cx:chart extended chart +/// types. Covers the same vocabulary as the Add path in ChartExBuilder.cs +/// so charts created via Add can be fully re-styled via Set. +/// +/// The shape of each case mirrors ChartHelper.Setter.cs for regular cChart: +/// remove the existing styled element, rebuild it via a shared helper (or +/// mutate in place), and save. All tree mutations respect the CT_Axis / +/// CT_Chart schema order. +/// +internal static partial class ChartExBuilder +{ + /// + /// Mutate an existing to apply the given + /// properties. Returns the list of keys that weren't recognized (caller + /// surfaces these to the user). Unknown keys are never an error — same + /// convention as ChartHelper.SetChartProperties. + /// + internal static List SetChartProperties( + ExtendedChartPart chartPart, Dictionary properties) + { + var unsupported = new List(); + var chartSpace = chartPart.ChartSpace; + var chart = chartSpace?.GetFirstChild(); + if (chart == null) { unsupported.AddRange(properties.Keys); return unsupported; } + + var plotArea = chart.GetFirstChild(); + var plotAreaRegion = plotArea?.GetFirstChild(); + var allSeries = plotAreaRegion?.Elements().ToList() ?? new List(); + var allAxes = plotArea?.Elements().ToList() ?? new List(); + var catAxis = allAxes.FirstOrDefault(); // Id=0 — category axis (histogram/boxWhisker) + var valAxis = allAxes.ElementAtOrDefault(1); // Id=1 — value axis + + // Process structural properties (title text, axis title creation) before + // styling properties (title.color, axisTitle.color) so the target element + // always exists by the time the styling case runs. Same trick as the + // regular cChart setter. + static int PropOrder(string k) + { + var lower = k.ToLowerInvariant(); + if (lower is "title" or "xaxistitle" or "yaxistitle" or "legend") return 0; + return 1; + } + + foreach (var (key, value) in properties.OrderBy(kv => PropOrder(kv.Key))) + { + var handled = HandleSetKey(chart, plotArea, allSeries, allAxes, catAxis, valAxis, + key, value, properties); + if (!handled) unsupported.Add(key); + } + + chartPart.ChartSpace?.Save(); + return unsupported; + } + + // The per-key dispatch lives in its own method so the surrounding loop + // stays readable. Returns true if the key was recognized (regardless of + // whether anything could actually be mutated — e.g. styling a non-existent + // title is a silent no-op, not an unsupported-key report, matching regular + // cChart semantics). + private static bool HandleSetKey( + CX.Chart chart, + CX.PlotArea? plotArea, + List allSeries, + List allAxes, + CX.Axis? catAxis, + CX.Axis? valAxis, + string key, + string value, + Dictionary allProperties) + { + switch (key.ToLowerInvariant()) + { + // ==================== Chart title ==================== + + case "title": + { + chart.RemoveAllChildren(); + if (!string.IsNullOrEmpty(value) + && !value.Equals("none", StringComparison.OrdinalIgnoreCase) + && !value.Equals("false", StringComparison.OrdinalIgnoreCase)) + { + // cx:title must be the first child of cx:chart per schema. + chart.PrependChild(BuildChartTitle(value, allProperties)); + } + return true; + } + + case "title.color" or "titlecolor": + case "title.size" or "titlesize": + case "title.font" or "titlefont": + case "title.bold" or "titlebold": + { + var ctitle = chart.GetFirstChild(); + if (ctitle == null) return true; // silent no-op + foreach (var run in ctitle.Descendants()) + { + var rPr = run.RunProperties + ?? (run.RunProperties = new Drawing.RunProperties { Language = "en-US" }); + ChartHelper.ApplyRunStyleProperties(rPr, allProperties, keyPrefix: "title"); + } + return true; + } + + case "title.shadow" or "titleshadow": + { + // Apply an a:outerShdw effect to the title run's rPr. Same + // vocabulary as regular cChart (ChartHelper.Setter.cs:63): + // "COLOR-BLUR-ANGLE-DIST-OPACITY" or "none" to clear. + var ctitle = chart.GetFirstChild(); + if (ctitle == null) return true; + foreach (var run in ctitle.Descendants()) + { + var rPr = run.RunProperties + ?? (run.RunProperties = new Drawing.RunProperties { Language = "en-US" }); + ApplyRunEffectShadow(rPr, value); + } + return true; + } + + // ==================== Legend ==================== + + case "legend": + { + chart.RemoveAllChildren(); + if (!string.IsNullOrEmpty(value) + && !value.Equals("none", StringComparison.OrdinalIgnoreCase) + && !value.Equals("false", StringComparison.OrdinalIgnoreCase) + && !value.Equals("off", StringComparison.OrdinalIgnoreCase)) + { + // Legend goes after plotArea per cx:chart schema. + chart.AppendChild(BuildLegend(value, allProperties)); + } + return true; + } + + case "legend.overlay" or "legendoverlay": + { + var legend = chart.GetFirstChild(); + if (legend == null) return true; + legend.Overlay = ParseHelpers.IsTruthy(value); + return true; + } + + case "legendfont" or "legend.font": + { + // Compound form "size:color:fontname" styles the legend text. + // Mirrors ChartHelper.Setter.cs:118 "legendfont" for regular + // cChart. Wraps an a:defRPr in cx:txPr on the legend. + var legend = chart.GetFirstChild(); + if (legend == null) return true; + legend.RemoveAllChildren(); + if (!string.IsNullOrEmpty(value) + && !value.Equals("none", StringComparison.OrdinalIgnoreCase)) + { + var txPr = BuildAxisTickLabelStyle(value); + if (txPr != null) legend.AppendChild(txPr); + } + return true; + } + + // ==================== Axis titles (text) ==================== + + case "xaxistitle": + { + if (catAxis == null) return true; + catAxis.RemoveAllChildren(); + if (!string.IsNullOrEmpty(value) + && !value.Equals("none", StringComparison.OrdinalIgnoreCase)) + { + InsertAxisTitle(catAxis, BuildAxisTitle(value, allProperties)); + } + return true; + } + + case "yaxistitle": + { + if (valAxis == null) return true; + valAxis.RemoveAllChildren(); + if (!string.IsNullOrEmpty(value) + && !value.Equals("none", StringComparison.OrdinalIgnoreCase)) + { + InsertAxisTitle(valAxis, BuildAxisTitle(value, allProperties)); + } + return true; + } + + case "axistitle.color" or "axistitlecolor": + case "axistitle.size" or "axistitlesize": + case "axistitle.font" or "axistitlefont": + case "axistitle.bold" or "axistitlebold": + { + foreach (var axis in allAxes) + { + var axisTitle = axis.GetFirstChild(); + if (axisTitle == null) continue; + foreach (var run in axisTitle.Descendants()) + { + var rPr = run.RunProperties + ?? (run.RunProperties = new Drawing.RunProperties { Language = "en-US" }); + ChartHelper.ApplyRunStyleProperties(rPr, allProperties, keyPrefix: "axisTitle"); + } + } + return true; + } + + // ==================== Tick-label font (axis-level cx:txPr) ==================== + + case "axisfont" or "axis.font": + { + foreach (var axis in allAxes) + { + // cx:txPr must remain the last axis child (per CT_Axis schema: + // ... → tickLabels → numFmt → spPr → txPr → extLst). + axis.RemoveAllChildren(); + var txPr = BuildAxisTickLabelStyle(value); + if (txPr != null) axis.AppendChild(txPr); + } + return true; + } + + // ==================== Gridlines ==================== + + case "gridlines": + { + if (valAxis == null) return true; + valAxis.RemoveAllChildren(); + if (ParseHelpers.IsTruthy(value)) + InsertGridlinesInAxisOrder(valAxis, new CX.MajorGridlinesGridlines()); + return true; + } + + case "xgridlines": + { + if (catAxis == null) return true; + catAxis.RemoveAllChildren(); + if (ParseHelpers.IsTruthy(value)) + InsertGridlinesInAxisOrder(catAxis, new CX.MajorGridlinesGridlines()); + return true; + } + + case "gridlinecolor" or "gridline.color": + { + var gl = valAxis?.GetFirstChild(); + if (gl != null) gl.ShapeProperties = BuildGridlineShapeProperties(value); + return true; + } + + case "xgridlinecolor" or "xgridline.color": + { + var gl = catAxis?.GetFirstChild(); + if (gl != null) gl.ShapeProperties = BuildGridlineShapeProperties(value); + return true; + } + + // ==================== Value-axis scaling (axismin/max/majorunit) ==================== + // CONSISTENCY(chart-axis-scaling): same prop names as regular cChart + // (ChartHelper.Setter.cs:357). CX.ValueAxisScaling stores Min/Max/ + // MajorUnit/MinorUnit as StringValue attributes, not typed doubles, + // but we still parse + re-format as invariant double for + // consistency with cChart behavior (reject NaN/Infinity). + case "axismin" or "min": + { + var valScaling = valAxis?.GetFirstChild(); + if (valScaling == null) return true; + valScaling.Min = ParseHelpers.SafeParseDouble(value, "axismin") + .ToString("G", CultureInfo.InvariantCulture); + return true; + } + + case "axismax" or "max": + { + var valScaling = valAxis?.GetFirstChild(); + if (valScaling == null) return true; + valScaling.Max = ParseHelpers.SafeParseDouble(value, "axismax") + .ToString("G", CultureInfo.InvariantCulture); + return true; + } + + case "majorunit": + { + var valScaling = valAxis?.GetFirstChild(); + if (valScaling == null) return true; + valScaling.MajorUnit = ParseHelpers.SafeParseDouble(value, "majorunit") + .ToString("G", CultureInfo.InvariantCulture); + return true; + } + + case "minorunit": + { + var valScaling = valAxis?.GetFirstChild(); + if (valScaling == null) return true; + valScaling.MinorUnit = ParseHelpers.SafeParseDouble(value, "minorunit") + .ToString("G", CultureInfo.InvariantCulture); + return true; + } + + // ==================== Axis visibility (hidden flag) ==================== + // CONSISTENCY(chart-axis-visibility): same prop names as regular + // cChart (ChartHelper.Setter.cs:795). CX uses a simple @hidden + // attribute on cx:axis, unlike cChart's c:delete child element. + case "axisvisible" or "axis.visible" or "axis.delete": + { + var hide = key.Contains("delete") + ? ParseHelpers.IsTruthy(value) + : !ParseHelpers.IsTruthy(value); + foreach (var axis in allAxes) axis.Hidden = hide; + return true; + } + + case "cataxisvisible" or "cataxis.visible": + { + if (catAxis != null) catAxis.Hidden = !ParseHelpers.IsTruthy(value); + return true; + } + + case "valaxisvisible" or "valaxis.visible": + { + if (valAxis != null) valAxis.Hidden = !ParseHelpers.IsTruthy(value); + return true; + } + + // ==================== Axis line styling ==================== + // CONSISTENCY(chart-axis-line): "color" | "color:width" | "color:width:dash" + // | "none". Same vocabulary as regular cChart (ChartHelper.Setter.cs:1471), + // reuses ChartHelper.BuildOutlineElement for parsing. + case "axisline" or "axis.line": + { + foreach (var axis in allAxes) ApplyCxAxisLine(axis, value); + return true; + } + + case "cataxisline" or "cataxis.line": + { + if (catAxis != null) ApplyCxAxisLine(catAxis, value); + return true; + } + + case "valaxisline" or "valaxis.line": + { + if (valAxis != null) ApplyCxAxisLine(valAxis, value); + return true; + } + + // ==================== Tick labels (on/off, both axes) ==================== + + case "ticklabels": + { + var enable = ParseHelpers.IsTruthy(value); + foreach (var axis in allAxes) + { + axis.RemoveAllChildren(); + if (enable) InsertTickLabelsInAxisOrder(axis, new CX.TickLabels()); + } + return true; + } + + // ==================== Data labels (series-level) ==================== + + case "datalabels" or "labels": + { + var enable = ParseHelpers.IsTruthy(value); + foreach (var series in allSeries) + { + series.RemoveAllChildren(); + if (!enable) continue; + var dl = new CX.DataLabels { Pos = CX.DataLabelPos.OutEnd }; + dl.AppendChild(new CX.DataLabelVisibilities + { + Value = true, SeriesName = false, CategoryName = false, + }); + // dataLabels goes before cx:dataId per cx:series schema. + var dataId = series.GetFirstChild(); + if (dataId != null) series.InsertBefore(dl, dataId); + else series.AppendChild(dl); + } + return true; + } + + case "datalabels.numfmt" or "labelnumfmt" or "datalabels.format" or "labelformat": + { + // CONSISTENCY(chart-datalabel-numfmt): same prop names as + // regular cChart (ChartHelper.Setter.cs:1181). Applies a + // cx:numFmt element to every series' cx:dataLabels. Silent + // no-op if a series has no dataLabels block (use `dataLabels=true` + // to enable them first, same as regular cChart semantics). + foreach (var series in allSeries) + { + var dl = series.GetFirstChild(); + if (dl == null) continue; + dl.NumberFormat = new CX.NumberFormat + { + FormatCode = value, + SourceLinked = false, + }; + } + return true; + } + + // ==================== Series fill / multi-series colors ==================== + + case "fill": + { + foreach (var series in allSeries) + ReplaceSeriesFill(series, value); + return true; + } + + case "colors": + { + var colorList = value.Split(',').Select(c => c.Trim()).ToArray(); + for (int i = 0; i < Math.Min(allSeries.Count, colorList.Length); i++) + ReplaceSeriesFill(allSeries[i], colorList[i]); + return true; + } + + // ==================== Series effects (shadow) ==================== + // CONSISTENCY(chart-series-shadow): same vocabulary as regular cChart + // (ChartHelper.Setter.cs:642 / SetterHelpers.cs:374). Format + // "COLOR-BLUR-ANGLE-DIST-OPACITY" or "none" to clear. Applied to + // every series by attaching an a:effectLst inside the existing + // cx:spPr (or creating one if the series has no fill yet). + case "series.shadow" or "seriesshadow": + { + foreach (var series in allSeries) + ApplyCxSeriesShadow(series, value); + return true; + } + + // ==================== Histogram binning ==================== + + case "bincount": + { + SetHistogramBinSpec(allSeries, kind: "binCount", rawValue: value); + return true; + } + + case "binsize": + { + SetHistogramBinSpec(allSeries, kind: "binSize", rawValue: value); + return true; + } + + case "intervalclosed": + { + foreach (var series in allSeries) + { + var binning = series.Descendants().FirstOrDefault(); + if (binning == null) continue; + binning.IntervalClosed = value.ToLowerInvariant() == "l" + ? CX.IntervalClosedSide.L + : CX.IntervalClosedSide.R; + } + return true; + } + + case "underflowbin": + { + foreach (var series in allSeries) + { + var binning = series.Descendants().FirstOrDefault(); + if (binning != null) + binning.Underflow = string.IsNullOrEmpty(value) ? null : value; + } + return true; + } + + case "overflowbin": + { + foreach (var series in allSeries) + { + var binning = series.Descendants().FirstOrDefault(); + if (binning != null) + binning.Overflow = string.IsNullOrEmpty(value) ? null : value; + } + return true; + } + + case "gapwidth": + { + var catScaling = catAxis?.GetFirstChild(); + if (catScaling != null) catScaling.GapWidth = value; + return true; + } + + // ==================== Other extended-type layoutPr ==================== + + case "parentlabellayout": // treemap + { + foreach (var series in allSeries) + { + var parentLabel = series.Descendants().FirstOrDefault(); + if (parentLabel == null) continue; + parentLabel.ParentLabelLayoutVal = value.ToLowerInvariant() switch + { + "none" => CX.ParentLabelLayoutVal.None, + "banner" => CX.ParentLabelLayoutVal.Banner, + _ => CX.ParentLabelLayoutVal.Overlapping, + }; + } + return true; + } + + case "quartilemethod": // boxwhisker + { + foreach (var series in allSeries) + { + var stats = series.Descendants().FirstOrDefault(); + if (stats == null) continue; + stats.QuartileMethod = value.ToLowerInvariant() == "inclusive" + ? CX.QuartileMethod.Inclusive + : CX.QuartileMethod.Exclusive; + } + return true; + } + + // ==================== Plot area / chart area fill + border ==================== + // CONSISTENCY(chart-area-fill): same prop names as regular cChart + // (ChartHelper.Setter.cs:476,491,1220,1232). Both PlotArea and + // ChartSpace accept a cx:spPr child; we attach a solidFill for + // the background and an a:ln outline for the border. + case "plotareafill" or "plotfill": + { + if (plotArea == null) return true; + ApplyCxAreaFill(plotArea, value); + return true; + } + + case "plotarea.border" or "plotborder": + { + if (plotArea == null) return true; + ApplyCxAreaBorder(plotArea, value); + return true; + } + + case "chartareafill" or "chartfill": + { + var chartSpace = chart.Parent as CX.ChartSpace; + if (chartSpace == null) return true; + ApplyCxAreaFill(chartSpace, value); + return true; + } + + case "chartarea.border" or "chartborder": + { + var chartSpace = chart.Parent as CX.ChartSpace; + if (chartSpace == null) return true; + ApplyCxAreaBorder(chartSpace, value); + return true; + } + } + return false; + } + + // ==================== Schema-aware insertion helpers ==================== + + /// + /// Insert a into an axis, respecting the + /// CT_Axis sequence: catScaling/valScaling → title → units → gridlines → ... + /// + private static void InsertAxisTitle(CX.Axis axis, CX.AxisTitle title) + { + // Title goes immediately after catScaling/valScaling. + var scaling = axis.GetFirstChild() as OpenXmlElement + ?? axis.GetFirstChild(); + if (scaling != null) scaling.InsertAfterSelf(title); + else axis.PrependChild(title); + } + + /// + /// Insert majorGridlines after title (or scaling) but before tickLabels / + /// spPr / txPr, matching the CT_Axis schema sequence. + /// + private static void InsertGridlinesInAxisOrder(CX.Axis axis, CX.MajorGridlinesGridlines gl) + { + var insertAfter = (OpenXmlElement?)axis.GetFirstChild() + ?? (OpenXmlElement?)axis.GetFirstChild() + ?? axis.GetFirstChild(); + if (insertAfter != null) insertAfter.InsertAfterSelf(gl); + else axis.PrependChild(gl); + } + + /// + /// Insert tickLabels after gridlines (or earlier children) but before + /// axis-level spPr / txPr. + /// + private static void InsertTickLabelsInAxisOrder(CX.Axis axis, CX.TickLabels tickLabels) + { + // cx:txPr is what our Set path appends to the axis for tick-label + // styling; tickLabels must come BEFORE any existing txPr. + var existingTxPr = axis.GetFirstChild(); + if (existingTxPr != null) + { + axis.InsertBefore(tickLabels, existingTxPr); + return; + } + var insertAfter = (OpenXmlElement?)axis.GetFirstChild() + ?? (OpenXmlElement?)axis.GetFirstChild() + ?? (OpenXmlElement?)axis.GetFirstChild() + ?? axis.GetFirstChild(); + if (insertAfter != null) insertAfter.InsertAfterSelf(tickLabels); + else axis.AppendChild(tickLabels); + } + + // ==================== Series-level helpers ==================== + + /// + /// Replace the series fill color (single solid fill). Used by both + /// `fill` and `colors` cases. + /// + private static void ReplaceSeriesFill(CX.Series series, string color) + { + if (string.IsNullOrEmpty(color)) return; + series.RemoveAllChildren(); + var (rgb, _) = ParseHelpers.SanitizeColorForOoxml(color); + var spPr = new CX.ShapeProperties( + new Drawing.SolidFill(new Drawing.RgbColorModelHex { Val = rgb })); + // spPr goes right after cx:tx per cx:series schema sequence. + var tx = series.GetFirstChild(); + if (tx != null) tx.InsertAfterSelf(spPr); + else series.PrependChild(spPr); + } + + /// + /// Replace a histogram's cx:binCount / cx:binSize with the + /// given value. Binning is XOR — setting one removes the other. Uses the + /// same OpenXmlUnknownElement workaround as the Add path (SDK's typed + /// binCount is a leaf-text element but Excel wants a val attribute). + /// + private static void SetHistogramBinSpec( + IReadOnlyList allSeries, string kind, string rawValue) + { + const string cxNs = "http://schemas.microsoft.com/office/drawing/2014/chartex"; + + foreach (var series in allSeries) + { + var lp = series.GetFirstChild(); + if (lp == null) continue; + var binning = lp.GetFirstChild(); + if (binning == null) continue; + + // Remove any existing binCount / binSize (XOR with the new one). + foreach (var existing in binning.ChildElements.ToList()) + if (existing.LocalName is "binCount" or "binSize") existing.Remove(); + + if (string.IsNullOrEmpty(rawValue)) continue; // bare "bincount=" clears + + if (kind == "binCount" && uint.TryParse(rawValue, out var binCount)) + { + var el = new OpenXmlUnknownElement("cx", "binCount", cxNs); + el.SetAttribute(new OpenXmlAttribute("val", "", binCount.ToString())); + binning.AppendChild(el); + } + else if (kind == "binSize" + && double.TryParse(rawValue, NumberStyles.Float, CultureInfo.InvariantCulture, + out var binSize)) + { + var el = new OpenXmlUnknownElement("cx", "binSize", cxNs); + el.SetAttribute(new OpenXmlAttribute("val", "", + binSize.ToString("G", CultureInfo.InvariantCulture))); + binning.AppendChild(el); + } + } + } +} diff --git a/src/officecli/Core/Chart/ChartExBuilder.cs b/src/officecli/Core/Chart/ChartExBuilder.cs new file mode 100644 index 000000000..f2f779193 --- /dev/null +++ b/src/officecli/Core/Chart/ChartExBuilder.cs @@ -0,0 +1,989 @@ +// Copyright 2025 OfficeCli (officecli.ai) +// SPDX-License-Identifier: Apache-2.0 + +using System.Globalization; +using DocumentFormat.OpenXml; +using Drawing = DocumentFormat.OpenXml.Drawing; +using CX = DocumentFormat.OpenXml.Office2016.Drawing.ChartDrawing; + +namespace OfficeCli.Core; + +/// +/// Builder for cx:chart (Office 2016 extended chart types): +/// funnel, treemap, sunburst, boxWhisker, histogram, waterfall (native). +/// +/// Split into two files: +/// ChartExBuilder.cs — BuildExtendedChartSpace (Add path) +/// ChartExBuilder.Setter.cs — SetChartProperties (Set path) +/// Both halves share the same private helpers defined here. +/// +internal static partial class ChartExBuilder +{ + internal static readonly HashSet ExtendedChartTypes = new(StringComparer.OrdinalIgnoreCase) + { + "funnel", "treemap", "sunburst", "boxwhisker", "histogram", "pareto" + // Pareto is a 2-series structure: clusteredColumn (sorted bars) + + // paretoLine (cumulative-% overlay). PreparePareto pre-sorts desc + // and computes cumulative %. The value axis is forced to 0-100 so + // both bars and cumulative line share the same 0-100 range. + // DetectExtendedChartType handles both OfficeCli-authored and + // MSO-authored (same 2-series shape) forms. + }; + + internal static bool IsExtendedChartType(string chartType) + { + var normalized = chartType.ToLowerInvariant().Replace(" ", "").Replace("_", "").Replace("-", ""); + return ExtendedChartTypes.Contains(normalized); + } + + /// + /// Build a cx:chartSpace for an extended chart type. + /// + internal static CX.ChartSpace BuildExtendedChartSpace( + string chartType, + string? title, + string[]? categories, + List<(string name, double[] values)> seriesData, + Dictionary properties) + { + var normalized = chartType.ToLowerInvariant().Replace(" ", "").Replace("_", "").Replace("-", ""); + + // Pareto pre-sorts descending and keeps a single series. The + // paretoLine series is appended after the main loop with ownerIdx=0 + // (derives from the clusteredColumn series — no separate data needed). + if (normalized == "pareto") + (categories, seriesData) = PreparePareto(categories, seriesData); + + var chartSpace = new CX.ChartSpace(); + + // 1. Build ChartData + var chartData = new CX.ChartData(); + + // boxWhisker: native Excel structure is one cx:data per group (numDim only, + // no strDim) + one cx:series per group. The category axis positions each + // group automatically by series order. Any strDim causes Excel to stack + // all boxes onto the same X position. + var dataBlockCount = seriesData.Count; + for (int si = 0; si < dataBlockCount; si++) + { + CX.Data data = normalized == "boxwhisker" + ? BuildBoxWhiskerGroupDataBlock((uint)si, seriesData[si].values, seriesData[si].name) + : BuildDataBlock((uint)si, normalized, categories, seriesData[si].values); + chartData.AppendChild(data); + } + chartSpace.AppendChild(chartData); + + // 2. Build Chart + var chart = new CX.Chart(); + + if (!string.IsNullOrEmpty(title)) + { + chart.AppendChild(BuildChartTitle(title, properties)); + } + + var plotArea = new CX.PlotArea(); + var plotAreaRegion = new CX.PlotAreaRegion(); + + var layoutId = normalized switch + { + "funnel" => "funnel", + "treemap" => "treemap", + "sunburst" => "sunburst", + "boxwhisker" => "boxWhisker", + "histogram" => "clusteredColumn", + "pareto" => "clusteredColumn", + _ => "funnel" + }; + + // Parse series fill colors — reuse the `colors=RED,BLUE,GREEN` + // convention from regular charts, or accept a single `fill=COLOR` + // for one-series charts like histogram. + var seriesColors = ChartHelper.ParseSeriesColors(properties); + if (seriesColors == null && properties.TryGetValue("fill", out var fillStr)) + seriesColors = new[] { fillStr }; + + // dataLabels: off by default. Accept "true" / "on" / "1" / "value" + // (any explicit truthy value enables). "false" / "off" / "0" disables. + var showDataLabels = IsTruthyProp(properties, "dataLabels", defaultValue: false); + + // All chart types including boxWhisker: one cx:series per data set. + // boxWhisker gets one series per group, matching the one-cx:data-per-group + // structure above. Colors are set per-series via cx:spPr. + for (int si = 0; si < seriesData.Count; si++) + { + var series = new CX.Series { LayoutId = new EnumValue( + ParseSeriesLayout(layoutId)) }; + + // Schema order for cx:series: + // tx → spPr → valueColors → valueColorPositions → dataPoint* + // → dataLabels → dataId → layoutPr → axisId* → extLst + series.AppendChild(new CX.Text( + new CX.TextData( + new CX.Formula(""), + new CX.VXsdstring(seriesData[si].name)))); + + // Per-series solid fill + if (seriesColors != null && si < seriesColors.Length && !string.IsNullOrEmpty(seriesColors[si])) + { + var (rgb, _) = ParseHelpers.SanitizeColorForOoxml(seriesColors[si]); + series.AppendChild(new CX.ShapeProperties( + new Drawing.SolidFill( + new Drawing.RgbColorModelHex { Val = rgb }))); + } + + // Optional series.shadow (applied to every series). Reuses the + // ApplyCxSeriesShadow helper so the Add and Set paths emit + // identical trees. + var seriesShadow = properties.GetValueOrDefault("series.shadow") + ?? properties.GetValueOrDefault("seriesshadow"); + if (!string.IsNullOrEmpty(seriesShadow)) + ApplyCxSeriesShadow(series, seriesShadow); + + // Data labels (value count above each bar) + if (showDataLabels) + { + var dl = new CX.DataLabels { Pos = CX.DataLabelPos.OutEnd }; + dl.AppendChild(new CX.DataLabelVisibilities + { + Value = true, + SeriesName = false, + CategoryName = false, + }); + // Optional number format (datalabels.numfmt / labelnumfmt). + var dlNumFmt = properties.GetValueOrDefault("datalabels.numfmt") + ?? properties.GetValueOrDefault("labelnumfmt") + ?? properties.GetValueOrDefault("datalabels.format") + ?? properties.GetValueOrDefault("labelformat"); + if (!string.IsNullOrEmpty(dlNumFmt)) + { + dl.NumberFormat = new CX.NumberFormat + { + FormatCode = dlNumFmt, + SourceLinked = false, + }; + } + series.AppendChild(dl); + } + + series.AppendChild(new CX.DataId { Val = (uint)si }); + + // Chart-type specific layoutPr (histogram binning, treemap label + // layout, boxWhisker stats, etc.). Pareto's clusteredColumn + // series must NOT have binning — the data is categorical + // (strDim categories), not continuous numeric for histogram bins. + if (normalized != "pareto") + { + var layoutPr = BuildLayoutProperties(normalized, properties, seriesData[si].values.Length); + if (layoutPr != null) + series.AppendChild(layoutPr); + } + + // Pareto clusteredColumn series: explicit axisId binding to + // the primary value axis (id=1), matching MSO's structure. + if (normalized == "pareto") + { + const string cxAxNs = "http://schemas.microsoft.com/office/drawing/2014/chartex"; + var barAxisId = new OpenXmlUnknownElement("cx", "axisId", cxAxNs); + barAxisId.SetAttribute(new OpenXmlAttribute("val", "", "1")); + series.AppendChild(barAxisId); + } + + plotAreaRegion.AppendChild(series); + } + + // Pareto: append the paretoLine overlay series (derives from series 0 + // via ownerIdx="0", auto-computes cumulative %; bound to the secondary + // percentage axis id=2). Matches MSO's on-the-wire structure. + if (normalized == "pareto") + { + const string cxParetoNs = "http://schemas.microsoft.com/office/drawing/2014/chartex"; + var paretoLine = new CX.Series + { + LayoutId = new EnumValue(CX.SeriesLayout.ParetoLine), + OwnerIdx = 0, + }; + var axisIdEl = new OpenXmlUnknownElement("cx", "axisId", cxParetoNs); + axisIdEl.SetAttribute(new OpenXmlAttribute("val", "", "2")); + paretoLine.AppendChild(axisIdEl); + plotAreaRegion.AppendChild(paretoLine); + } + + plotArea.AppendChild(plotAreaRegion); + + // Axes for chart types that need them (histogram / boxWhisker / pareto). + // Funnel/treemap/sunburst are axis-less. Pareto gets 3 axes: cat(0), + // primary val(1) for bars, secondary percentage(2) for the cumulative line. + if (normalized is "boxwhisker" or "histogram" or "pareto") + { + plotArea.AppendChild(BuildCategoryAxis(id: 0, chartType: normalized, properties)); + plotArea.AppendChild(BuildValueAxis(id: 1, properties)); + + if (normalized == "pareto") + { + // Secondary percentage axis for the cumulative line (0-100%). + // Uses raw elements for cx:units since the SDK doesn't expose + // a typed CX.Units class. + const string cxAxisNs = "http://schemas.microsoft.com/office/drawing/2014/chartex"; + var pctAxis = new CX.Axis { Id = 2 }; + pctAxis.AppendChild(new CX.ValueAxisScaling { Max = "1", Min = "0" }); + var unitsEl = new OpenXmlUnknownElement("cx", "units", cxAxisNs); + unitsEl.SetAttribute(new OpenXmlAttribute("unit", "", "percentage")); + pctAxis.AppendChild(unitsEl); + pctAxis.AppendChild(new CX.TickLabels()); + plotArea.AppendChild(pctAxis); + } + } + + // Plot area fill / border — optional background styling + // (CONSISTENCY(chart-area-fill)). Must be appended AFTER all axes + // per CT_PlotArea schema sequence: + // plotSurface? → plotAreaRegion → axis* → spPr? → extLst? + var plotAreaFill = properties.GetValueOrDefault("plotareafill") + ?? properties.GetValueOrDefault("plotfill"); + if (!string.IsNullOrEmpty(plotAreaFill)) + ApplyCxAreaFill(plotArea, plotAreaFill); + + var plotAreaBorder = properties.GetValueOrDefault("plotarea.border") + ?? properties.GetValueOrDefault("plotborder"); + if (!string.IsNullOrEmpty(plotAreaBorder)) + ApplyCxAreaBorder(plotArea, plotAreaBorder); + + chart.AppendChild(plotArea); + + // Legend (optional, appears AFTER plotArea per cx:chart schema order). + // BuildLegend reads legend.overlay / legendfont from properties too. + if (properties.TryGetValue("legend", out var legendPos) && + !string.IsNullOrEmpty(legendPos) && + !legendPos.Equals("none", StringComparison.OrdinalIgnoreCase) && + !legendPos.Equals("false", StringComparison.OrdinalIgnoreCase) && + !legendPos.Equals("off", StringComparison.OrdinalIgnoreCase)) + { + chart.AppendChild(BuildLegend(legendPos, properties)); + } + + chartSpace.AppendChild(chart); + + // Chart area fill / border — attached to cx:chartSpace's own spPr. + // This is the outermost background; tests should verify Excel + // accepts it (the cx schema technically does not list spPr as a + // chartSpace child but the SDK tolerates it; real Excel silently + // ignores it rather than rejecting, so we still emit it for + // round-trip Set() compatibility). + var chartAreaFill = properties.GetValueOrDefault("chartareafill") + ?? properties.GetValueOrDefault("chartfill"); + if (!string.IsNullOrEmpty(chartAreaFill)) + ApplyCxAreaFill(chartSpace, chartAreaFill); + + var chartAreaBorder = properties.GetValueOrDefault("chartarea.border") + ?? properties.GetValueOrDefault("chartborder"); + if (!string.IsNullOrEmpty(chartAreaBorder)) + ApplyCxAreaBorder(chartSpace, chartAreaBorder); + + return chartSpace; + } + + private static CX.ChartTitle BuildChartTitle(string title, Dictionary? properties = null) + { + var rPr = new Drawing.RunProperties { Language = "en-US" }; + // Delegate style parsing to the shared helper so cChart and cxChart + // stay in vocabulary lockstep. See + // ChartHelper.ApplyRunStyleProperties. + if (properties != null) + { + ChartHelper.ApplyRunStyleProperties(rPr, properties, keyPrefix: "title"); + + // title.shadow is a separate knob — ApplyRunStyleProperties covers + // color/size/bold/font only (see its doc-comment). Same format as + // regular cChart: "COLOR-BLUR-ANGLE-DIST-OPACITY". + var titleShadow = properties.GetValueOrDefault("title.shadow") + ?? properties.GetValueOrDefault("titleshadow"); + if (!string.IsNullOrEmpty(titleShadow)) + ApplyRunEffectShadow(rPr, titleShadow); + } + + var chartTitle = new CX.ChartTitle(); + chartTitle.AppendChild(new CX.Text( + new CX.RichTextBody( + new Drawing.BodyProperties(), + new Drawing.Paragraph( + new Drawing.Run( + rPr, + new Drawing.Text(title)))))); + return chartTitle; + } + + private static CX.AxisTitle BuildAxisTitle(string title, Dictionary? properties = null) + { + var rPr = new Drawing.RunProperties { Language = "en-US" }; + if (properties != null) + ChartHelper.ApplyRunStyleProperties(rPr, properties, keyPrefix: "axisTitle"); + + return new CX.AxisTitle( + new CX.Text( + new CX.RichTextBody( + new Drawing.BodyProperties(), + new Drawing.Paragraph( + new Drawing.Run( + rPr, + new Drawing.Text(title)))))); + } + + /// + /// Wrap a shared `a:defRPr` (built from a compound `"size:color:fontname"` + /// spec by ) + /// in a . Only the outer container differs + /// from the regular-cChart path (). + /// + private static CX.TxPrTextBody? BuildAxisTickLabelStyle(string compoundSpec) + { + if (string.IsNullOrEmpty(compoundSpec)) return null; + var defRp = ChartHelper.BuildDefaultRunPropertiesFromCompoundSpec(compoundSpec); + return new CX.TxPrTextBody( + new Drawing.BodyProperties(), + new Drawing.ListStyle(), + new Drawing.Paragraph(new Drawing.ParagraphProperties(defRp))); + } + + /// + /// Build a containing a solid-fill outline + /// for coloring gridlines. Mirrors the regular-chart `gridline.color` knob. + /// + private static CX.ShapeProperties? BuildGridlineShapeProperties(string color) + { + if (string.IsNullOrEmpty(color)) return null; + var (rgb, _) = ParseHelpers.SanitizeColorForOoxml(color); + var outline = new Drawing.Outline(); + outline.AppendChild(new Drawing.SolidFill(new Drawing.RgbColorModelHex { Val = rgb })); + return new CX.ShapeProperties(outline); + } + + private static CX.Legend BuildLegend(string posSpec, Dictionary? properties = null) + { + var legend = new CX.Legend + { + Align = CX.PosAlign.Ctr, + Overlay = false, + }; + legend.Pos = posSpec.ToLowerInvariant() switch + { + "top" or "t" => CX.SidePos.T, + "bottom" or "b" => CX.SidePos.B, + "left" or "l" => CX.SidePos.L, + _ => CX.SidePos.R, // right is the Excel default + }; + + if (properties != null) + { + // Optional overlay flag — matches regular cChart's `legend.overlay`. + var overlay = properties.GetValueOrDefault("legend.overlay") + ?? properties.GetValueOrDefault("legendoverlay"); + if (!string.IsNullOrEmpty(overlay)) + legend.Overlay = ParseHelpers.IsTruthy(overlay); + + // Compound font styling — "size:color:fontname", same form as + // regular cChart's `legendfont`. Wraps an a:defRPr in cx:txPr. + var legendFont = properties.GetValueOrDefault("legendfont") + ?? properties.GetValueOrDefault("legend.font"); + if (!string.IsNullOrEmpty(legendFont)) + { + var txPr = BuildAxisTickLabelStyle(legendFont); + if (txPr != null) legend.AppendChild(txPr); + } + } + + return legend; + } + + // ==================== Shared cx:spPr / effect helpers ==================== + // + // These helpers mirror the regular-cChart versions in + // ChartHelper.SetterHelpers.cs (ApplyAxisLine, BuildOutlineElement, + // DrawingEffectsHelper.BuildOuterShadow) but target cx:spPr containers + // instead of c:spPr / c:ChartShapeProperties. + // + // They are used by BOTH the Add path (ChartExBuilder.cs BuildExtended...) + // and the Set path (ChartExBuilder.Setter.cs HandleSetKey), so each knob + // creates the same OOXML tree regardless of whether it was set at Add + // time or via a later Set call. + + /// + /// Apply an a:outerShdw effect to a Drawing.RunProperties (used for + /// `title.shadow`). Reuses the shared DrawingEffectsHelper format: + /// "COLOR-BLUR-ANGLE-DIST-OPACITY" or "none" to clear. + /// + private static void ApplyRunEffectShadow(Drawing.RunProperties rPr, string value) + { + rPr.RemoveAllChildren(); + if (value.Equals("none", StringComparison.OrdinalIgnoreCase)) return; + var effects = new Drawing.EffectList(); + effects.AppendChild(DrawingEffectsHelper.BuildOuterShadow( + value, DrawingEffectsHelper.BuildRgbColor)); + rPr.AppendChild(effects); + } + + /// + /// Apply an a:ln outline to a cx:axis's own cx:spPr. Same vocabulary as + /// ChartHelper.SetterHelpers.cs:ApplyAxisLine — "color" / "color:width" / + /// "color:width:dash" / "none". + /// + private static void ApplyCxAxisLine(CX.Axis axis, string value) + { + var spPr = axis.GetFirstChild(); + if (spPr == null) + { + spPr = new CX.ShapeProperties(); + // cx:spPr comes after tickLabels but before txPr in the cx:axis + // schema (catScaling → title → gridlines → tickLabels → numFmt + // → spPr → txPr → extLst). + var existingTxPr = axis.GetFirstChild(); + if (existingTxPr != null) axis.InsertBefore(spPr, existingTxPr); + else axis.AppendChild(spPr); + } + spPr.RemoveAllChildren(); + if (value.Equals("none", StringComparison.OrdinalIgnoreCase)) + { + var noFillOutline = new Drawing.Outline(); + noFillOutline.AppendChild(new Drawing.NoFill()); + spPr.PrependChild(noFillOutline); + return; + } + spPr.PrependChild(ChartHelper.BuildOutlineElement(value)); + } + + /// + /// Apply an a:outerShdw (inside a:effectLst) to a cx:series's own cx:spPr. + /// Preserves any existing solidFill so the series keeps its color. + /// + private static void ApplyCxSeriesShadow(CX.Series series, string value) + { + var spPr = series.GetFirstChild(); + if (spPr == null) + { + spPr = new CX.ShapeProperties(); + // spPr goes right after cx:tx per cx:series schema. + var tx = series.GetFirstChild(); + if (tx != null) tx.InsertAfterSelf(spPr); + else series.PrependChild(spPr); + } + // Remove any existing effectList so repeated Sets don't stack. + spPr.RemoveAllChildren(); + if (value.Equals("none", StringComparison.OrdinalIgnoreCase)) return; + var effects = new Drawing.EffectList(); + effects.AppendChild(DrawingEffectsHelper.BuildOuterShadow( + value, DrawingEffectsHelper.BuildRgbColor)); + spPr.AppendChild(effects); + } + + /// + /// Apply a solid background fill to a cx:plotArea or cx:chartSpace via + /// its own cx:spPr child. Accepts "none" to clear. + /// + private static void ApplyCxAreaFill(OpenXmlCompositeElement container, string value) + { + var spPr = container.GetFirstChild(); + if (spPr == null) + { + spPr = new CX.ShapeProperties(); + container.AppendChild(spPr); + } + spPr.RemoveAllChildren(); + spPr.RemoveAllChildren(); + if (value.Equals("none", StringComparison.OrdinalIgnoreCase)) + { + spPr.PrependChild(new Drawing.NoFill()); + return; + } + var (rgb, _) = ParseHelpers.SanitizeColorForOoxml(value); + spPr.PrependChild(new Drawing.SolidFill( + new Drawing.RgbColorModelHex { Val = rgb })); + } + + /// + /// Apply an a:ln outline border to a cx:plotArea or cx:chartSpace via its + /// own cx:spPr child. Shares the "color / color:width / color:width:dash" + /// vocabulary with ChartHelper.BuildOutlineElement. + /// + private static void ApplyCxAreaBorder(OpenXmlCompositeElement container, string value) + { + var spPr = container.GetFirstChild(); + if (spPr == null) + { + spPr = new CX.ShapeProperties(); + container.AppendChild(spPr); + } + spPr.RemoveAllChildren(); + if (value.Equals("none", StringComparison.OrdinalIgnoreCase)) + { + var noFillOutline = new Drawing.Outline(); + noFillOutline.AppendChild(new Drawing.NoFill()); + spPr.AppendChild(noFillOutline); + return; + } + spPr.AppendChild(ChartHelper.BuildOutlineElement(value)); + } + + // Build the category axis (X axis for histogram / boxWhisker). Schema + // order of Axis children: catScaling → title → majorGridlines → + // tickLabels → ... (only the ones we emit are listed). + private static CX.Axis BuildCategoryAxis(uint id, string chartType, Dictionary properties) + { + var axis = new CX.Axis { Id = id }; + + // CONSISTENCY(chart-axis-visibility): apply @hidden from axis.visible + // / cataxis.visible / axis.delete props. See ApplyAxisHiddenFromProps + // for the precedence rules. + ApplyAxisHiddenFromProps(axis, properties, catOnly: true, valOnly: false); + + // catScaling is required. histogram defaults gapWidth="0" (bars touch) + // because that's what real Excel emits and it's what users expect. + var catScaling = new CX.CategoryAxisScaling(); + var gapWidth = properties.GetValueOrDefault("gapWidth"); + if (string.IsNullOrEmpty(gapWidth) && chartType == "histogram") + gapWidth = "0"; + if (!string.IsNullOrEmpty(gapWidth)) + catScaling.GapWidth = gapWidth; + axis.AppendChild(catScaling); + + if (properties.TryGetValue("xAxisTitle", out var xTitle) && !string.IsNullOrEmpty(xTitle)) + axis.AppendChild(BuildAxisTitle(xTitle, properties)); + + // Category-axis major gridlines are off by default in Excel; opt-in. + if (IsTruthyProp(properties, "xGridlines", defaultValue: false)) + { + var gl = new CX.MajorGridlinesGridlines(); + // CONSISTENCY(chart-text-style): category-axis gridline color uses + // `xGridlineColor` to distinguish from value-axis `gridlineColor`. + var xglColor = properties.GetValueOrDefault("xGridlineColor") + ?? properties.GetValueOrDefault("xGridline.color"); + if (!string.IsNullOrEmpty(xglColor)) + gl.ShapeProperties = BuildGridlineShapeProperties(xglColor); + axis.AppendChild(gl); + } + + // Tick labels (bin range labels like "[100, 200]") are ON by default + // to match real Excel output. Opt out with tickLabels=false. Note + // that cx:tickLabels itself is an EMPTY element per CT_TickLabels — + // label text styling lives on the axis's own cx:txPr sibling (below), + // NOT inside tickLabels. Nesting txPr under tickLabels produces + // schema-invalid XML that Excel silently "repairs". + if (IsTruthyProp(properties, "tickLabels", defaultValue: true)) + axis.AppendChild(new CX.TickLabels()); + + // CONSISTENCY(chart-text-style): axis-level cx:txPr styles tick + // labels AND axis title text, matching what ApplyAxisTextProperties + // does for regular cChart. Compound form `axisfont=size:color:fontname`. + // Must be AFTER tickLabels per CT_Axis schema sequence + // (catScaling → title → gridlines → tickLabels → numFmt → spPr → txPr). + var axisFont = properties.GetValueOrDefault("axisfont") + ?? properties.GetValueOrDefault("axis.font"); + if (!string.IsNullOrEmpty(axisFont)) + { + var tickTxPr = BuildAxisTickLabelStyle(axisFont); + if (tickTxPr != null) axis.AppendChild(tickTxPr); + } + + // CONSISTENCY(chart-axis-line): optional category-axis spine outline. + // cataxis.line takes precedence over the shared axis.line. + var catAxisLine = properties.GetValueOrDefault("cataxisline") + ?? properties.GetValueOrDefault("cataxis.line") + ?? properties.GetValueOrDefault("axisline") + ?? properties.GetValueOrDefault("axis.line"); + if (!string.IsNullOrEmpty(catAxisLine)) + ApplyCxAxisLine(axis, catAxisLine); + + return axis; + } + + private static CX.Axis BuildValueAxis(uint id, Dictionary properties) + { + var axis = new CX.Axis { Id = id }; + + // CONSISTENCY(chart-axis-visibility): axis.visible / axis.delete are + // mutually exclusive aliases for the same knob. valaxis.visible is + // the value-axis-only variant (matches ChartHelper.Setter.cs:817). + ApplyAxisHiddenFromProps(axis, properties, catOnly: false, valOnly: true); + + // CONSISTENCY(chart-axis-scaling): parse axismin/axismax/majorunit/ + // minorunit at Build time so newly created charts already have them. + var valScaling = new CX.ValueAxisScaling(); + ApplyValueAxisScalingFromProps(valScaling, properties); + axis.AppendChild(valScaling); + + if (properties.TryGetValue("yAxisTitle", out var yTitle) && !string.IsNullOrEmpty(yTitle)) + axis.AppendChild(BuildAxisTitle(yTitle, properties)); + + // Value-axis gridlines are ON by default — matches Excel's histogram + // and column charts out of the box. + if (IsTruthyProp(properties, "gridlines", defaultValue: true)) + { + var gl = new CX.MajorGridlinesGridlines(); + var glColor = properties.GetValueOrDefault("gridlineColor") + ?? properties.GetValueOrDefault("gridline.color"); + if (!string.IsNullOrEmpty(glColor)) + gl.ShapeProperties = BuildGridlineShapeProperties(glColor); + axis.AppendChild(gl); + } + + if (IsTruthyProp(properties, "tickLabels", defaultValue: true)) + axis.AppendChild(new CX.TickLabels()); + + // cx:txPr must come after tickLabels per CT_Axis schema. See the + // CONSISTENCY(chart-text-style) note in BuildCategoryAxis above. + var axisFont = properties.GetValueOrDefault("axisfont") + ?? properties.GetValueOrDefault("axis.font"); + if (!string.IsNullOrEmpty(axisFont)) + { + var tickTxPr = BuildAxisTickLabelStyle(axisFont); + if (tickTxPr != null) axis.AppendChild(tickTxPr); + } + + // CONSISTENCY(chart-axis-line): optional value-axis spine outline. + // Accepts "color", "color:width", "color:width:dash", or "none". + // ApplyCxAxisLine handles placement within the cx:axis schema. + var valAxisLine = properties.GetValueOrDefault("valaxisline") + ?? properties.GetValueOrDefault("valaxis.line") + ?? properties.GetValueOrDefault("axisline") + ?? properties.GetValueOrDefault("axis.line"); + if (!string.IsNullOrEmpty(valAxisLine)) + ApplyCxAxisLine(axis, valAxisLine); + + return axis; + } + + /// + /// Apply CX.Axis.Hidden from the three-way prop set: axis.visible / + /// axisvisible / axis.delete (both axes), cataxis.visible / + /// cataxisvisible (category-only), valaxis.visible / valaxisvisible + /// (value-only). The caller passes catOnly/valOnly flags indicating + /// which specific axis is being built; the shared prop still applies + /// universally. Matches ChartHelper.Setter.cs:795. + /// + private static void ApplyAxisHiddenFromProps( + CX.Axis axis, Dictionary properties, bool catOnly, bool valOnly) + { + // Universal axis.visible / axis.delete first (if present). + var universalVisible = properties.GetValueOrDefault("axis.visible") + ?? properties.GetValueOrDefault("axisvisible"); + if (!string.IsNullOrEmpty(universalVisible)) + axis.Hidden = !ParseHelpers.IsTruthy(universalVisible); + + var universalDelete = properties.GetValueOrDefault("axis.delete"); + if (!string.IsNullOrEmpty(universalDelete)) + axis.Hidden = ParseHelpers.IsTruthy(universalDelete); + + // Axis-specific override (takes precedence over the universal form). + if (catOnly) + { + var cv = properties.GetValueOrDefault("cataxis.visible") + ?? properties.GetValueOrDefault("cataxisvisible"); + if (!string.IsNullOrEmpty(cv)) axis.Hidden = !ParseHelpers.IsTruthy(cv); + } + if (valOnly) + { + var vv = properties.GetValueOrDefault("valaxis.visible") + ?? properties.GetValueOrDefault("valaxisvisible"); + if (!string.IsNullOrEmpty(vv)) axis.Hidden = !ParseHelpers.IsTruthy(vv); + } + } + + /// + /// Copy axismin / axismax / majorunit / minorunit from properties onto + /// a . These are string-typed attributes + /// in cx namespace (unlike c:scaling which uses typed doubles), but we + /// still round-trip through + /// so NaN/Infinity are rejected. + /// + private static void ApplyValueAxisScalingFromProps( + CX.ValueAxisScaling scaling, Dictionary properties) + { + string? FormatIfPresent(string keyA, string? keyB) + { + var v = properties.GetValueOrDefault(keyA); + if (string.IsNullOrEmpty(v) && keyB != null) v = properties.GetValueOrDefault(keyB); + if (string.IsNullOrEmpty(v)) return null; + var d = ParseHelpers.SafeParseDouble(v, keyA); + return d.ToString("G", CultureInfo.InvariantCulture); + } + + var min = FormatIfPresent("axismin", "min"); + if (min != null) scaling.Min = min; + + var max = FormatIfPresent("axismax", "max"); + if (max != null) scaling.Max = max; + + var maj = FormatIfPresent("majorunit", null); + if (maj != null) scaling.MajorUnit = maj; + + var mnr = FormatIfPresent("minorunit", null); + if (mnr != null) scaling.MinorUnit = mnr; + } + + private static bool IsTruthyProp(Dictionary properties, string key, bool defaultValue) + { + if (!properties.TryGetValue(key, out var v) || string.IsNullOrEmpty(v)) + return defaultValue; + return !(v.Equals("false", StringComparison.OrdinalIgnoreCase) + || v.Equals("off", StringComparison.OrdinalIgnoreCase) + || v == "0" + || v.Equals("no", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Build a single cx:data block for one boxWhisker group. + /// Includes a strDim type="cat" with the group name repeated once per data + /// point so the X axis shows the group label. The strDim is per-data-block + /// (not shared across series), so each group stays at its own X position. + /// + private static CX.Data BuildBoxWhiskerGroupDataBlock(uint id, double[] values, string groupName) + { + var data = new CX.Data { Id = id }; + + // strDim provides the X-axis label for this group. + // Repeat the group name once per data point (required: ptCount must equal numDim ptCount). + var strDim = new CX.StringDimension { Type = CX.StringDimensionType.Cat }; + var strLvl = new CX.StringLevel { PtCount = (uint)values.Length }; + for (int i = 0; i < values.Length; i++) + strLvl.AppendChild(new CX.ChartStringValue(groupName) { Index = (uint)i }); + strDim.AppendChild(strLvl); + data.AppendChild(strDim); + + var numDim = new CX.NumericDimension { Type = CX.NumericDimensionType.Val }; + var numLvl = new CX.NumericLevel { PtCount = (uint)values.Length, FormatCode = "General" }; + for (int i = 0; i < values.Length; i++) + numLvl.AppendChild(new CX.NumericValue(values[i].ToString("G", CultureInfo.InvariantCulture)) { Idx = (uint)i }); + numDim.AppendChild(numLvl); + data.AppendChild(numDim); + + return data; + } + + private static CX.Data BuildDataBlock(uint id, string chartType, string[]? categories, double[] values) + { + var data = new CX.Data { Id = id }; + + // String dimension for categories (if provided). Pareto is included + // because both of its series (clusteredColumn + paretoLine) share + // the same sorted category labels — unlike histogram which auto-bins + // numeric data and has no explicit categories. + if (categories != null && chartType is "funnel" or "treemap" or "sunburst" or "boxwhisker" or "pareto") + { + var strDim = new CX.StringDimension { Type = CX.StringDimensionType.Cat }; + + // boxWhisker: each data block carries ONE group label but N values. + // strDim.PtCount must equal numDim.PtCount — Excel requires them to + // match or it collapses all series onto the same X position. + // Repeat the single label N times (once per data point) so the + // counts align. funnel/treemap/sunburst keep their original 1:1 mapping. + bool repeatSingle = chartType == "boxwhisker" && categories.Length == 1; + int ptCount = repeatSingle ? values.Length : categories.Length; + + var strLvl = new CX.StringLevel { PtCount = (uint)ptCount }; + for (int i = 0; i < ptCount; i++) + { + string cat = repeatSingle ? categories[0] : categories[i]; + strLvl.AppendChild(new CX.ChartStringValue(cat) { Index = (uint)i }); + } + strDim.AppendChild(strLvl); + data.AppendChild(strDim); + } + + // Numeric dimension + var numType = chartType is "treemap" or "sunburst" + ? CX.NumericDimensionType.Size + : CX.NumericDimensionType.Val; + var numDim = new CX.NumericDimension { Type = numType }; + var numLvl = new CX.NumericLevel { PtCount = (uint)values.Length, FormatCode = "General" }; + for (int i = 0; i < values.Length; i++) + numLvl.AppendChild(new CX.NumericValue(values[i].ToString("G")) { Idx = (uint)i }); + numDim.AppendChild(numLvl); + data.AppendChild(numDim); + + return data; + } + + private static CX.SeriesLayoutProperties? BuildLayoutProperties( + string chartType, Dictionary properties, int valueCount) + { + switch (chartType) + { + case "treemap": + { + var lp = new CX.SeriesLayoutProperties(); + var parentLayout = properties.GetValueOrDefault("parentLabelLayout") ?? "overlapping"; + lp.AppendChild(new CX.ParentLabelLayout + { + ParentLabelLayoutVal = parentLayout.ToLowerInvariant() switch + { + "none" => CX.ParentLabelLayoutVal.None, + "banner" => CX.ParentLabelLayoutVal.Banner, + _ => CX.ParentLabelLayoutVal.Overlapping + } + }); + return lp; + } + case "boxwhisker": + { + var lp = new CX.SeriesLayoutProperties(); + lp.AppendChild(new CX.SeriesElementVisibilities + { + MeanLine = false, MeanMarker = true, + Nonoutliers = false, Outliers = true + }); + var method = properties.GetValueOrDefault("quartileMethod") ?? "exclusive"; + lp.AppendChild(new CX.Statistics + { + QuartileMethod = method.ToLowerInvariant() switch + { + "inclusive" => CX.QuartileMethod.Inclusive, + _ => CX.QuartileMethod.Exclusive + } + }); + return lp; + } + case "histogram": + { + // cx:layoutPr > cx:binning (empty for auto-bin; child cx:binCount + // OR cx:binSize for explicit bin count/width). `cx:aggregation` + // is for Pareto charts and causes Excel to render the whole + // dataset as a single bar. + // + // NOTE: the Open XML SDK models cx:binCount as a leaf text + // element (BinCountXsdunsignedInt → `5`), + // but real Excel writes it as an empty element with a `val` + // attribute (``). SDK's form is schema- + // valid per the generated type metadata but Excel rejects the + // whole file with "We found a problem with some content" + // and deletes the drawing. Same applies to cx:binSize. Work + // around by appending a raw OpenXmlUnknownElement carrying + // the correct form. + const string cxNs = "http://schemas.microsoft.com/office/drawing/2014/chartex"; + var lp = new CX.SeriesLayoutProperties(); + var binning = new CX.Binning(); + + // intervalClosed: "r" (default, bins are (a,b]) or "l" (bins are [a,b)) + var intervalClosed = properties.GetValueOrDefault("intervalClosed") ?? "r"; + binning.IntervalClosed = intervalClosed.ToLowerInvariant() switch + { + "l" => CX.IntervalClosedSide.L, + _ => CX.IntervalClosedSide.R, + }; + + // underflow / overflow: cut-off values for outlier bins + if (properties.TryGetValue("underflowBin", out var underflow)) + binning.Underflow = underflow; + if (properties.TryGetValue("overflowBin", out var overflow)) + binning.Overflow = overflow; + + // binCount (explicit count) XOR binSize (explicit width). If + // both are given, binCount wins (it's the more common knob). + if (properties.TryGetValue("binCount", out var binCountStr) && + uint.TryParse(binCountStr, out var binCount)) + { + var binCountEl = new OpenXmlUnknownElement("cx", "binCount", cxNs); + binCountEl.SetAttribute(new OpenXmlAttribute("val", "", binCount.ToString())); + binning.AppendChild(binCountEl); + } + else if (properties.TryGetValue("binSize", out var binSizeStr) && + double.TryParse(binSizeStr, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var binSize)) + { + var binSizeEl = new OpenXmlUnknownElement("cx", "binSize", cxNs); + binSizeEl.SetAttribute(new OpenXmlAttribute("val", "", + binSize.ToString("G", System.Globalization.CultureInfo.InvariantCulture))); + binning.AppendChild(binSizeEl); + } + + lp.AppendChild(binning); + return lp; + } + default: + return null; + } + } + + private static CX.SeriesLayout ParseSeriesLayout(string layoutId) + { + return layoutId switch + { + "funnel" => CX.SeriesLayout.Funnel, + "treemap" => CX.SeriesLayout.Treemap, + "sunburst" => CX.SeriesLayout.Sunburst, + "boxWhisker" => CX.SeriesLayout.BoxWhisker, + "clusteredColumn" => CX.SeriesLayout.ClusteredColumn, + "paretoLine" => CX.SeriesLayout.ParetoLine, + "regionMap" => CX.SeriesLayout.RegionMap, + _ => CX.SeriesLayout.Funnel + }; + } + + /// + /// Detect if a cx:chartSpace contains an extended chart type and return the type name. + /// Also handles MSO-authored Pareto files which may contain both a clusteredColumn + /// and a paretoLine series — if any series has paretoLine layout, it's a pareto. + /// + internal static string? DetectExtendedChartType(CX.ChartSpace chartSpace) + { + var allSeries = chartSpace.Descendants().ToList(); + if (allSeries.Count == 0) return null; + + // Pareto: any paretoLine series ⇒ the whole chart is a pareto. + // Handles both OfficeCli-authored (single paretoLine series) and + // MSO-authored (clusteredColumn + paretoLine pair) forms. + if (allSeries.Any(s => s.LayoutId?.InnerText == "paretoLine")) + return "pareto"; + + var layoutId = allSeries[0].LayoutId?.InnerText; + if (layoutId == null) return null; + return layoutId switch + { + "funnel" => "funnel", + "treemap" => "treemap", + "sunburst" => "sunburst", + "boxWhisker" => "boxWhisker", + "clusteredColumn" => "histogram", + "regionMap" => "regionMap", + _ => layoutId + }; + } + + /// + /// Transform a user's single-series Pareto input into the 2-series form + /// that Excel's cx:chart pareto uses internally. The first user series + /// is sorted descending (biggest first); cumulative percentages are + /// computed on the sorted order and returned as the second series. + /// If the user supplies multiple series, extras are silently ignored — + /// pareto is inherently univariate. + /// + /// + /// Pre-sort the user's single series descending for Pareto. Returns a + /// single series (the sorted values); the cumulative-% paretoLine + /// series is appended in BuildExtendedChartSpace via ownerIdx=0 + /// (Excel auto-computes cumulative from the bar data). + /// + private static (string[]? categories, List<(string name, double[] values)> seriesData) + PreparePareto(string[]? categories, List<(string name, double[] values)> seriesData) + { + if (seriesData.Count == 0) + return (categories, seriesData); + + var (srcName, srcValues) = seriesData[0]; + int n = srcValues.Length; + if (n == 0) + return (categories, seriesData); + + var cats = (categories != null && categories.Length == n) + ? categories + : Enumerable.Range(1, n).Select(i => i.ToString(CultureInfo.InvariantCulture)).ToArray(); + + // Sort by value descending; stable for equal values. + var indices = Enumerable.Range(0, n).OrderByDescending(i => srcValues[i]).ToArray(); + var sortedCats = indices.Select(i => cats[i]).ToArray(); + var sortedVals = indices.Select(i => srcValues[i]).ToArray(); + + var barsName = string.IsNullOrEmpty(srcName) ? "Value" : srcName; + return (sortedCats, new List<(string, double[])> + { + (barsName, sortedVals), + }); + } +} diff --git a/src/officecli/Core/Chart/ChartExStyleBuilder.cs b/src/officecli/Core/Chart/ChartExStyleBuilder.cs new file mode 100644 index 000000000..a16b6a0d4 --- /dev/null +++ b/src/officecli/Core/Chart/ChartExStyleBuilder.cs @@ -0,0 +1,381 @@ +// Copyright 2025 OfficeCli (officecli.ai) +// SPDX-License-Identifier: Apache-2.0 + +using System.Text; +using System.Text.Json; + +namespace OfficeCli.Core; + +/// +/// Section-based assembler for the cx chartStyle sidecar (an OOXML +/// chartEx auxiliary part defined by ECMA-376 / ISO/IEC 29500). Iterates +/// the canonical chartStyle section tags in schema-required order and +/// emits, for each section, either a curated fragment looked up by the +/// caller's (chartType, variant) key or a minimal schema-compliant +/// fallback provided by . +/// +/// The result is a single byte stream suitable for feeding directly +/// into ChartStylePart.FeedData. +/// +internal static class ChartExStyleBuilder +{ + /// + /// Canonical chartStyle section order. Must match the CT_ChartStyle + /// schema sequence — Excel silently repairs (drops) the whole chart + /// if a section is missing, reordered, or unknown. + /// + internal static readonly string[] Sections = new[] + { + "axisTitle", + "categoryAxis", + "chartArea", + "dataLabel", + "dataLabelCallout", + "dataPoint", + "dataPoint3D", + "dataPointLine", + "dataPointMarker", + "dataPointMarkerLayout", + "dataPointWireframe", + "dataTable", + "downBar", + "dropLine", + "errorBar", + "floor", + "gridlineMajor", + "gridlineMinor", + "hiLoLine", + "leaderLine", + "legend", + "plotArea", + "plotArea3D", + "seriesAxis", + "seriesLine", + "title", + "trendline", + "trendlineLabel", + "upBar", + "valueAxis", + "wall", + }; + + private const string CsNs = "http://schemas.microsoft.com/office/drawing/2012/chartStyle"; + private const string ANs = "http://schemas.openxmlformats.org/drawingml/2006/main"; + + /// + /// Build a cx chartStyle.xml stream for the given chart type and + /// optional style variant. Caller feeds the stream into + /// ChartStylePart.FeedData. + /// + /// + /// The cx chart type name (case-insensitive, whitespace/dash/underscore + /// tolerated via ). Used as part + /// of the section lookup key. + /// + /// + /// Optional style variant name. Defaults to "default". Also + /// accepts "style1".."style10" or bare integers + /// "1".."10". + /// + internal static Stream BuildChartStyleXml( + string chartType, string variant = "default") + { + var normalizedType = NormalizeTypeForLookup(chartType); + var normalizedVariant = NormalizeVariantForLookup(variant); + + var entry = GalleryIndex.TryGet(normalizedType, normalizedVariant); + var styleId = entry?.StyleId ?? 410; + + var sb = new StringBuilder(4096); + sb.Append(""); + sb.Append( + $""); + + foreach (var section in Sections) + { + string? fragment = null; + if (entry != null + && entry.Fragments.TryGetValue(section, out var fragId)) + { + fragment = FragmentStore.TryLoad(fragId); + } + // Any missing section falls through to the minimal + // schema-compliant scaffold below. + fragment ??= MinimalScaffold.For(section); + sb.Append(fragment); + } + + sb.Append(""); + return new MemoryStream(Encoding.UTF8.GetBytes(sb.ToString())); + } + + /// + /// Normalize a chart type name to the lookup key used by the + /// internal style index. Matches ChartExBuilder.IsExtendedChartType + /// so "Box Whisker" / "box-whisker" / "BOXWHISKER" / "box_whisker" + /// all resolve to the same entry. + /// + internal static string NormalizeTypeForLookup(string chartType) + { + return chartType.ToLowerInvariant() + .Replace(" ", "") + .Replace("_", "") + .Replace("-", ""); + } + + /// + /// Normalize a variant name to the lookup key used by the internal + /// style index. Accepts default, style{N}, bare + /// integers ("3""style3"), and any case. + /// + internal static string NormalizeVariantForLookup(string variant) + { + if (string.IsNullOrWhiteSpace(variant)) return "default"; + var v = variant.Trim().ToLowerInvariant(); + if (v == "default" || v == "0") return "default"; + if (int.TryParse(v, out var n) && n >= 1 && n <= 10) return $"style{n}"; + return v; + } +} + +/// +/// Minimal schema-compliant default fragments for cx chartStyle sections. +/// Every fragment is a self-contained <cs:section> element +/// with zero chart-type dependencies — safe to emit for any cx chart. +/// Each child of cs:styleEntry is minOccurs=0 per +/// CT_StyleEntry, so the generic 4-ref form is the smallest +/// schema-valid content Excel accepts. +/// +internal static class MinimalScaffold +{ + /// + /// Return the minimal default fragment for a given chartStyle section + /// name. Specific sections need enriched content to keep the chart + /// visually coherent; the rest get the generic 4-ref scaffold. + /// + internal static string For(string section) => section switch + { + // chartArea needs a visible background + outline for the chart + // rectangle to render at all. + "chartArea" => + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "", + + // dataPoint uses the phClr placeholder fill so the accent color + // from the accompanying chartColorStyle sidecar flows through. + "dataPoint" => + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "", + + // dataPointMarkerLayout is a self-closing element with + // symbol/size attributes per CT_MarkerLayoutProperties — unlike + // every other section it's not a CT_StyleEntry composite. + "dataPointMarkerLayout" => + "", + + // plotArea / plotArea3D carry the `mods` attribute so Excel + // honors user fill/line overrides emitted into chart.xml via + // the plotareafill / plotarea.border knobs. + "plotArea" => + "" + + "" + + "" + + "" + + "" + + "", + + "plotArea3D" => + "" + + "" + + "" + + "" + + "" + + "", + + // Generic 4-ref scaffold — the smallest schema-valid form per + // CT_StyleEntry (every child is minOccurs=0). + _ => + $"" + + "" + + "" + + "" + + "" + + $"" + }; +} + +/// +/// In-memory lookup table mapping (chartType, variant) to a set +/// of per-section fragment IDs consumed by . +/// Backed by an optional embedded resource; if the resource isn't +/// present, always returns null and the builder +/// emits everywhere. +/// +/// Lazy-loaded on first access, cached for process lifetime, thread-safe +/// via double-checked lock. +/// +internal static class GalleryIndex +{ + private const string IndexResourceName = + "OfficeCli.Resources.cx-gallery.index.json"; + + private static Dictionary? _cache; + private static readonly object _cacheLock = new(); + + /// + /// Look up the style entry for a given (chartType, variant) pair. + /// Returns null when the index has nothing for that key, in which + /// case falls back to + /// for every section. + /// + internal static GalleryEntry? TryGet(string chartType, string variant) + { + var cache = EnsureLoaded(); + if (cache == null) return null; + var key = $"{chartType.ToLowerInvariant()}/{variant.ToLowerInvariant()}"; + return cache.TryGetValue(key, out var entry) ? entry : null; + } + + /// + /// Expose the set of known (type, variant) keys for diagnostics. + /// + internal static IReadOnlyCollection KnownKeys() + { + var cache = EnsureLoaded(); + return cache?.Keys ?? (IReadOnlyCollection)Array.Empty(); + } + + private static Dictionary? EnsureLoaded() + { + if (_cache != null) return _cache; + lock (_cacheLock) + { + if (_cache != null) return _cache; + _cache = LoadFromEmbeddedResource() ?? new Dictionary(); + } + return _cache; + } + + private static Dictionary? LoadFromEmbeddedResource() + { + var assembly = typeof(GalleryIndex).Assembly; + using var stream = assembly.GetManifestResourceStream(IndexResourceName); + if (stream == null) + { + // No index resource embedded — TryGet returns null and the + // builder falls back to minimal scaffolds for every section. + return null; + } + + using var doc = JsonDocument.Parse(stream); + var root = doc.RootElement; + if (!root.TryGetProperty("entries", out var entriesEl) + || entriesEl.ValueKind != JsonValueKind.Object) + { + return null; + } + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var entry in entriesEl.EnumerateObject()) + { + var key = entry.Name.ToLowerInvariant(); + var val = entry.Value; + if (val.ValueKind != JsonValueKind.Object) continue; + + int styleId = 410; + if (val.TryGetProperty("styleId", out var styleIdEl) + && styleIdEl.ValueKind == JsonValueKind.Number) + { + styleId = styleIdEl.GetInt32(); + } + + var fragMap = new Dictionary(StringComparer.Ordinal); + if (val.TryGetProperty("fragments", out var fragsEl) + && fragsEl.ValueKind == JsonValueKind.Object) + { + foreach (var frag in fragsEl.EnumerateObject()) + { + if (frag.Value.ValueKind == JsonValueKind.String) + { + fragMap[frag.Name] = frag.Value.GetString()!; + } + } + } + + result[key] = new GalleryEntry(styleId, fragMap); + } + return result; + } +} + +/// +/// Record holding one (chartType, variant) entry: the numeric +/// cs:chartStyle @id and a map from section name to fragment ID. +/// Sections not in the map fall through to . +/// +internal sealed record GalleryEntry( + int StyleId, + IReadOnlyDictionary Fragments); + +/// +/// Loads individual chartStyle section fragments by their content-hash +/// ID from embedded resources. Fragments are lazily loaded on first +/// request and cached for the process lifetime. Thread-safe via a +/// lock-free . +/// +internal static class FragmentStore +{ + private const string FragmentResourcePrefix = + "OfficeCli.Resources.cx-gallery.fragments."; + + private static readonly System.Collections.Concurrent.ConcurrentDictionary _cache + = new(StringComparer.Ordinal); + + /// + /// Load the raw XML text of a single chartStyle section fragment + /// by its content-hash ID. Returns null if the fragment isn't + /// embedded — caller () then falls + /// back to . + /// + internal static string? TryLoad(string fragmentId) + { + return _cache.GetOrAdd(fragmentId, LoadFromEmbeddedResource); + } + + private static string? LoadFromEmbeddedResource(string fragmentId) + { + var assembly = typeof(FragmentStore).Assembly; + var resourceName = FragmentResourcePrefix + fragmentId + ".xml"; + using var stream = assembly.GetManifestResourceStream(resourceName); + if (stream == null) return null; + using var reader = new StreamReader(stream, Encoding.UTF8); + return reader.ReadToEnd(); + } +} diff --git a/src/officecli/Core/ChartAdvancedFeatures.cs b/src/officecli/Core/Chart/ChartHelper.Advanced.cs similarity index 79% rename from src/officecli/Core/ChartAdvancedFeatures.cs rename to src/officecli/Core/Chart/ChartHelper.Advanced.cs index a61de6016..c7ca9a40b 100644 --- a/src/officecli/Core/ChartAdvancedFeatures.cs +++ b/src/officecli/Core/Chart/ChartHelper.Advanced.cs @@ -16,11 +16,20 @@ internal static partial class ChartHelper /// /// Add a reference (target/average) line to a chart by inserting a hidden line series. - /// Format: "value" or "value:color" or "value:color:label" or "value:color:label:dash" - /// e.g. "50", "75:FF0000", "100:00AA00:Target", "80:0000FF:Average:dash" + /// Format (positional, ':'-separated): + /// value + /// value:color + /// value:color:label + /// value:color:width:dash (4 parts, if parts[2] is numeric and parts[3] is a known dash style) + /// value:color:label:dash (4 parts, legacy — parts[2] is non-numeric) + /// value:color:width:dash:label (5 parts, canonical — parts[2] may be empty for default width) + /// Width is in points (default 1.5pt). Dash style: solid/dot/dash/dashdot/longdash/longdashdot/longdashdotdot. + /// e.g. "50", "75:FF0000", "100:00AA00:Target", "80:0000FF:Average:dash", + /// "50:FF0000:2.5:dash", "50:FF0000:2:dash:Target", "50:FF0000::dash:Target" /// internal static void AddReferenceLine(C.Chart chart, string spec) { + const double DefaultWidthPt = 1.5; var plotArea = chart.GetFirstChild(); if (plotArea == null) return; @@ -32,11 +41,70 @@ internal static void AddReferenceLine(C.Chart chart, string spec) System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var refValue)) throw new ArgumentException( - $"Invalid referenceLine value '{parts[0]}'. Expected: number or number:color:label:dash (e.g. '50:FF0000:Target:dash')."); + $"Invalid referenceLine value '{parts[0]}'. Expected: number or number:color:label:dash (e.g. '50:FF0000:Target:dash') or number:color:width:dash (e.g. '50:FF0000:2:dash')."); var color = parts.Length > 1 ? parts[1].Trim() : "FF0000"; - var label = parts.Length > 2 ? parts[2].Trim() : $"Ref ({refValue})"; - var dash = parts.Length > 3 ? parts[3].Trim() : "dash"; + double widthPt = DefaultWidthPt; + string label = $"Ref ({refValue.ToString("G", System.Globalization.CultureInfo.InvariantCulture)})"; + string dash = "dash"; + + // Positional parse — see doc comment above. parts[0..1] already consumed. + if (parts.Length == 3) + { + label = parts[2].Trim(); + } + else if (parts.Length == 4) + { + var p2 = parts[2].Trim(); + var p3 = parts[3].Trim(); + // Disambiguate: "50:FF0000:2.5:dash" (width form) vs "50:FF0000:Target:dash" (legacy label form). + // Only treat p2 as width if it parses as a number AND p3 is a recognized dash keyword — both + // conditions together make the "ergonomic" width interpretation unambiguous. + if (double.TryParse(p2, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var w4) + && IsKnownDashStyle(p3)) + { + widthPt = w4; + dash = p3; + } + else + { + label = p2; + dash = p3; + } + } + else if (parts.Length >= 5) + { + // Canonical 5-part form: value:color:width:dash:label (extra parts after label are joined + // back with ':' so labels containing literal colons survive a round-trip). + var widthStr = parts[2].Trim(); + if (widthStr.Length > 0) + { + if (!double.TryParse(widthStr, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out widthPt)) + throw new ArgumentException( + $"Invalid referenceLine width '{widthStr}'. Expected a number in points (e.g. '1.5'), or empty for default {DefaultWidthPt}pt."); + } + dash = parts[3].Trim(); + label = string.Join(':', parts.Skip(4)).Trim(); + } + + if (widthPt <= 0 || widthPt > 100) + throw new ArgumentException( + $"Invalid referenceLine width '{widthPt.ToString("G", System.Globalization.CultureInfo.InvariantCulture)}'. Expected a positive number of points, typically 0.25–10."); + + // Warn: percent-stacked value axis is 0-1 (displayed 0%-100%). A refValue > 1 + // is almost always a mistake — user likely forgot to convert 50 → 0.5. + // Without this check, Excel silently stretches the val axis to fit (e.g. 5000%), + // producing a chart where the real bars are compressed to a thin sliver on the left. + if (refValue > 1.0 && IsPercentStackedChart(plotArea)) + { + Console.Error.WriteLine( + $"Warning: referenceLine value {refValue.ToString("G", System.Globalization.CultureInfo.InvariantCulture)} " + + "on a percent-stacked chart. The value axis is 0-1 (0%-100%); " + + $"did you mean {(refValue / 100.0).ToString("G", System.Globalization.CultureInfo.InvariantCulture)}? " + + "Excel will auto-scale the axis to fit, compressing the real bars."); + } // Find max data point count from existing series (after removing old ref lines) var existingSerCount = CountSeries(plotArea); @@ -92,9 +160,9 @@ internal static void AddReferenceLine(C.Chart chart, string spec) refSer.AppendChild(new C.Order { Val = seriesIdx }); refSer.AppendChild(new C.SeriesText(new C.NumericValue(label))); - // Style: colored dashed line, no markers + // Style: colored dashed line, no markers. Width is pt → EMU (1pt = 12700 EMU). var spPr = new C.ChartShapeProperties(); - var outline = new Drawing.Outline { Width = 19050 }; // 1.5pt + var outline = new Drawing.Outline { Width = (int)Math.Round(widthPt * 12700) }; var sf = new Drawing.SolidFill(); sf.AppendChild(BuildChartColorElement(color)); outline.AppendChild(sf); @@ -172,6 +240,38 @@ internal static void RemoveExistingReferenceLines(C.PlotArea plotArea) lineChart.Remove(); } + /// + /// Returns true if any chart in the plot area uses percent-stacked grouping. + /// BarChart/Bar3DChart use BarGrouping; LineChart/AreaChart use Grouping. + /// + private static bool IsPercentStackedChart(C.PlotArea plotArea) + { + foreach (var el in plotArea.Elements()) + { + var barGrouping = el.GetFirstChild()?.Val?.Value; + if (barGrouping == C.BarGroupingValues.PercentStacked) return true; + + var grouping = el.GetFirstChild()?.Val?.Value; + if (grouping == C.GroupingValues.PercentStacked) return true; + } + return false; + } + + /// + /// Returns true if the given token matches a dash style accepted by ParseDashStyle + /// (see ChartHelper.Setter.cs). Used for the referenceLine numeric-label heuristic. + /// + private static bool IsKnownDashStyle(string token) + { + return token.ToLowerInvariant() switch + { + "solid" or "dot" or "sysdot" or "dash" or "sysdash" + or "dashdot" or "sysdash_dot" + or "longdash" or "longdashdot" or "longdashdotdot" => true, + _ => false + }; + } + // ==================== Conditional Coloring ==================== /// diff --git a/src/officecli/Core/ChartBuilder.cs b/src/officecli/Core/Chart/ChartHelper.Builder.cs similarity index 69% rename from src/officecli/Core/ChartBuilder.cs rename to src/officecli/Core/Chart/ChartHelper.Builder.cs index 303eba4e6..9be092d2b 100644 --- a/src/officecli/Core/ChartBuilder.cs +++ b/src/officecli/Core/Chart/ChartHelper.Builder.cs @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 using DocumentFormat.OpenXml; -using DocumentFormat.OpenXml.Packaging; using Drawing = DocumentFormat.OpenXml.Drawing; using C = DocumentFormat.OpenXml.Drawing.Charts; @@ -76,24 +75,80 @@ internal static C.ChartSpace BuildChartSpace( chartElement = BuildBarChart(C.BarDirectionValues.Column, stacked, percentStacked, categories, seriesData, catAxisId, valAxisId, colors); break; + case "line" when is3D: + { + var grouping3d = percentStacked ? C.GroupingValues.PercentStacked + : stacked ? C.GroupingValues.Stacked + : C.GroupingValues.Standard; + var line3d = new C.Line3DChart( + new C.Grouping { Val = grouping3d }, + new C.VaryColors { Val = false } + ); + for (int i = 0; i < seriesData.Count; i++) + { + var color = colors != null && i < colors.Length ? colors[i] : DefaultSeriesColors[i % DefaultSeriesColors.Length]; + line3d.AppendChild(BuildLineSeries((uint)i, seriesData[i].name, + categories, seriesData[i].values, color)); + } + line3d.AppendChild(new C.AxisId { Val = catAxisId }); + line3d.AppendChild(new C.AxisId { Val = valAxisId }); + chartElement = line3d; + break; + } case "line": chartElement = BuildLineChart(stacked, percentStacked, categories, seriesData, catAxisId, valAxisId, colors); break; + case "area" when is3D: + { + var grouping3d = percentStacked ? C.GroupingValues.PercentStacked + : stacked ? C.GroupingValues.Stacked + : C.GroupingValues.Standard; + var area3d = new C.Area3DChart( + new C.Grouping { Val = grouping3d }, + new C.VaryColors { Val = false } + ); + for (int i = 0; i < seriesData.Count; i++) + { + var color = colors != null && i < colors.Length ? colors[i] : DefaultSeriesColors[i % DefaultSeriesColors.Length]; + area3d.AppendChild(BuildAreaSeries((uint)i, seriesData[i].name, + categories, seriesData[i].values, color)); + } + area3d.AppendChild(new C.AxisId { Val = catAxisId }); + area3d.AppendChild(new C.AxisId { Val = valAxisId }); + chartElement = area3d; + break; + } case "area": chartElement = BuildAreaChart(stacked, percentStacked, categories, seriesData, catAxisId, valAxisId, colors); break; + case "pie" when is3D: + { + var pie3d = new C.Pie3DChart( + new C.VaryColors { Val = true } + ); + for (int i = 0; i < seriesData.Count; i++) + { + var color = colors != null && i < colors.Length ? colors[i] : DefaultSeriesColors[i % DefaultSeriesColors.Length]; + pie3d.AppendChild(BuildPieSeries((uint)i, seriesData[i].name, + categories, seriesData[i].values, color)); + } + chartElement = pie3d; + needsAxes = false; + 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": - chartElement = BuildScatterChart(categories, seriesData, catAxisId, valAxisId); + var scatterStyle = properties.GetValueOrDefault("scatterStyle", "lineMarker"); + chartElement = BuildScatterChart(categories, seriesData, catAxisId, valAxisId, scatterStyle); break; case "bubble": chartElement = BuildBubbleChart(categories, seriesData, catAxisId, valAxisId, colors); @@ -215,6 +270,7 @@ internal static C.ChartSpace BuildChartSpace( "left" or "l" => C.LegendPositionValues.Left, "right" or "r" => C.LegendPositionValues.Right, "bottom" or "b" => C.LegendPositionValues.Bottom, + "topright" or "tr" or "top-right" => C.LegendPositionValues.TopRight, _ => C.LegendPositionValues.Bottom }; chart.AppendChild(new C.Legend( @@ -241,8 +297,21 @@ internal static C.ChartSpace BuildChartSpace( private static void ApplySeriesReferences(C.PlotArea plotArea, Dictionary properties) { var extSeries = ParseSeriesDataExtended(properties); - if (extSeries == null || extSeries.Count == 0) return; - if (!extSeries.Any(s => s.ValuesRef != null || s.CategoriesRef != null)) + // Also detect name-only cell references (series{N}.name=Sheet1!A1) so + // legend text resolves to the cell value instead of a literal string. + bool hasNameRef = false; + if (extSeries != null) + { + for (int i = 0; i < extSeries.Count; i++) + { + if (IsCellReference(extSeries[i].Name)) { hasNameRef = true; break; } + } + } + if (extSeries == null || extSeries.Count == 0) + { + if (!hasNameRef) return; + } + if (extSeries != null && !extSeries.Any(s => s.ValuesRef != null || s.CategoriesRef != null) && !hasNameRef) { // Also check top-level categories ref var topCatRef = ParseCategoriesRef(properties); @@ -255,11 +324,19 @@ private static void ApplySeriesReferences(C.PlotArea plotArea, Dictionary + /// Prefixes for dynamic deferred keys (e.g. title.x, plotArea.y, legend.w, + /// dataLabel1.text, dataTable.show*, displayUnitsLabel.*, trendlineLabel.*). + /// + private static readonly string[] DeferredPrefixes = + [ + "title.", "plotarea.", "legend.", "datalabel", + "datatable.", "displayunitslabel.", "trendlinelabel." + ]; + + /// + /// Check if a property key should be deferred from BuildChartSpace to SetChartProperties. + /// Matches exact keys in plus dynamic prefix patterns. + /// + internal static bool IsDeferredKey(string key) + { + if (DeferredAddKeys.Contains(key)) return true; + var lower = key.ToLowerInvariant(); + foreach (var prefix in DeferredPrefixes) + if (lower.StartsWith(prefix)) return true; + // CONSISTENCY(chart-series-color): select per-series dotted keys + // route through HandleSeriesDottedProperty at SetChartProperties + // time. Only visual-effect subkeys are deferred here; `.name`, + // `.values`, `.categories`, `.ref`, `.valuesRef`, `.categoriesRef`, + // `.color` are consumed at build time by ParseSeriesData / + // ParseSeriesColors and must NOT be deferred (double-apply / + // literal-expansion regressions). + if (TryParseSeriesDottedKey(key, out _, out var sProp) + && DeferredSeriesSubkeys.Contains(sProp)) return true; + return false; + } + + // Per-series dotted subkeys that route through HandleSeriesDottedProperty + // during SetChartProperties (post-build). See IsDeferredKey. + private static readonly HashSet DeferredSeriesSubkeys = new(StringComparer.OrdinalIgnoreCase) + { + "gradient", "gradientfill", + "smooth", "trendline", "marker", "markersize", + "invertifneg", "invertifnegative", + "errbars", "errorbars", + "explosion", "explode", + "linewidth", "linedash", "dash", + "shadow", "outline", + "alpha", "transparency", }; // ==================== Chart Type Builders ==================== @@ -441,32 +585,64 @@ 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) + uint catAxisId, uint valAxisId, string scatterStyle = "lineMarker") { + var styleVal = scatterStyle.ToLowerInvariant() switch + { + "marker" => C.ScatterStyleValues.Marker, + "line" => C.ScatterStyleValues.Line, + "smooth" => C.ScatterStyleValues.SmoothMarker, + "smoothmarker" => C.ScatterStyleValues.SmoothMarker, + _ => C.ScatterStyleValues.LineMarker + }; var scatterChart = new C.ScatterChart( - new C.ScatterStyle { Val = C.ScatterStyleValues.LineMarker }, + new C.ScatterStyle { Val = styleVal }, new C.VaryColors { Val = false } ); @@ -474,10 +650,20 @@ internal static C.ScatterChart BuildScatterChart( if (categories != null) xValues = categories.Select(c => double.TryParse(c, out var v) ? v : 0).ToArray(); + var hideLines = styleVal == C.ScatterStyleValues.Marker; for (int i = 0; i < seriesData.Count; i++) { - scatterChart.AppendChild(BuildScatterSeries((uint)i, seriesData[i].name, - xValues, seriesData[i].values)); + var ser = BuildScatterSeries((uint)i, seriesData[i].name, + xValues, seriesData[i].values); + // For marker-only style, explicitly hide connecting lines + if (hideLines) + { + var spPr = ser.GetFirstChild() ?? new C.ChartShapeProperties(); + if (ser.GetFirstChild() == null) ser.AppendChild(spPr); + spPr.RemoveAllChildren(); + spPr.AppendChild(new Drawing.Outline(new Drawing.NoFill())); + } + scatterChart.AppendChild(ser); } scatterChart.AppendChild(new C.AxisId { Val = catAxisId }); @@ -579,8 +765,8 @@ internal static C.StockChart BuildStockChart( string[]? categories, List<(string name, double[] values)> seriesData, uint catAxisId, uint valAxisId) { - // Stock chart expects series in High-Low-Close order (minimum 3 series) - // or Open-High-Low-Close order (4 series) + // Stock chart expects series in Open-High-Low-Close order (4 series) + // or High-Low-Close order (3 series) var stockChart = new C.StockChart(); for (int i = 0; i < seriesData.Count; i++) @@ -590,11 +776,43 @@ internal static C.StockChart BuildStockChart( new C.Order { Val = (uint)i }, new C.SeriesText(new C.NumericValue(seriesData[i].name)) ); + + // Hide individual series lines — stock chart visuals come from + // hiLowLines + upDownBars, not from the series lines themselves + var spPr = new C.ChartShapeProperties(); + spPr.AppendChild(new Drawing.Outline(new Drawing.NoFill())); + series.AppendChild(spPr); + + // No markers on stock series + series.AppendChild(new C.Marker(new C.Symbol { Val = C.MarkerStyleValues.None })); + if (categories != null) series.AppendChild(BuildCategoryData(categories)); series.AppendChild(BuildValues(seriesData[i].values)); stockChart.AppendChild(series); } + // Hi-low lines: vertical lines connecting High to Low at each data point + stockChart.AppendChild(new C.HighLowLines()); + + // Up-down bars: colored boxes from Open to Close (green=up, red=down) + if (seriesData.Count >= 4) + { + var upDownBars = new C.UpDownBars( + new C.GapWidth { Val = 150 } + ); + var upBars = new C.UpBars(); + var upSpPr = new C.ChartShapeProperties(); + upSpPr.AppendChild(new Drawing.SolidFill(new Drawing.RgbColorModelHex { Val = "4CAF50" })); + upBars.AppendChild(upSpPr); + upDownBars.AppendChild(upBars); + var downBars = new C.DownBars(); + var dnSpPr = new C.ChartShapeProperties(); + dnSpPr.AppendChild(new Drawing.SolidFill(new Drawing.RgbColorModelHex { Val = "F44336" })); + downBars.AppendChild(dnSpPr); + upDownBars.AppendChild(downBars); + stockChart.AppendChild(upDownBars); + } + stockChart.AppendChild(new C.AxisId { Val = catAxisId }); stockChart.AppendChild(new C.AxisId { Val = valAxisId }); return stockChart; @@ -680,13 +898,19 @@ private static OpenXmlElement BuildFillElement(string value) } /// - /// Apply text properties (font, size, color) to all axis labels. - /// Format: "size:color:fontname" e.g. "10:8B949E:Helvetica Neue" or "10:CCCCCC" + /// Parse a compound text-style spec "size:color:fontname" into a + /// . Shared by regular cChart + /// and cx extended chart builders. Unspecified fields keep their + /// defaults (size defaults to 10pt = 1000 hundredths). + /// + /// CONSISTENCY(chart-text-style): this is the single source of truth + /// for parsing compound font specs. Callers wrap the result in their + /// container of choice — for regular + /// cChart, or CX.TxPrTextBody for extended cxChart. /// - internal static void ApplyAxisTextProperties(OpenXmlCompositeElement axis, string value) + internal static Drawing.DefaultRunProperties BuildDefaultRunPropertiesFromCompoundSpec(string spec) { - axis.RemoveAllChildren(); - var parts = value.Split(':'); + var parts = spec.Split(':'); var fontSize = parts.Length > 0 && int.TryParse(parts[0], out var fs) ? fs * 100 : 1000; var color = parts.Length > 1 ? parts[1] : null; var fontName = parts.Length > 2 ? parts[2] : null; @@ -703,6 +927,72 @@ internal static void ApplyAxisTextProperties(OpenXmlCompositeElement axis, strin defRp.AppendChild(new Drawing.LatinFont { Typeface = fontName }); defRp.AppendChild(new Drawing.EastAsianFont { Typeface = fontName }); } + return defRp; + } + + /// + /// Apply run-level styling from `{prefix}.color`/`{prefix}.size`/ + /// `{prefix}.font`/`{prefix}.bold` properties (and dotless aliases + /// `{prefix}color`, `{prefix}size`, ...) onto an existing + /// . Shared by both chart families. + /// + /// CONSISTENCY(chart-text-style): same vocabulary as + /// ChartHelper.Setter.cs case `"title.color"`. Setter keeps its + /// own inline implementation because it layers extra effects (glow / + /// shadow) that are out of scope here. + /// + internal static void ApplyRunStyleProperties(Drawing.RunProperties rPr, + Dictionary properties, string keyPrefix) + { + string? Get(string suffix) + { + if (properties.TryGetValue($"{keyPrefix}.{suffix}", out var v) && !string.IsNullOrEmpty(v)) return v; + if (properties.TryGetValue($"{keyPrefix}{suffix}", out v) && !string.IsNullOrEmpty(v)) return v; + return null; + } + + var color = Get("color"); + if (!string.IsNullOrEmpty(color)) + { + rPr.RemoveAllChildren(); + var sf = new Drawing.SolidFill(); + sf.AppendChild(BuildChartColorElement(color)); + rPr.AppendChild(sf); + } + + var size = Get("size"); + if (!string.IsNullOrEmpty(size)) + { + var sizeStr = size.EndsWith("pt", StringComparison.OrdinalIgnoreCase) ? size[..^2] : size; + if (double.TryParse(sizeStr, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var pts)) + rPr.FontSize = (int)Math.Round(pts * 100); + } + + var font = Get("font"); + if (!string.IsNullOrEmpty(font)) + { + rPr.RemoveAllChildren(); + rPr.RemoveAllChildren(); + rPr.AppendChild(new Drawing.LatinFont { Typeface = font }); + rPr.AppendChild(new Drawing.EastAsianFont { Typeface = font }); + } + + var bold = Get("bold"); + if (!string.IsNullOrEmpty(bold)) + rPr.Bold = ParseHelpers.IsTruthy(bold); + } + + /// + /// Apply text properties (font, size, color) to all axis labels. + /// Format: "size:color:fontname" e.g. "10:8B949E:Helvetica Neue" or "10:CCCCCC". + /// Used by the regular cChart path; delegates parsing to + /// . + /// + internal static void ApplyAxisTextProperties(OpenXmlCompositeElement axis, string value) + { + axis.RemoveAllChildren(); + var defRp = BuildDefaultRunPropertiesFromCompoundSpec(value); var tp = new C.TextProperties( new Drawing.BodyProperties(), @@ -718,6 +1008,40 @@ internal static void ApplyAxisTextProperties(OpenXmlCompositeElement axis, strin axis.AppendChild(tp); } + /// + /// R15-4: set tick-label rotation on a category/value/date axis. Reuses + /// the existing c:txPr subtree if any (preserves axisfont) and sets + /// a:bodyPr/@rot. Creates a minimal c:txPr otherwise. + /// + internal static void ApplyAxisLabelRotation(OpenXmlCompositeElement axis, string rotAttrVal) + { + var tp = axis.GetFirstChild(); + if (tp == null) + { + tp = new C.TextProperties( + new Drawing.BodyProperties { Rotation = int.Parse(rotAttrVal) }, + new Drawing.ListStyle(), + new Drawing.Paragraph(new Drawing.ParagraphProperties(new Drawing.EndParagraphRunProperties { Language = "en-US" })) + ); + var crossAxis = axis.GetFirstChild(); + if (crossAxis != null) + axis.InsertBefore(tp, crossAxis); + else + axis.AppendChild(tp); + return; + } + var bodyPr = tp.GetFirstChild(); + if (bodyPr == null) + { + bodyPr = new Drawing.BodyProperties { Rotation = int.Parse(rotAttrVal) }; + tp.PrependChild(bodyPr); + } + else + { + bodyPr.Rotation = int.Parse(rotAttrVal); + } + } + /// /// Build a color element supporting both hex RGB and scheme color names. /// @@ -849,6 +1173,34 @@ internal static C.Values BuildValues(double[] values) return new C.Values(numLit); } + /// + /// Rewrite the SeriesText (c:tx) on a series so its content is a + /// formula[...] referencing a + /// single cell, instead of a literal string. Used when users pass + /// series{N}.name=Sheet1!A1 — the legend/tooltip should resolve to the cell's + /// current value, not show "Sheet1!A1" as literal text. + /// + /// If cachedValue is non-null, a minimal c:strCache with one c:pt idx="0" is + /// attached so first-open viewers (before Excel recalculates) still see the + /// resolved text. When null, Excel fills the cache on open. + /// + internal static void RewriteSeriesTextAsRef( + OpenXmlCompositeElement series, string formula, string? cachedValue) + { + var serText = series.GetFirstChild(); + if (serText == null) return; + serText.RemoveAllChildren(); + var strRef = new C.StringReference(new C.Formula(formula)); + if (cachedValue != null) + { + var cache = new C.StringCache( + new C.PointCount { Val = 1U }, + new C.StringPoint(new C.NumericValue(cachedValue)) { Index = 0U }); + strRef.AppendChild(cache); + } + serText.AppendChild(strRef); + } + /// /// Build a Values element with a NumberReference (cell range formula, no cache). /// @@ -947,6 +1299,21 @@ internal static C.ValueAxis BuildValueAxis(uint axisId, uint crossAxisId, C.Axis internal static C.Title BuildChartTitle(string titleText) { + // CONSISTENCY(chart-cell-ref): if titleText looks like a single-cell + // reference (e.g. "Sheet1!A1"), emit so Excel resolves + // the cell on open. Same fix family as R17-B1 (series name strRef). + // Applies to chart title and cat/val axis titles (R18-B1/B2). + if (IsCellReference(titleText)) + { + var formula = NormalizeCellReference(titleText); + return new C.Title( + new C.ChartText( + new C.StringReference(new C.Formula(formula)) + ), + new C.Overlay { Val = false } + ); + } + return new C.Title( new C.ChartText( new C.RichText( diff --git a/src/officecli/Core/ChartReader.cs b/src/officecli/Core/Chart/ChartHelper.Reader.cs similarity index 85% rename from src/officecli/Core/ChartReader.cs rename to src/officecli/Core/Chart/ChartHelper.Reader.cs index 25a8e37d8..22d9514a2 100644 --- a/src/officecli/Core/ChartReader.cs +++ b/src/officecli/Core/Chart/ChartHelper.Reader.cs @@ -402,6 +402,10 @@ internal static void ReadChartProperties(C.Chart chart, DocumentNode node, int d if (valRef != null) seriesNode.Format["valuesRef"] = valRef; var catRef = ReadFormulaRef(serEl.GetFirstChild()); if (catRef != null) seriesNode.Format["categoriesRef"] = catRef; + var nameRefF = serEl.GetFirstChild() + ?.GetFirstChild() + ?.GetFirstChild()?.Text; + if (!string.IsNullOrEmpty(nameRefF)) seriesNode.Format["nameRef"] = nameRefF; } var serSpPr = serEl?.GetFirstChild(); @@ -499,10 +503,14 @@ internal static void ReadChartProperties(C.Chart chart, DocumentNode node, int d internal static string? DetectChartType(C.PlotArea plotArea) { + // Count real chart-type elements. A LineChart containing only reference-line-shaped + // series (flat values, no marker, dashed outline) is a ref-line overlay added by + // AddReferenceLine — it must not promote the underlying chart to a "combo". var chartTypeCount = plotArea.ChildElements - .Count(e => e is C.BarChart or C.LineChart or C.PieChart or C.AreaChart + .Count(e => (e is C.BarChart or C.LineChart or C.PieChart or C.AreaChart or C.Area3DChart or C.ScatterChart or C.DoughnutChart or C.Bar3DChart or C.Line3DChart or C.Pie3DChart - or C.BubbleChart or C.RadarChart or C.StockChart); + or C.BubbleChart or C.RadarChart or C.StockChart) + && !(e is C.LineChart lc && IsReferenceLineOnlyChart(lc))); if (chartTypeCount > 1) return "combo"; if (plotArea.GetFirstChild() is C.BarChart bar) @@ -530,6 +538,13 @@ or C.ScatterChart or C.DoughnutChart or C.Bar3DChart or C.Line3DChart or C.Pie3D if (areaGrp == "percentStacked") return "area_percentStacked"; return "area"; } + if (plotArea.GetFirstChild() is C.Area3DChart area3d) + { + var area3dGrp = area3d.GetFirstChild()?.Val?.InnerText; + if (area3dGrp == "stacked") return "area3d_stacked"; + if (area3dGrp == "percentStacked") return "area3d_percentStacked"; + return "area3d"; + } if (plotArea.GetFirstChild() != null) return "scatter"; if (plotArea.GetFirstChild() != null) return "bubble"; if (plotArea.GetFirstChild() != null) return "radar"; @@ -549,6 +564,89 @@ or C.ScatterChart or C.DoughnutChart or C.Bar3DChart or C.Line3DChart or C.Pie3D return null; } + /// + /// A reference-line series has (a) all values equal (flat horizontal line in OOXML terms), + /// (b) marker set to None, and (c) outline with a preset dash style. This matches the + /// shape that AddReferenceLine emits and is used to detect/remove overlays. + /// + internal static bool IsReferenceLineSeries(OpenXmlCompositeElement ser) + { + if (ser.LocalName != "ser") return false; + + var marker = ser.GetFirstChild(); + if (marker?.GetFirstChild()?.Val?.Value != C.MarkerStyleValues.None) return false; + + var spPr = ser.GetFirstChild(); + var outline = spPr?.GetFirstChild(); + if (outline?.GetFirstChild() == null) return false; + + // Flat values — every NumericPoint has the same text. Must have at least 1 literal point. + var numLit = ser.GetFirstChild()?.GetFirstChild(); + if (numLit == null) return false; + var distinct = numLit.Elements() + .Select(p => p.InnerText) + .Distinct() + .Take(2) + .Count(); + return distinct == 1; + } + + /// + /// True if a LineChart is made up entirely of reference-line series (i.e. it is a + /// ref-line overlay, not a real line chart). Empty LineCharts do not count. + /// + internal static bool IsReferenceLineOnlyChart(C.LineChart lineChart) + { + var sers = lineChart.Elements().ToList(); + if (sers.Count == 0) return false; + return sers.All(IsReferenceLineSeries); + } + + /// + /// Read all reference-line overlays from a plot area. Returns value, label, color, + /// line width in points, and dash style name. Colors come back as 6-digit hex without + /// the '#' prefix; dash name is the OOXML PresetLineDashValues InnerText (e.g. "sysDash"). + /// + internal static List<(string Name, double Value, string Color, double WidthPt, string Dash)> ReadReferenceLines(C.PlotArea plotArea) + { + var result = new List<(string, double, string, double, string)>(); + foreach (var lineChart in plotArea.Elements()) + { + foreach (var ser in lineChart.Elements()) + { + if (!IsReferenceLineSeries(ser)) continue; + + // Value: any NumericPoint (all equal by definition of ref-line series) + var numLit = ser.GetFirstChild()?.GetFirstChild(); + var pt = numLit?.Elements().FirstOrDefault(); + if (pt == null) continue; + if (!double.TryParse(pt.InnerText, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out var val)) + continue; + + var name = ser.GetFirstChild() + ?.Descendants().FirstOrDefault()?.Text ?? ""; + + var outline = ser.GetFirstChild()?.GetFirstChild(); + var widthEmu = outline?.Width?.Value ?? 19050; + var widthPt = widthEmu / 12700.0; + + // Color: solidFill srgbClr val + var color = "FF0000"; + var srgb = outline?.GetFirstChild()?.GetFirstChild()?.Val?.Value; + if (!string.IsNullOrEmpty(srgb)) color = srgb; + + var dashVal = outline?.GetFirstChild()?.Val; + var dash = dashVal?.InnerText ?? "dash"; + + result.Add((name, val, color, widthPt, dash)); + } + } + return result; + } + /// /// Detect waterfall chart pattern: a stacked bar chart with exactly 3 series /// where the first series is named "Base" and has NoFill (invisible base). @@ -630,7 +728,25 @@ internal static int CountSeries(C.PlotArea plotArea) (e.Parent.LocalName.Contains("Chart") || e.Parent.LocalName.Contains("chart")))) { var serText = ser.GetFirstChild(); - var name = serText?.Descendants().FirstOrDefault()?.Text ?? "?"; + // c:tx may carry (cached cell value) or (literal). + // Prefer the cached value from strRef, fall back to the formula, then + // literal , so users who set series{N}.name=Sheet1!A1 still get + // a meaningful name back from Get. + string name = "?"; + var strRef = serText?.GetFirstChild(); + if (strRef != null) + { + var cached = strRef.GetFirstChild() + ?.GetFirstChild() + ?.GetFirstChild()?.Text; + name = !string.IsNullOrEmpty(cached) + ? cached + : (strRef.GetFirstChild()?.Text ?? "?"); + } + else + { + name = serText?.Descendants().FirstOrDefault()?.Text ?? "?"; + } var values = ReadNumericData(ser.GetFirstChild()) ?? ReadNumericData(ser.Elements() @@ -643,6 +759,24 @@ internal static int CountSeries(C.PlotArea plotArea) return result; } + /// + /// Enumerate ser elements in the same order ReadAllSeries visits them, returning + /// `true` for each series that is a reference-line overlay. The caller can zip + /// this with the ReadAllSeries output to filter out ref-line entries without + /// re-walking the OOXML tree. + /// + internal static List ReadReferenceLineMask(C.PlotArea plotArea) + { + var result = new List(); + foreach (var ser in plotArea.Descendants() + .Where(e => e.LocalName == "ser" && e.Parent != null && + (e.Parent.LocalName.Contains("Chart") || e.Parent.LocalName.Contains("chart")))) + { + result.Add(IsReferenceLineSeries(ser)); + } + return result; + } + internal static double[]? ReadNumericData(OpenXmlCompositeElement? valElement) { if (valElement == null) return null; diff --git a/src/officecli/Core/ChartSetter.cs b/src/officecli/Core/Chart/ChartHelper.Setter.cs similarity index 82% rename from src/officecli/Core/ChartSetter.cs rename to src/officecli/Core/Chart/ChartHelper.Setter.cs index b42baa407..892c69d2a 100644 --- a/src/officecli/Core/ChartSetter.cs +++ b/src/officecli/Core/Chart/ChartHelper.Setter.cs @@ -155,6 +155,7 @@ static int PropOrder(string k) "top" or "t" => C.LegendPositionValues.Top, "left" or "l" => C.LegendPositionValues.Left, "right" or "r" => C.LegendPositionValues.Right, + "topright" or "tr" or "top-right" => C.LegendPositionValues.TopRight, _ => C.LegendPositionValues.Bottom }; var plotVisOnly = chart.GetFirstChild(); @@ -178,11 +179,36 @@ static int PropOrder(string k) { var dl = new C.DataLabels(); var parts = value.ToLowerInvariant().Split(',').Select(s => s.Trim()).ToHashSet(); + // Position values (outsideEnd, center, insideEnd, insideBase, top, bottom, left, right) + // implicitly enable showVal when used as the dataLabels value + var positionValues = new HashSet { "outsideend", "center", "insideend", "insidebase", + "top", "bottom", "left", "right", "bestfit", "t", "b", "l", "r", "outend", "ctr" }; + var isPositionValue = parts.Any(p => positionValues.Contains(p)); + var showVal = parts.Contains("value") || parts.Contains("true") || parts.Contains("all") || isPositionValue; dl.AppendChild(new C.ShowLegendKey { Val = false }); - dl.AppendChild(new C.ShowValue { Val = parts.Contains("value") || parts.Contains("true") || parts.Contains("all") }); + dl.AppendChild(new C.ShowValue { Val = showVal }); dl.AppendChild(new C.ShowCategoryName { Val = parts.Contains("category") || parts.Contains("all") }); dl.AppendChild(new C.ShowSeriesName { Val = parts.Contains("series") || parts.Contains("all") }); dl.AppendChild(new C.ShowPercent { Val = parts.Contains("percent") || parts.Contains("all") }); + // If a position value was given, apply it as dLblPos + if (isPositionValue) + { + var posVal = parts.First(p => positionValues.Contains(p)); + var dLblPos = posVal switch + { + "outsideend" or "outend" => C.DataLabelPositionValues.OutsideEnd, + "insideend" => C.DataLabelPositionValues.InsideEnd, + "insidebase" => C.DataLabelPositionValues.InsideBase, + "center" or "ctr" => C.DataLabelPositionValues.Center, + "top" or "t" => C.DataLabelPositionValues.Top, + "bottom" or "b" => C.DataLabelPositionValues.Bottom, + "left" or "l" => C.DataLabelPositionValues.Left, + "right" or "r" => C.DataLabelPositionValues.Right, + "bestfit" => C.DataLabelPositionValues.BestFit, + _ => C.DataLabelPositionValues.OutsideEnd + }; + dl.AppendChild(new C.DataLabelPosition { Val = dLblPos }); + } // Insert dLbls before gapWidth/overlap/showMarker/holeSize/axId per schema order var dlInsertBefore = chartTypeEl.GetFirstChild() as OpenXmlElement ?? chartTypeEl.GetFirstChild() as OpenXmlElement @@ -204,8 +230,21 @@ static int PropOrder(string k) var plotArea2 = chart.GetFirstChild(); if (plotArea2 == null) { unsupported.Add(key); break; } - // Doughnut does NOT support dLblPos at all — skip entirely - if (plotArea2.GetFirstChild() != null) break; + // dLblPos is NOT supported by doughnut, area, radar, or stock charts — skip entirely + if (plotArea2.GetFirstChild() != null + || plotArea2.GetFirstChild() != null + || plotArea2.GetFirstChild() != null + || plotArea2.GetFirstChild() != null + || plotArea2.GetFirstChild() != null) break; + + // Combo charts (bar+line in same plot area) have incompatible dLblPos + // value sets — bar supports inEnd/inBase/outEnd but not t/b/l/r, while + // line supports t/b/l/r but not inEnd/inBase/outEnd. Only 'ctr' is + // universally valid. Skip entirely for combo charts. + var chartGroupCount = plotArea2.ChildElements.Count( + e => e is C.BarChart or C.Bar3DChart or C.LineChart or C.Line3DChart + or C.ScatterChart or C.BubbleChart); + if (chartGroupCount > 1) break; // Pie only supports: bestFit, center, insideEnd, insideBase var isPie = plotArea2.GetFirstChild() != null @@ -271,11 +310,86 @@ static int PropOrder(string k) break; } + // R15-4: tick-label rotation. Degrees (-90..90). Emits a + // with on the target + // axis so Excel rotates the tick labels on open. + case "labelrotation": + case "xaxis.labelrotation": + case "xaxislabelrotation": + case "valaxis.labelrotation": + case "valaxislabelrotation": + case "yaxis.labelrotation": + case "yaxislabelrotation": + { + var plotArea2 = chart.GetFirstChild(); + if (plotArea2 == null) { unsupported.Add(key); break; } + if (!double.TryParse(value, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var deg)) + { unsupported.Add(key); break; } + var rotAttrVal = ((int)(deg * 60000)).ToString(System.Globalization.CultureInfo.InvariantCulture); + var lowerKey = key.ToLowerInvariant(); + var targetCat = lowerKey is "labelrotation" or "xaxis.labelrotation" or "xaxislabelrotation"; + var targetVal = lowerKey is "labelrotation" or "valaxis.labelrotation" or "valaxislabelrotation" + or "yaxis.labelrotation" or "yaxislabelrotation"; + if (targetCat) + { + foreach (var axis in plotArea2.Elements()) + ApplyAxisLabelRotation(axis, rotAttrVal); + foreach (var axis in plotArea2.Elements()) + ApplyAxisLabelRotation(axis, rotAttrVal); + } + if (targetVal) + { + foreach (var axis in plotArea2.Elements()) + ApplyAxisLabelRotation(axis, rotAttrVal); + } + break; + } + case "colors": { 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++) @@ -537,7 +651,11 @@ static int PropOrder(string k) } // ---- #6 Gradient fill ---- - case "gradient": + // CONSISTENCY(gradient-fill-alias): accept `gradientFill=` as an + // alias for `gradient=` so chart vocabulary matches shape/textbox + // (ExcelHandler.Add.cs line 1931 / Set.cs line 727 use + // BuildShapeGradientFill keyed on `gradientFill`). + case "gradient" or "gradientfill": { var plotArea2 = chart.GetFirstChild(); if (plotArea2 == null) { unsupported.Add(key); break; } @@ -900,15 +1018,32 @@ static int PropOrder(string k) break; } - case "logbase" or "logscale": + case "logbase" or "logscale" or "yaxisscale": { var plotArea2 = chart.GetFirstChild(); var valAx = plotArea2?.GetFirstChild(); var scaling = valAx?.GetFirstChild(); if (scaling == null) { unsupported.Add(key); break; } scaling.RemoveAllChildren(); - if (!value.Equals("none", StringComparison.OrdinalIgnoreCase) && - !value.Equals("false", StringComparison.OrdinalIgnoreCase)) + // DEFERRED(xlsx/chart-logscale) CL23: accept `logScale=true` + // as shorthand for logBase=10 (Excel's default log base). + // `false`/`none` removes the log scale. `logBase=` still + // accepts an explicit numeric base via the same key. + // R19-2: also accept `yAxisScale=log` / `yAxisScale=linear` + // as a verb-style alias. `log` == shorthand for logBase=10, + // `linear`/`none` removes the log scale. + if (value.Equals("true", StringComparison.OrdinalIgnoreCase) || + value.Equals("yes", StringComparison.OrdinalIgnoreCase) || + value.Equals("log", StringComparison.OrdinalIgnoreCase) || + value == "1") + { + scaling.PrependChild(new C.LogBase { Val = 10d }); + } + else if (!value.Equals("none", StringComparison.OrdinalIgnoreCase) && + !value.Equals("linear", StringComparison.OrdinalIgnoreCase) && + !value.Equals("false", StringComparison.OrdinalIgnoreCase) && + !value.Equals("no", StringComparison.OrdinalIgnoreCase) && + value != "0") { var logVal = ParseHelpers.SafeParseDouble(value, "logBase"); scaling.PrependChild(new C.LogBase { Val = logVal }); @@ -995,6 +1130,16 @@ static int PropOrder(string k) var showVal = ParseHelpers.IsTruthy(value); foreach (var lc in plotArea2.Elements()) { lc.RemoveAllChildren(); InsertLineChartChildInOrder(lc, new C.ShowMarker { Val = showVal }); } + // For scatter charts, set per-series marker symbol to none when hiding markers + if (!showVal) + { + foreach (var ser in plotArea2.Descendants() + .Where(e => e.LocalName == "ser" && e.Parent is C.ScatterChart)) + { + ser.RemoveAllChildren(); + InsertSeriesChildInOrder(ser, new C.Marker(new C.Symbol { Val = C.MarkerStyleValues.None })); + } + } break; } @@ -1118,8 +1263,81 @@ static int PropOrder(string k) foreach (var ser in plotArea2.Descendants().Where(e => e.LocalName == "ser")) { ser.RemoveAllChildren(); - if (!value.Equals("none", StringComparison.OrdinalIgnoreCase)) - ser.AppendChild(BuildErrorBars(value)); + if (!value.Equals("none", StringComparison.OrdinalIgnoreCase) + && SeriesSupportsErrorBars(ser)) + InsertSeriesChildInOrder(ser, BuildErrorBars(value)); + } + break; + } + + // CL23 — errBars.direction / errBarDirection controls . + // Applied to any existing errBars on all series. If none exist yet, silently no-op + // (consistency with other per-series options that require the parent prop to be set first). + case "errbars.direction" or "errbardirection": + { + var plotArea2 = chart.GetFirstChild(); + if (plotArea2 == null) { unsupported.Add(key); break; } + var dirVal = value.Trim().ToLowerInvariant() switch + { + "plus" => C.ErrorBarValues.Plus, + "minus" => C.ErrorBarValues.Minus, + "both" or "" => C.ErrorBarValues.Both, + _ => throw new ArgumentException( + $"Invalid errBarDirection '{value}'. Use: plus, minus, both.") + }; + foreach (var ser in plotArea2.Descendants().Where(e => e.LocalName == "ser")) + { + foreach (var eb in ser.Elements()) + { + eb.RemoveAllChildren(); + // Schema order in CT_ErrBars: errDir, errBarType, errValType, noEndCap, plus, minus, val, spPr + var dir = eb.GetFirstChild(); + var newType = new C.ErrorBarType { Val = dirVal }; + if (dir != null) dir.InsertAfterSelf(newType); + else eb.PrependChild(newType); + } + } + break; + } + + // CL23 — chart-level trendline.* fan-out. Applies the sub-property to every + // series' existing trendline. Use `series{N}.trendline.{prop}` for per-series. + case "trendline.label" or "trendline.forecastforward" or "trendline.forecastbackward" + or "trendline.order" or "trendline.period" + or "trendline.intercept" or "trendline.displayequation" or "trendline.displayrsquared": + { + var plotArea2 = chart.GetFirstChild(); + if (plotArea2 == null) { unsupported.Add(key); break; } + var subKey = key.ToLowerInvariant()["trendline.".Length..] switch + { + "label" => "name", + "forecastforward" => "forward", + "forecastbackward" => "backward", + "order" => "order", + "period" => "period", + "intercept" => "intercept", + "displayequation" => "dispeq", + "displayrsquared" => "disprsqr", + var s => s + }; + foreach (var ser in plotArea2.Descendants().Where(e => e.LocalName == "ser")) + { + foreach (var tl in ser.Elements()) + ApplyTrendlineOptions(tl, subKey, value); + } + break; + } + + // CL15 — showLeaderLines on pie/doughnut. Alias of datalabels.showleaderlines. + case "showleaderlines": + { + var plotArea2 = chart.GetFirstChild(); + if (plotArea2 == null) { unsupported.Add(key); break; } + var show = ParseHelpers.IsTruthy(value); + foreach (var dl in plotArea2.Descendants()) + { + dl.RemoveAllChildren(); + dl.AppendChild(new C.ShowLeaderLines { Val = show }); } break; } @@ -1176,6 +1394,74 @@ static int PropOrder(string k) break; } + // CleanupE1 — dotted subkeys for toggling individual show* flags on existing + // dataLabels. Useful for pie charts where `datalabels.showpercent=true` should + // emit `` rather than raw values. + case "datalabels.showvalue" or "datalabels.showval": + { + var plotArea2 = chart.GetFirstChild(); + if (plotArea2 == null) { unsupported.Add(key); break; } + var show = ParseHelpers.IsTruthy(value); + foreach (var dl in plotArea2.Descendants()) + { + dl.RemoveAllChildren(); + dl.AppendChild(new C.ShowValue { Val = show }); + } + break; + } + + case "datalabels.showpercent" or "datalabels.showpct": + { + var plotArea2 = chart.GetFirstChild(); + if (plotArea2 == null) { unsupported.Add(key); break; } + var show = ParseHelpers.IsTruthy(value); + foreach (var dl in plotArea2.Descendants()) + { + dl.RemoveAllChildren(); + dl.AppendChild(new C.ShowPercent { Val = show }); + } + break; + } + + case "datalabels.showcatname" or "datalabels.showcategoryname" or "datalabels.showcategory": + { + var plotArea2 = chart.GetFirstChild(); + if (plotArea2 == null) { unsupported.Add(key); break; } + var show = ParseHelpers.IsTruthy(value); + foreach (var dl in plotArea2.Descendants()) + { + dl.RemoveAllChildren(); + dl.AppendChild(new C.ShowCategoryName { Val = show }); + } + break; + } + + case "datalabels.showsername" or "datalabels.showseriesname" or "datalabels.showseries": + { + var plotArea2 = chart.GetFirstChild(); + if (plotArea2 == null) { unsupported.Add(key); break; } + var show = ParseHelpers.IsTruthy(value); + foreach (var dl in plotArea2.Descendants()) + { + dl.RemoveAllChildren(); + dl.AppendChild(new C.ShowSeriesName { Val = show }); + } + break; + } + + case "datalabels.showlegendkey": + { + var plotArea2 = chart.GetFirstChild(); + if (plotArea2 == null) { unsupported.Add(key); break; } + var show = ParseHelpers.IsTruthy(value); + foreach (var dl in plotArea2.Descendants()) + { + dl.RemoveAllChildren(); + dl.AppendChild(new C.ShowLegendKey { Val = show }); + } + break; + } + // ==================== Border / Outline ==================== case "plotarea.border" or "plotborder": @@ -1337,10 +1623,12 @@ static int PropOrder(string k) case "gapdepth": { var plotArea2 = chart.GetFirstChild(); - var bar3d = plotArea2?.GetFirstChild(); - if (bar3d == null) { unsupported.Add(key); break; } - bar3d.RemoveAllChildren(); - bar3d.AppendChild(new C.GapDepth { Val = (ushort)ParseHelpers.SafeParseInt(value, "gapDepth") }); + var target3d = plotArea2?.GetFirstChild() as OpenXmlCompositeElement + ?? plotArea2?.GetFirstChild() as OpenXmlCompositeElement + ?? plotArea2?.GetFirstChild() as OpenXmlCompositeElement; + if (target3d == null) { unsupported.Add(key); break; } + target3d.RemoveAllChildren(); + target3d.AppendChild(new C.GapDepth { Val = (ushort)ParseHelpers.SafeParseInt(value, "gapDepth") }); break; } @@ -1376,7 +1664,7 @@ static int PropOrder(string k) var dl = new C.DropLines(); if (!value.Equals("true", StringComparison.OrdinalIgnoreCase)) dl.AppendChild(BuildLineShapeProperties(value)); - lc.AppendChild(dl); + InsertLineChartChildInOrder(lc, dl); } break; } @@ -1392,7 +1680,7 @@ static int PropOrder(string k) var hl = new C.HighLowLines(); if (!value.Equals("true", StringComparison.OrdinalIgnoreCase)) hl.AppendChild(BuildLineShapeProperties(value)); - lc.AppendChild(hl); + InsertLineChartChildInOrder(lc, hl); } break; } @@ -1403,13 +1691,42 @@ static int PropOrder(string k) var lc = plotArea2?.GetFirstChild(); if (lc == null) { unsupported.Add(key); break; } lc.RemoveAllChildren(); - if (ParseHelpers.IsTruthy(value)) + if (value.Equals("none", StringComparison.OrdinalIgnoreCase) + || value.Equals("false", StringComparison.OrdinalIgnoreCase)) break; + if (value.Contains(':') || (ParseHelpers.IsValidBooleanString(value) && ParseHelpers.IsTruthy(value))) { var udb = new C.UpDownBars(); - udb.AppendChild(new C.GapWidth { Val = 150 }); - udb.AppendChild(new C.UpBars()); - udb.AppendChild(new C.DownBars()); - lc.AppendChild(udb); + ushort gapWidth = 150; + string? upColor = null, downColor = null; + if (value.Contains(':')) + { + var udbParts = value.Split(':'); + if (udbParts.Length >= 1 && ushort.TryParse(udbParts[0], out var gw)) gapWidth = gw; + if (udbParts.Length >= 2 && !string.IsNullOrEmpty(udbParts[1])) upColor = udbParts[1]; + if (udbParts.Length >= 3 && !string.IsNullOrEmpty(udbParts[2])) downColor = udbParts[2]; + } + udb.AppendChild(new C.GapWidth { Val = gapWidth }); + var upBars = new C.UpBars(); + if (upColor != null) + { + var upSpPr = new C.ChartShapeProperties(); + var upFill = new Drawing.SolidFill(); + upFill.AppendChild(BuildChartColorElement(upColor)); + upSpPr.AppendChild(upFill); + upBars.AppendChild(upSpPr); + } + udb.AppendChild(upBars); + var downBars = new C.DownBars(); + if (downColor != null) + { + var downSpPr = new C.ChartShapeProperties(); + var downFill = new Drawing.SolidFill(); + downFill.AppendChild(BuildChartColorElement(downColor)); + downSpPr.AppendChild(downFill); + downBars.AppendChild(downSpPr); + } + udb.AppendChild(downBars); + InsertLineChartChildInOrder(lc, udb); } break; } @@ -1523,7 +1840,7 @@ static int PropOrder(string k) dLbls.AppendChild(new C.ShowCategoryName { Val = false }); dLbls.AppendChild(new C.ShowSeriesName { Val = false }); dLbls.AppendChild(new C.ShowPercent { Val = false }); - firstSer.AppendChild(dLbls); + InsertSeriesChildInOrder(firstSer, dLbls); } // Find or create individual dLbl for the point index (0-based in OOXML) var ooxmlIdx = (uint)(dlPointIdx - 1); @@ -1781,6 +2098,7 @@ internal static void ApplySeriesMarker(OpenXmlCompositeElement series, string ma "dash" => C.MarkerStyleValues.Dash, "dot" => C.MarkerStyleValues.Dot, "none" => C.MarkerStyleValues.None, + "auto" => C.MarkerStyleValues.Auto, _ => C.MarkerStyleValues.Circle }; @@ -1922,9 +2240,27 @@ internal static void ApplySecondaryAxis(C.PlotArea plotArea, HashSet second var sourceChartType = seriesToMove[0].Parent; if (sourceChartType == null) return; + // Reject 3D source charts. Excel itself greys out the secondary-axis + // option on 3D charts because a 3D plotArea has one shared camera / + // perspective and cannot host a sibling 2D chart element. Previously + // the code below would match `bar3DChart` / `line3DChart` / + // `area3DChart` against the StartsWith("bar"/"line"/"area") branches + // and create a 2D sibling chart, which produced a plotArea mixing + // 3D + 2D chart types and made Excel crash on open. Match Excel UI: + // refuse the operation with a clear error. + var sourceLocalName = sourceChartType.LocalName; + if (sourceLocalName.Contains("3D", StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Invalid secondaryaxis: source chart is 3D ({sourceLocalName}). " + + "Excel does not support a secondary axis on 3D charts because a 3D " + + "plot area cannot coexist with a second chart type. Convert to the 2D " + + "variant first (e.g. column3d -> column) before applying secondaryaxis."); + } + // Create a new chart element of the same type for secondary axis OpenXmlCompositeElement secondaryChart; - var localName = sourceChartType.LocalName; + var localName = sourceLocalName; if (localName.StartsWith("line", StringComparison.OrdinalIgnoreCase)) { secondaryChart = new C.LineChart( @@ -2000,6 +2336,16 @@ internal static void ApplySecondaryAxis(C.PlotArea plotArea, HashSet second var secValAxis = BuildValueAxis(secondaryValAxisId, secondaryCatAxisId, C.AxisPositionValues.Right); secValAxis.RemoveAllChildren(); // secondary axis typically has no gridlines + // Bind secondary Y axis to the right edge by crossing the (hidden) secondary + // category axis at its maximum. Without this, Excel ignores axPos="r" and + // renders both Y axes on the left edge — BuildValueAxis defaults crosses to + // autoZero, which is correct for the primary axis but wrong here. + foreach (var c in secValAxis.Elements().ToList()) c.Remove(); + foreach (var c in secValAxis.Elements().ToList()) c.Remove(); + // Schema order: crosses comes after crossAx; append is safe as BuildValueAxis + // ends with Crosses and we already stripped the autoZero Crosses above. + secValAxis.AppendChild(new C.Crosses { Val = C.CrossesValues.Maximum }); + // Insert after the last existing axis to maintain schema order var lastAxis = plotArea.Elements().LastOrDefault() as OpenXmlElement ?? plotArea.Elements().LastOrDefault() as OpenXmlElement; diff --git a/src/officecli/Core/ChartSetterHelpers.cs b/src/officecli/Core/Chart/ChartHelper.SetterHelpers.cs similarity index 74% rename from src/officecli/Core/ChartSetterHelpers.cs rename to src/officecli/Core/Chart/ChartHelper.SetterHelpers.cs index fe38e0211..458965f31 100644 --- a/src/officecli/Core/ChartSetterHelpers.cs +++ b/src/officecli/Core/Chart/ChartHelper.SetterHelpers.cs @@ -79,27 +79,55 @@ internal static void ApplyTrendlineOptions(C.Trendline trendline, string optionK { switch (optionKey) { - case "name": + case "name" or "label": trendline.RemoveAllChildren(); trendline.PrependChild(new C.TrendlineName { Text = value }); + // Also emit a with rich-text so Excel actually + // paints the label next to the trendline (a alone is + // used by older tooling as a legend-entry override). + trendline.RemoveAllChildren(); + var tlLbl = new C.TrendlineLabel( + new C.Layout(), + new C.ChartText( + new C.RichText( + new Drawing.BodyProperties(), + new Drawing.ListStyle(), + new Drawing.Paragraph( + new Drawing.Run( + new Drawing.RunProperties { Language = "en-US" }, + new Drawing.Text(value)))))); + // Schema order under CT_Trendline: name, trendlineLbl, trendlineType, ... + var trendlineType = trendline.GetFirstChild(); + if (trendlineType != null) + trendline.InsertBefore(tlLbl, trendlineType); + else + trendline.AppendChild(tlLbl); break; - case "forward": + case "forward" or "forecastforward": trendline.RemoveAllChildren(); trendline.AppendChild(new C.Forward { Val = ParseHelpers.SafeParseDouble(value, "trendline.forward") }); break; - case "backward": + case "backward" or "forecastbackward": trendline.RemoveAllChildren(); trendline.AppendChild(new C.Backward { Val = ParseHelpers.SafeParseDouble(value, "trendline.backward") }); break; + case "order": + trendline.RemoveAllChildren(); + trendline.AppendChild(new C.PolynomialOrder { Val = (byte)Math.Clamp(ParseHelpers.SafeParseInt(value, "trendline.order"), 2, 6) }); + break; + case "period": + trendline.RemoveAllChildren(); + trendline.AppendChild(new C.Period { Val = (uint)Math.Max(2, ParseHelpers.SafeParseInt(value, "trendline.period")) }); + break; case "intercept": trendline.RemoveAllChildren(); trendline.AppendChild(new C.Intercept { Val = ParseHelpers.SafeParseDouble(value, "trendline.intercept") }); break; - case "disprsqr" or "rsquared" or "r2": + case "disprsqr" or "rsquared" or "r2" or "displayrsquared": trendline.RemoveAllChildren(); trendline.AppendChild(new C.DisplayRSquaredValue { Val = ParseHelpers.IsTruthy(value) }); break; - case "dispeq" or "equation": + case "dispeq" or "equation" or "displayequation": trendline.RemoveAllChildren(); trendline.AppendChild(new C.DisplayEquation { Val = ParseHelpers.IsTruthy(value) }); break; @@ -108,6 +136,21 @@ internal static void ApplyTrendlineOptions(C.Trendline trendline, string optionK // ==================== Error Bars Helpers ==================== + /// + /// Check if the parent chart type supports errBars on its series (CT_*Ser). + /// OOXML allows errBars in: barChart, bar3DChart, scatterChart, areaChart, + /// area3DChart, bubbleChart. Not allowed in: lineChart, line3DChart, + /// pieChart, pie3DChart, doughnutChart, radarChart, stockChart. + /// + internal static bool SeriesSupportsErrorBars(OpenXmlElement ser) + { + var parentName = ser.Parent?.LocalName ?? ""; + return parentName is "barChart" or "bar3DChart" + or "scatterChart" + or "areaChart" or "area3DChart" + or "bubbleChart"; + } + internal static C.ErrorBars BuildErrorBars(string spec) { // Format: "type" or "type:value" e.g. "fixed:5", "percent:10", "stddev", "stderr" @@ -287,9 +330,31 @@ internal static void HandleSeriesDottedProperty(OpenXmlCompositeElement ser, str break; case "trendline": - ser.RemoveAllChildren(); - if (!value.Equals("none", StringComparison.OrdinalIgnoreCase)) - InsertSeriesChildInOrder(ser, BuildTrendline(value)); + // CL20: `Set trendline=X` APPENDS a trendline (Excel allows + // multiple trendlines per series). Pass `none` to clear. + // If the requested trendline type already exists on the + // series, replace it in place so repeated identical sets + // stay idempotent; otherwise append a new one. + if (value.Equals("none", StringComparison.OrdinalIgnoreCase)) + { + ser.RemoveAllChildren(); + } + else + { + var newTl = BuildTrendline(value); + var newType = newTl.GetFirstChild()?.Val?.Value; + var dupeTl = ser.Elements() + .FirstOrDefault(t => t.GetFirstChild()?.Val?.Value == newType); + if (dupeTl != null) + { + dupeTl.InsertAfterSelf(newTl); + dupeTl.Remove(); + } + else + { + InsertSeriesChildInOrder(ser, newTl); + } + } break; case "marker": @@ -314,8 +379,18 @@ internal static void HandleSeriesDottedProperty(OpenXmlCompositeElement ser, str var serText = ser.GetFirstChild(); if (serText != null) { - serText.RemoveAllChildren(); - serText.AppendChild(new C.NumericValue(value)); + // If the value looks like a cell reference, rewrite c:tx as a + // c:strRef so Excel resolves it to the cell's value (matches + // Add-path behavior for series{N}.name=Sheet1!A1). + if (IsCellReference(value)) + { + RewriteSeriesTextAsRef(ser, NormalizeCellReference(value), cachedValue: null); + } + else + { + serText.RemoveAllChildren(); + serText.AppendChild(new C.NumericValue(value)); + } } break; } @@ -353,8 +428,9 @@ internal static void HandleSeriesDottedProperty(OpenXmlCompositeElement ser, str case "errbars" or "errorbars": ser.RemoveAllChildren(); - if (!value.Equals("none", StringComparison.OrdinalIgnoreCase)) - ser.AppendChild(BuildErrorBars(value)); + if (!value.Equals("none", StringComparison.OrdinalIgnoreCase) + && SeriesSupportsErrorBars(ser)) + InsertSeriesChildInOrder(ser, BuildErrorBars(value)); break; case "explosion" or "explode": @@ -399,7 +475,7 @@ internal static void HandleSeriesDottedProperty(OpenXmlCompositeElement ser, str break; } - case "gradient": + case "gradient" or "gradientfill": ApplySeriesGradient(ser, value); break; @@ -474,8 +550,8 @@ internal static bool TryParseDataLabelDottedKey(string key, out int pointIndex, internal static void HandleDataLabelDottedProperty(OpenXmlCompositeElement firstSer, int pointIndex, string prop, string value) { var dLbls = firstSer.GetFirstChild(); - // For "text", auto-create a minimal DataLabels container on the series if not present - if (dLbls == null && prop == "text") + // Auto-create a minimal DataLabels container if not present and we're about to add per-point data. + if (dLbls == null && (prop == "text" || prop == "delete")) { dLbls = new C.DataLabels(); dLbls.AppendChild(new C.ShowLegendKey { Val = false }); @@ -483,42 +559,56 @@ internal static void HandleDataLabelDottedProperty(OpenXmlCompositeElement first dLbls.AppendChild(new C.ShowCategoryName { Val = false }); dLbls.AppendChild(new C.ShowSeriesName { Val = false }); dLbls.AppendChild(new C.ShowPercent { Val = false }); - // Insert before AxId or at end of series, per schema order - var insertBefore = firstSer.GetFirstChild() as OpenXmlElement; - if (insertBefore != null) firstSer.InsertBefore(dLbls, insertBefore); - else firstSer.AppendChild(dLbls); + InsertSeriesChildInOrder(firstSer, dLbls); } if (dLbls == null) return; var ooxmlIdx = (uint)(pointIndex - 1); + // Coalesce by idx: schema requires at most one per series. + // Find-or-create once, then merge subsequent settings into the same element. var dLbl = dLbls.Elements() .FirstOrDefault(dl => dl.Index?.Val?.Value == ooxmlIdx); + if (dLbl == null && (prop == "text" || prop == "delete")) + { + dLbl = new C.DataLabel(); + dLbl.AppendChild(new C.Index { Val = ooxmlIdx }); + var insertBefore = dLbls.GetFirstChild() as OpenXmlElement + ?? dLbls.GetFirstChild() + ?? dLbls.FirstChild; + if (insertBefore != null) dLbls.InsertBefore(dLbl, insertBefore); + else dLbls.AppendChild(dLbl); + } switch (prop) { case "delete": { - if (dLbl == null) - { - dLbl = new C.DataLabel(); - dLbl.Index = new C.Index { Val = ooxmlIdx }; - dLbl.AppendChild(new C.Delete { Val = ParseHelpers.IsTruthy(value) }); - var insertBefore = dLbls.GetFirstChild() as OpenXmlElement - ?? dLbls.GetFirstChild() - ?? dLbls.FirstChild; - if (insertBefore != null) dLbls.InsertBefore(dLbl, insertBefore); - else dLbls.AppendChild(dLbl); - } - else + if (dLbl == null) return; + var del = ParseHelpers.IsTruthy(value); + dLbl.RemoveAllChildren(); + dLbl.AppendChild(new C.Delete { Val = del }); + // "delete wins" semantics: a deleted label renders nothing, so strip + // any previously-set visible siblings (tx, numFmt, dLblPos, show*). + if (del) { - dLbl.RemoveAllChildren(); - dLbl.AppendChild(new C.Delete { Val = ParseHelpers.IsTruthy(value) }); + dLbl.RemoveAllChildren(); + dLbl.RemoveAllChildren(); + dLbl.RemoveAllChildren(); + dLbl.RemoveAllChildren(); + dLbl.RemoveAllChildren(); + dLbl.RemoveAllChildren(); + dLbl.RemoveAllChildren(); + dLbl.RemoveAllChildren(); + dLbl.RemoveAllChildren(); + dLbl.RemoveAllChildren(); } break; } case "pos" or "position": { if (dLbl == null) return; + // Skip if this dLbl is already marked deleted — delete wins. + if (dLbl.GetFirstChild() is { Val.Value: true }) return; dLbl.RemoveAllChildren(); var dlPos = value.ToLowerInvariant() switch { @@ -534,23 +624,16 @@ internal static void HandleDataLabelDottedProperty(OpenXmlCompositeElement first case "numfmt": { if (dLbl == null) return; + if (dLbl.GetFirstChild() is { Val.Value: true }) return; dLbl.RemoveAllChildren(); dLbl.AppendChild(new C.NumberingFormat { FormatCode = value, SourceLinked = false }); break; } case "text": { - // Create or find dLbl, then set custom text via c:tx > c:rich - if (dLbl == null) - { - dLbl = new C.DataLabel(); - dLbl.Index = new C.Index { Val = ooxmlIdx }; - var insertBefore = dLbls.GetFirstChild() as OpenXmlElement - ?? dLbls.GetFirstChild() - ?? dLbls.FirstChild; - if (insertBefore != null) dLbls.InsertBefore(dLbl, insertBefore); - else dLbls.AppendChild(dLbl); - } + if (dLbl == null) return; + // Delete wins: if this dLbl is already deleted, ignore a later text= set. + if (dLbl.GetFirstChild() is { Val.Value: true }) return; dLbl.RemoveAllChildren(); var richText = new C.ChartText(); var rich = new C.RichText( @@ -561,14 +644,7 @@ internal static void HandleDataLabelDottedProperty(OpenXmlCompositeElement first new Drawing.RunProperties { Language = "en-US" }, new Drawing.Text(value)))); richText.AppendChild(rich); - // Insert tx after idx (schema order: idx, delete, layout, tx, ...) - var afterIdx = dLbl.GetFirstChild() as OpenXmlElement; - var afterLayout = dLbl.GetFirstChild() as OpenXmlElement; - var insertAfter = afterLayout ?? afterIdx; - if (insertAfter != null) - insertAfter.InsertAfterSelf(richText); - else - dLbl.PrependChild(richText); + dLbl.AppendChild(richText); // Ensure show flags are present so the custom text renders if (dLbl.GetFirstChild() == null) dLbl.AppendChild(new C.ShowValue { Val = true }); @@ -579,6 +655,48 @@ internal static void HandleDataLabelDottedProperty(OpenXmlCompositeElement first break; } } + + // Final pass: enforce CT_DLbl schema order. Excel rejects the file silently + // if children are out of order (Sch_UnexpectedElementContentExpectingComplex). + // Order: idx, delete, layout, tx, numFmt, spPr, txPr, dLblPos, + // showLegendKey, showVal, showCatName, showSerName, showPercent, + // showBubbleSize, separator, extLst. + if (dLbl != null) ReorderDLblChildren(dLbl); + } + + private static readonly Type[] s_dLblChildOrder = + { + typeof(C.Index), + typeof(C.Delete), + typeof(C.Layout), + typeof(C.ChartText), + typeof(C.NumberingFormat), + typeof(C.ChartShapeProperties), + typeof(C.TextProperties), + typeof(C.DataLabelPosition), + typeof(C.ShowLegendKey), + typeof(C.ShowValue), + typeof(C.ShowCategoryName), + typeof(C.ShowSeriesName), + typeof(C.ShowPercent), + typeof(C.ShowBubbleSize), + typeof(C.Separator), + typeof(C.ExtensionList), + }; + + private static void ReorderDLblChildren(C.DataLabel dLbl) + { + var kept = new List(); + foreach (var t in s_dLblChildOrder) + { + foreach (var child in dLbl.ChildElements.Where(c => c.GetType() == t).ToList()) + { + child.Remove(); + kept.Add(child); + } + } + // Re-append in schema order. Any unknown children (shouldn't happen) are dropped. + foreach (var c in kept) dLbl.AppendChild(c); } /// @@ -639,17 +757,23 @@ internal static void InsertAxisChildInOrder(OpenXmlCompositeElement axis, OpenXm /// internal static void InsertLineChartChildInOrder(C.LineChart lc, OpenXmlElement child) { - // smooth must come before axId elements - if (child.LocalName is "smooth" or "marker") + // CT_LineChart schema order: grouping, varyColors, ser*, dLbls?, + // dropLines?, hiLowLines?, upDownBars?, marker?, smooth?, extLst?, axId+ + string[] insertBeforeNames = child.LocalName switch { - foreach (var sibling in lc.ChildElements) + "dropLines" => ["hiLowLines", "upDownBars", "marker", "smooth", "extLst", "axId"], + "hiLowLines" => ["upDownBars", "marker", "smooth", "extLst", "axId"], + "upDownBars" => ["marker", "smooth", "extLst", "axId"], + "marker" => ["smooth", "extLst", "axId"], + "smooth" => ["extLst", "axId"], + _ => ["extLst", "axId"] + }; + foreach (var sibling in lc.ChildElements) + { + if (insertBeforeNames.Contains(sibling.LocalName)) { - if (sibling.LocalName is "axId" or "extLst" || - (child.LocalName == "marker" && sibling.LocalName == "smooth")) - { - lc.InsertBefore(child, sibling); - return; - } + lc.InsertBefore(child, sibling); + return; } } lc.AppendChild(child); @@ -663,7 +787,9 @@ internal static void InsertSeriesChildInOrder(OpenXmlCompositeElement ser, OpenX { string[] insertBeforeNames = child.LocalName switch { + "dLbls" => ["trendline", "errBars", "cat", "val", "xVal", "yVal", "bubbleSize", "bubble3D", "smooth", "extLst"], "trendline" => ["errBars", "cat", "val", "xVal", "yVal", "bubbleSize", "bubble3D", "smooth", "extLst"], + "errBars" => ["cat", "val", "xVal", "yVal", "bubbleSize", "bubble3D", "smooth", "extLst"], "smooth" => ["extLst"], _ => ["extLst"] }; diff --git a/src/officecli/Core/ChartHelper.cs b/src/officecli/Core/Chart/ChartHelper.cs similarity index 73% rename from src/officecli/Core/ChartHelper.cs rename to src/officecli/Core/Chart/ChartHelper.cs index 5c49656d1..c0d129ed9 100644 --- a/src/officecli/Core/ChartHelper.cs +++ b/src/officecli/Core/Chart/ChartHelper.cs @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 using DocumentFormat.OpenXml; -using DocumentFormat.OpenXml.Packaging; -using Drawing = DocumentFormat.OpenXml.Drawing; using C = DocumentFormat.OpenXml.Drawing.Charts; namespace OfficeCli.Core; @@ -43,7 +41,7 @@ internal static (string kind, bool is3D, bool stacked, bool percentStacked) Pars _ => throw new ArgumentException( $"Unknown chart type: '{chartType}'. Supported types: " + "column, bar, line, pie, doughnut, area, scatter, bubble, radar, stock, combo, waterfall, " + - "funnel, treemap, sunburst, boxWhisker, histogram. " + + "funnel, treemap, sunburst, boxWhisker, histogram, pareto. " + "Modifiers: 3d (e.g. column3d), stacked (e.g. stackedColumn), percentStacked (e.g. percentStackedBar).") }; @@ -73,6 +71,45 @@ internal static bool IsRangeReference(string value) @"^\$?[A-Za-z]+\$?\d+:\$?[A-Za-z]+\$?\d+$"); } + /// + /// Returns true if the value looks like a single cell reference (A1, $A$1, Sheet1!A1, + /// Sheet1!$A$1) or a single-cell range (A1:A1, Sheet1!A1:A1). Used to detect when + /// a series.name parameter should be emitted as a c:strRef instead of literal c:v. + /// + internal static bool IsCellReference(string value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + var trimmed = value.Trim(); + // Optional sheet prefix (Sheet1! or 'Sheet with spaces'!), single cell A1 or $A$1, + // optionally followed by :A1 range of size 1. + return System.Text.RegularExpressions.Regex.IsMatch(trimmed, + @"^(?:'[^']+'!|[A-Za-z_][\w\.]*!)?\$?[A-Za-z]+\$?\d+(?::\$?[A-Za-z]+\$?\d+)?$"); + } + + /// + /// Normalizes a single-cell reference for use inside a chart's c:strRef/c:f. + /// Ensures absolute ($col$row) form and preserves any sheet prefix. If the + /// input is a A1:A1 style single-cell range, the range form is kept so the + /// output matches what Excel writes when a user points the Name field at a + /// single cell via the dialog. + /// + internal static string NormalizeCellReference(string value) + { + var trimmed = value.Trim(); + string sheetPart = ""; + string cellPart = trimmed; + var bangIdx = trimmed.IndexOf('!'); + if (bangIdx >= 0) + { + sheetPart = trimmed[..(bangIdx + 1)]; + cellPart = trimmed[(bangIdx + 1)..]; + } + var parts = cellPart.Split(':'); + for (int i = 0; i < parts.Length; i++) + parts[i] = AddAbsoluteMarkers(parts[i]); + return sheetPart + string.Join(":", parts); + } + /// /// Normalizes a range reference by adding $ signs for absolute references. /// If no sheet prefix, prepends defaultSheet. @@ -263,8 +300,9 @@ private static double[] ParseSeriesValues(string valStr, string seriesName) return valStr.Split(',').Select(v => { var trimmed = v.Trim(); - if (!double.TryParse(trimmed, System.Globalization.CultureInfo.InvariantCulture, out var num)) - throw new ArgumentException($"Invalid data value '{trimmed}' in series '{seriesName}'. Expected comma-separated numbers (e.g. '1,2,3')."); + if (!double.TryParse(trimmed, System.Globalization.CultureInfo.InvariantCulture, out var num) + || double.IsNaN(num) || double.IsInfinity(num)) + throw new ArgumentException($"Invalid data value '{trimmed}' in series '{seriesName}'. Expected comma-separated finite numbers (e.g. '1,2,3')."); return num; }).ToArray(); } @@ -279,9 +317,42 @@ private static double[] ParseSeriesValues(string valStr, string seriesName) internal static string[]? ParseSeriesColors(Dictionary properties) { + // CONSISTENCY(chart-series-color): Add path accepts both the + // compact `colors=red,blue,green` form and per-series dotted + // `series{N}.color=` keys (same vocabulary that `set chart` + // already supports via ApplySeriesColor). When both are supplied, + // dotted keys override positions in the `colors` array. + string[]? arr = null; if (properties.TryGetValue("colors", out var colorsStr)) - return colorsStr.Split(',').Select(c => c.Trim()).ToArray(); - return null; + arr = colorsStr.Split(',').Select(c => c.Trim()).ToArray(); + + // Collect per-series dotted color keys + var dotted = new Dictionary(); + foreach (var kv in properties) + { + var k = kv.Key; + if (!k.StartsWith("series", StringComparison.OrdinalIgnoreCase)) continue; + if (!k.EndsWith(".color", StringComparison.OrdinalIgnoreCase)) continue; + var mid = k.Substring(6, k.Length - 6 - ".color".Length); + if (!int.TryParse(mid, out var idx) || idx < 1) continue; + if (!string.IsNullOrWhiteSpace(kv.Value)) + dotted[idx] = kv.Value.Trim(); + } + if (dotted.Count == 0) return arr; + + var maxIdx = dotted.Keys.Max(); + var size = Math.Max(maxIdx, arr?.Length ?? 0); + var merged = new string[size]; + for (int i = 0; i < size; i++) + { + if (dotted.TryGetValue(i + 1, out var c)) + merged[i] = c; + else if (arr != null && i < arr.Length && !string.IsNullOrEmpty(arr[i])) + merged[i] = arr[i]; + else + merged[i] = DefaultSeriesColors[i % DefaultSeriesColors.Length]; + } + return merged; } // ==================== ManualLayout Helpers ==================== @@ -297,7 +368,37 @@ internal static void SetManualLayoutProperty(OpenXmlCompositeElement parent, str if (layout == null) { layout = new C.Layout(); - parent.InsertAt(layout, 0); + // Insert layout after structural elements to respect schema order. + // c:title → tx, [layout], overlay, ... + // c:legend → legendPos, legendEntry*, [layout], overlay, ... + // c:dLbl → idx, delete, [layout], ... + // c:plotArea → layout is first child (InsertAt 0 is correct) + if (isPlotArea) + { + parent.InsertAt(layout, 0); + } + else if (parent is C.DataLabel) + { + // CT_DLbl: idx, delete, [layout], tx, numFmt, spPr, ... + var insertAfter = parent.GetFirstChild() as OpenXmlElement + ?? parent.GetFirstChild() as OpenXmlElement; + if (insertAfter != null) + insertAfter.InsertAfterSelf(layout); + else + parent.InsertAt(layout, 0); + } + else + { + // c:title → tx, [layout], overlay, ... + // c:legend → legendPos, legendEntry*, [layout], overlay, ... + var insertAfter = parent.GetFirstChild() as OpenXmlElement + ?? parent.ChildElements.LastOrDefault( + e => e.LocalName is "legendPos" or "legendEntry") as OpenXmlElement; + if (insertAfter != null) + insertAfter.InsertAfterSelf(layout); + else + parent.InsertAt(layout, 0); + } } var ml = layout.GetFirstChild(); if (ml == null) diff --git a/src/officecli/Core/ChartPresets.cs b/src/officecli/Core/Chart/ChartPresets.cs similarity index 100% rename from src/officecli/Core/ChartPresets.cs rename to src/officecli/Core/Chart/ChartPresets.cs diff --git a/src/officecli/Core/Chart/ChartSvgRenderer.CxExtract.cs b/src/officecli/Core/Chart/ChartSvgRenderer.CxExtract.cs new file mode 100644 index 000000000..7b9604f10 --- /dev/null +++ b/src/officecli/Core/Chart/ChartSvgRenderer.CxExtract.cs @@ -0,0 +1,804 @@ +// Copyright 2025 OfficeCli (officecli.ai) +// SPDX-License-Identifier: Apache-2.0 + +using System.Globalization; +using System.Text; +using DocumentFormat.OpenXml; +using Drawing = DocumentFormat.OpenXml.Drawing; +using CX = DocumentFormat.OpenXml.Office2016.Drawing.ChartDrawing; + +namespace OfficeCli.Core; + +/// +/// Extract ChartInfo from a cx:chart (Office 2016 extended chart) element and +/// emit SVG for the shape primitives that don't map onto the regular cChart +/// renderers (treemap nested rectangles, sunburst arcs, box-whisker boxes). +/// +/// Histogram and funnel reuse the existing RenderBarChartSvg pipeline by +/// client-side binning (histogram) or treating the levels as categories +/// (funnel). Treemap / sunburst / boxWhisker have dedicated inline emitters. +/// +/// This partial is on the same ChartSvgRenderer class so we have access to +/// the private helpers (HtmlEncode, colors, etc.). +/// +internal partial class ChartSvgRenderer +{ + // ==================== cx → ChartInfo extraction ==================== + + /// + /// Extract a from a cx:chart element. Produces + /// the same shape the regular ExtractChartInfo does, so all of + /// RenderChartSvgContent's downstream emitters work without branching on + /// source format — except for the cx-specific types (treemap / sunburst / + /// boxWhisker) which dispatch to new dedicated emitters in + /// RenderChartSvgContent. + /// + public static ChartInfo ExtractCxChartInfo(CX.Chart chart) + { + var info = new ChartInfo(); + + // ---- Title ---- + var chartTitle = chart.GetFirstChild(); + if (chartTitle != null) + { + var titleText = chartTitle.Descendants().FirstOrDefault()?.Text; + if (!string.IsNullOrEmpty(titleText)) info.Title = titleText; + var titleRpr = chartTitle.Descendants().FirstOrDefault(); + if (titleRpr?.FontSize?.HasValue == true) + info.TitleFontSize = $"{titleRpr.FontSize.Value / 100.0}pt"; + var titleColor = titleRpr?.GetFirstChild() + ?.GetFirstChild()?.Val?.Value; + if (!string.IsNullOrEmpty(titleColor)) info.TitleFontColor = $"#{titleColor}"; + } + + // ---- Series (plot area region) ---- + var plotArea = chart.GetFirstChild(); + var plotAreaRegion = plotArea?.GetFirstChild(); + var allSeries = plotAreaRegion?.Elements().ToList() ?? new List(); + var chartSpace = chart.Ancestors().FirstOrDefault(); + var chartData = chartSpace?.GetFirstChild(); + + // Determine normalized chart type from the first series' LayoutId. + // CX.SeriesLayout is a struct, not a C# enum, so we can't pattern-match + // the typed value directly — compare via InnerText. + var firstLayoutId = allSeries.FirstOrDefault()?.LayoutId?.InnerText ?? ""; + info.ChartType = firstLayoutId.ToLowerInvariant() switch + { + "funnel" => "funnel", + "treemap" => "treemap", + "sunburst" => "sunburst", + "boxwhisker" => "boxwhisker", + "clusteredcolumn" => "histogram", // histogram is stored as clusteredColumn layoutId + _ => "histogram" + }; + + // Read each series' data from the matching cx:data block (dataId.val → data.id). + foreach (var series in allSeries) + { + var dataIdVal = series.GetFirstChild()?.Val?.Value ?? 0; + var dataBlock = chartData?.Elements().FirstOrDefault(d => (d.Id?.Value ?? 0) == dataIdVal); + if (dataBlock == null) continue; + + var seriesName = series.GetFirstChild() + ?.GetFirstChild() + ?.GetFirstChild()?.Text ?? "Series"; + + var values = dataBlock.Elements() + .SelectMany(nd => nd.Descendants()) + .Select(nv => double.TryParse(nv.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 0.0) + .ToArray(); + + // Categories: strDim if present (funnel/treemap/sunburst), else values themselves (histogram) + if (info.Categories.Length == 0) + { + var catStrDim = dataBlock.Elements() + .FirstOrDefault(sd => sd.Type?.Value == CX.StringDimensionType.Cat); + if (catStrDim != null) + { + info.Categories = catStrDim.Descendants() + .Select(cv => cv.Text ?? "") + .ToArray(); + } + } + + info.Series.Add((seriesName, values)); + + // Series fill color + var spPrFill = series.GetFirstChild() + ?.GetFirstChild() + ?.GetFirstChild()?.Val?.Value; + // Hex-gate the raw attribute — an adversarial chartEx chart1.xml + // otherwise feeds the color into legend/SVG style attributes and + // escapes the context. + if (!string.IsNullOrEmpty(spPrFill) + && spPrFill.Length is 3 or 6 or 8 + && spPrFill.All(c => (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) + info.Colors.Add($"#{spPrFill}"); + } + + // Fill in fallback colors for any series without an explicit spPr + while (info.Colors.Count < info.Series.Count) + info.Colors.Add(FallbackColors[info.Colors.Count % FallbackColors.Length]); + + // ---- Histogram-specific: bin the raw values into columns ---- + if (info.ChartType == "histogram" && info.Series.Count > 0) + { + var firstSeries = info.Series[0]; + var binning = allSeries.FirstOrDefault()?.Descendants().FirstOrDefault(); + var binCount = ReadBinCount(binning); + var binSize = ReadBinSize(binning); + var (binEdges, binCounts) = ComputeBins(firstSeries.values, binCount, binSize); + // Replace values with bin counts, and categories with bin labels + var labels = new string[binCounts.Length]; + for (int i = 0; i < binCounts.Length; i++) + { + var lo = FormatNumber(binEdges[i]); + var hi = FormatNumber(binEdges[i + 1]); + labels[i] = $"[{lo},{hi}]"; + } + info.Categories = labels; + info.Series[0] = (firstSeries.name, binCounts.Select(c => (double)c).ToArray()); + info.GapWidth = 0; // histogram default — overridden below if cx:catScaling/@gapWidth is present + } + + // ---- Axes: titles, scaling, styling ---- + // + // Extracts the full per-axis vocabulary so it matches what the cx + // builder emits (ChartExBuilder.BuildCategoryAxis / BuildValueAxis): + // - axismin/axismax/majorunit → cx:valScaling @min/@max/@majorUnit + // - gapWidth → cx:catScaling @gapWidth + // - gridlineColor → cx:axis/cx:majorGridlines/cx:spPr/a:ln + // - axisline → cx:axis/cx:spPr/a:ln + // - axisfont (size+color) → cx:axis/cx:txPr/.../a:defRPr + // - axis title font/bold → cx:axis/cx:title/.../a:rPr + // + // Without these reads, any histogram that sets locked Y scale, custom + // gridline/axis-line color, custom tick-label font, or custom axis + // title bold/size renders in the HTML preview with Excel-default + // values even though the XML is correct. Excel itself renders them + // fine — this only affects officecli's in-process preview. + if (plotArea != null) + { + var axes = plotArea.Elements().ToList(); + var catAxis = axes.FirstOrDefault(); // Id=0 + var valAxis = axes.ElementAtOrDefault(1); + + info.CatAxisTitle = ExtractAxisTitleText(catAxis); + info.ValAxisTitle = ExtractAxisTitleText(valAxis); + + if (valAxis != null) + { + // Axis scaling (min/max/majorUnit) — string attributes on cx:valScaling. + var valScaling = valAxis.GetFirstChild(); + if (valScaling != null) + { + if (double.TryParse(valScaling.Min?.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var mnV)) + info.AxisMin = mnV; + if (double.TryParse(valScaling.Max?.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var mxV)) + info.AxisMax = mxV; + if (double.TryParse(valScaling.MajorUnit?.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var muV)) + info.MajorUnit = muV; + } + + // Axis title font size / bold + var valTitleEl = valAxis.Elements().FirstOrDefault(e => e.LocalName == "title"); + 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; + + // Tick label font — cx:axis/cx:txPr/.../a:defRPr (axisfont compound knob) + 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); + + // Major gridline color + var valGl = valAxis.Elements().FirstOrDefault(e => e.LocalName == "majorGridlines"); + var valGlSpPr = valGl?.Elements().FirstOrDefault(e => e.LocalName == "spPr"); + info.GridlineColor = ExtractLineColor(valGlSpPr); + + // Axis spine color + var valSpPr = valAxis.Elements().FirstOrDefault(e => e.LocalName == "spPr"); + info.AxisLineColor = ExtractLineColor(valSpPr); + } + + if (catAxis != null) + { + // gapWidth — string attribute on cx:catScaling (overrides the + // histogram default of 0 set during binning above). + var catScaling = catAxis.GetFirstChild(); + if (catScaling?.GapWidth?.Value is string gwStr + && int.TryParse(gwStr, out var gw)) + info.GapWidth = gw; + + // Axis title font size / bold + var catTitleEl = catAxis.Elements().FirstOrDefault(e => e.LocalName == "title"); + 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; + + // Tick label font + 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); + + // Category-axis spine color (cataxis.line / axisline) — if + // only axisline was set, both axes received identical outlines; + // we still read cat separately so per-axis overrides work. + // valSpPr is preferred but if valAxis has none we fall back + // to catAxis for AxisLineColor. + if (info.AxisLineColor == null) + { + var catSpPr = catAxis.Elements().FirstOrDefault(e => e.LocalName == "spPr"); + info.AxisLineColor = ExtractLineColor(catSpPr); + } + } + } + + // ---- Data labels (histogram) ---- + // + // cx attaches dLbls to the series, not the chart type element. Read + // cx:series/cx:dataLabels/cx:visibility[@value] to decide whether + // the bar chart renderer should draw value labels above each bar. + var firstSeriesEl = allSeries.FirstOrDefault(); + var dLabelsEl = firstSeriesEl?.GetFirstChild(); + if (dLabelsEl != null) + { + var vis = dLabelsEl.GetFirstChild(); + if (vis?.Value?.Value == true) + { + info.ShowDataLabels = true; + info.ShowDataLabelVal = true; + } + } + + // ---- Plot-area / chart-area background fills ---- + // Mirrors the regular cChart path in ExtractChartInfo: read the + // spPr direct child of and of and pull + // the a:solidFill/a:srgbClr value. ExtractFillColor uses LocalName + // matching so it works across c: and cx: namespaces unchanged. + // + // Downstream, PlotFillColor is painted as a inside the chart + // SVG (RenderChartSvgContent) and ChartFillColor is applied as a + // `background:` style on the chart container div (ExcelHandler + // HtmlPreview). Without these lines, cx histograms with + // `plotareafill` / `chartareafill` render on a blank white page + // even though the XML is perfectly correct — the fills only + // surface in Excel itself. + var plotSpPr = plotArea?.Elements().FirstOrDefault(e => e.LocalName == "spPr"); + info.PlotFillColor = ExtractFillColor(plotSpPr); + var chartSpPr = chartSpace?.Elements().FirstOrDefault(e => e.LocalName == "spPr"); + info.ChartFillColor = ExtractFillColor(chartSpPr); + + // ---- Legend ---- + // Presence-based (cx omits the element entirely to hide the legend, + // unlike c:legend which uses ). + var legend = chart.GetFirstChild(); + info.HasLegend = legend != null; + if (legend != null) + { + // legendfont — cx:legend/cx:txPr/.../a:defRPr — compound + // "size:color:fontname" knob from the builder. + var legendTxPr = legend.Elements().FirstOrDefault(e => e.LocalName == "txPr"); + var legendDefRPr = legendTxPr?.Descendants().FirstOrDefault(); + if (legendDefRPr?.FontSize?.HasValue == true) + info.LegendFontSize = $"{legendDefRPr.FontSize.Value / 100.0:0.##}pt"; + info.LegendFontColor = ExtractFontColor(legendDefRPr); + } + + return info; + } + + private static string? ExtractAxisTitleText(CX.Axis? axis) + { + var title = axis?.GetFirstChild(); + if (title == null) return null; + return title.Descendants().FirstOrDefault()?.Text; + } + + // ==================== Histogram binning (client-side) ==================== + + // The cx binning XML uses raw OpenXmlUnknownElement children (val attribute + // workaround — see ChartExBuilder.cs notes). Read val attribute directly. + private static uint? ReadBinCount(CX.Binning? binning) + { + if (binning == null) return null; + foreach (var child in binning.ChildElements) + { + if (child.LocalName != "binCount") continue; + var val = child.GetAttributes() + .FirstOrDefault(a => a.LocalName == "val").Value; + if (uint.TryParse(val, out var n)) return n; + } + return null; + } + + private static double? ReadBinSize(CX.Binning? binning) + { + if (binning == null) return null; + foreach (var child in binning.ChildElements) + { + if (child.LocalName != "binSize") continue; + var val = child.GetAttributes() + .FirstOrDefault(a => a.LocalName == "val").Value; + if (double.TryParse(val, NumberStyles.Float, CultureInfo.InvariantCulture, out var w)) + return w; + } + return null; + } + + /// + /// Compute histogram bins from raw values. Matches Excel's semantics: + /// - If binCount is set, divide [min, max] into N equal-width bins. + /// - If binSize is set, width = binSize, bins anchored at min. + /// - Else auto-bin using sqrt(N) rule, clamped to [5, 20]. + /// Right-closed intervals (a, b] — the default for Excel's histogram. + /// + private static (double[] edges, int[] counts) ComputeBins(double[] values, uint? binCount, double? binSize) + { + if (values.Length == 0) return (new[] { 0.0, 1.0 }, new[] { 0 }); + var min = values.Min(); + var max = values.Max(); + if (Math.Abs(max - min) < 1e-9) { max = min + 1; } + + int n; + double width; + if (binSize is double sz && sz > 0) + { + width = sz; + n = (int)Math.Max(1, Math.Ceiling((max - min) / width)); + } + else + { + n = binCount is uint bc && bc > 0 + ? (int)bc + : (int)Math.Clamp(Math.Ceiling(Math.Sqrt(values.Length)), 5, 20); + width = (max - min) / n; + } + + var edges = new double[n + 1]; + for (int i = 0; i <= n; i++) edges[i] = min + width * i; + edges[n] = max; // clamp last edge to exact max to avoid FP drift + + var counts = new int[n]; + foreach (var v in values) + { + // Right-closed: find first bin where v <= edges[i+1] + var idx = 0; + for (int i = 0; i < n; i++) + { + if (v <= edges[i + 1]) { idx = i; break; } + idx = n - 1; + } + counts[idx]++; + } + return (edges, counts); + } + + private static string FormatNumber(double v) + { + // Short display — use "G" format for compact values, no trailing zeros. + if (Math.Abs(v) >= 1000) return v.ToString("F0", CultureInfo.InvariantCulture); + if (Math.Abs(v - Math.Round(v)) < 1e-9) return v.ToString("F0", CultureInfo.InvariantCulture); + return v.ToString("0.##", CultureInfo.InvariantCulture); + } + + // ==================== cx-specific SVG emitters ==================== + + /// + /// Render a funnel chart as centered horizontal bars. Excel funnels are + /// drawn bottom-to-top with the widest level at the top, so we reverse + /// the series order and render each level as a bar whose width is + /// proportional to its value. Simple but visually conveys the shape. + /// + public void RenderCxFunnelSvg(StringBuilder sb, ChartInfo info, + int marginLeft, int marginTop, int plotW, int plotH) + { + if (info.Series.Count == 0) return; + var values = info.Series[0].values; + var cats = info.Categories.Length == values.Length ? info.Categories : new string[values.Length]; + if (values.Length == 0) return; + + var maxVal = values.Max(); + if (maxVal <= 0) return; + + var rowH = (double)plotH / values.Length; + var barH = rowH * 0.75; + // Funnel: use a single series color (or first palette entry). + // Cycling colors per level conflicts with the standard funnel look. + var color = info.Colors.FirstOrDefault() ?? DefaultColors[0]; + var cx = marginLeft + plotW / 2; + + for (int i = 0; i < values.Length; i++) + { + var w = (values[i] / maxVal) * plotW; + var y = marginTop + rowH * i + (rowH - barH) / 2; + var x = cx - w / 2; + sb.AppendLine($" "); + // Label inside or to the right of bar + var labelX = cx; + var labelY = y + barH / 2; + var label = (cats[i] ?? "") + $" ({FormatNumber(values[i])})"; + sb.AppendLine($" {HtmlEncode(label)}"); + } + } + + /// + /// Render a treemap as a simple squarified layout. Treats all leaves as a + /// flat list (ignores hierarchy — good enough for preview). Each rectangle's + /// area is proportional to its value. + /// + /// Uses Bruls/Huijbregts/van Wijk (2000) squarify with row-wise fallback: + /// pack items into strips along the shorter axis, finishing one strip + /// before starting the next. + /// + public void RenderCxTreemapSvg(StringBuilder sb, ChartInfo info, + int marginLeft, int marginTop, int plotW, int plotH) + { + if (info.Series.Count == 0) return; + var values = info.Series[0].values; + var cats = info.Categories.Length == values.Length ? info.Categories : new string[values.Length]; + if (values.Length == 0) return; + var total = values.Sum(); + if (total <= 0) return; + + // Sort descending so big rectangles go first + var order = Enumerable.Range(0, values.Length) + .Where(i => values[i] > 0) + .OrderByDescending(i => values[i]).ToArray(); + + // Scale values so that sum equals rectangle area — then we can talk + // directly in pixel areas for each cell. + var scale = (double)plotW * plotH / total; + var scaledVals = order.Select(i => values[i] * scale).ToArray(); + + // Treemap / sunburst / funnel have ONE series but N cells, so cycle + // through the palette per cell rather than painting every cell the + // same series color. Use the theme palette if available. + var palette = DefaultColors.Length > 0 ? DefaultColors : FallbackColors; + + var rect = new Rect { X = marginLeft, Y = marginTop, W = plotW, H = plotH }; + Squarify(scaledVals, 0, rect, (idx, r) => + { + var origIdx = order[idx]; + var color = palette[origIdx % palette.Length]; + sb.AppendLine($" "); + if (r.W > 40 && r.H > 18) + { + var label = cats[origIdx] ?? ""; + sb.AppendLine($" {HtmlEncode(label)}"); + } + }); + } + + private struct Rect { public double X, Y, W, H; } + + /// + /// Classic squarify algorithm (Bruls et al. 2000), simplified: greedily + /// group items into strips along the shorter side of the remaining rect, + /// committing the strip when adding one more item would worsen the worst + /// aspect ratio of the current group. Each committed strip consumes the + /// full shorter side; remaining items fill the leftover rectangle. + /// + private static void Squarify(double[] areas, int start, Rect rect, Action emit) + { + if (start >= areas.Length || rect.W <= 0.5 || rect.H <= 0.5) return; + + // Convention: the "strip" is placed along the SHORT side. If the + // rectangle is WIDE (W > H), the strip is a vertical column at the + // left edge (full H tall, stripW wide). If the rectangle is TALL + // (H > W), the strip is a horizontal row at the top edge (full W + // wide, stripH tall). Items stack ALONG the short side (vertically + // in a wide rect, horizontally in a tall rect). + var shortSide = Math.Min(rect.W, rect.H); + + // Greedily extend the current row as long as aspect ratio improves + // (or stays equal). Stop and commit when the next item would make + // the worst aspect ratio worse. + int end = start + 1; + double bestWorst = RowWorstRatio(areas, start, end, shortSide); + + while (end < areas.Length) + { + var tryEnd = end + 1; + var tryWorst = RowWorstRatio(areas, start, tryEnd, shortSide); + if (tryWorst <= bestWorst) + { + end = tryEnd; + bestWorst = tryWorst; + } + else break; + } + + // Emit the committed row + var stripAdvance = LayoutRow(areas, start, end, rect, emit); + + // Recurse on the leftover rectangle (the part outside the strip). + Rect remaining = rect.W >= rect.H + // Wide rect → vertical strip at left → recurse on right slab + ? new Rect { X = rect.X + stripAdvance, Y = rect.Y, W = rect.W - stripAdvance, H = rect.H } + // Tall rect → horizontal strip at top → recurse on bottom slab + : new Rect { X = rect.X, Y = rect.Y + stripAdvance, W = rect.W, H = rect.H - stripAdvance }; + + Squarify(areas, end, remaining, emit); + } + + /// + /// Worst aspect ratio for the proposed row (items [start, end)) packed + /// along a strip of length . Each item then + /// has one dimension = stripThickness = rowSum/shortSide and the other + /// = a_i / stripThickness. Per Bruls et al.: + /// worst = max(max_i(w² · a_max / s²), max_i(s² / (w² · a_min))) + /// + private static double RowWorstRatio(double[] areas, int start, int end, double shortSide) + { + if (end <= start) return double.MaxValue; + double s = 0; + double maxArea = 0, minArea = double.MaxValue; + for (int i = start; i < end; i++) + { + s += areas[i]; + if (areas[i] > maxArea) maxArea = areas[i]; + if (areas[i] < minArea) minArea = areas[i]; + } + if (s <= 0 || shortSide <= 0) return double.MaxValue; + var sqSide = shortSide * shortSide; + var a = (sqSide * maxArea) / (s * s); + var b = (s * s) / (sqSide * Math.Max(minArea, 1e-9)); + return Math.Max(a, b); + } + + /// + /// Lay out a committed row inside and call + /// for each item. Returns how far the strip + /// advanced along the LONG side of the rectangle — the caller uses + /// this to compute the leftover rectangle. + /// + private static double LayoutRow(double[] areas, int start, int end, Rect rect, Action emit) + { + double rowSum = 0; + for (int i = start; i < end; i++) rowSum += areas[i]; + if (rowSum <= 0) return 0; + + var wideRect = rect.W >= rect.H; + var shortSide = Math.Min(rect.W, rect.H); + var stripThickness = rowSum / shortSide; // strip depth along long side + + // Items inside the strip have one fixed side = stripThickness and + // the other side = a_i / stripThickness. They stack along the short + // side of the original rect. + var cursor = 0.0; + for (int i = start; i < end; i++) + { + var itemLenAlongShort = areas[i] / stripThickness; + Rect r; + if (wideRect) + { + // Strip is a vertical column at rect.X, full height stacked. + r = new Rect + { + X = rect.X, + Y = rect.Y + cursor, + W = stripThickness, + H = itemLenAlongShort, + }; + } + else + { + // Strip is a horizontal row at rect.Y, full width packed. + r = new Rect + { + X = rect.X + cursor, + Y = rect.Y, + W = itemLenAlongShort, + H = stripThickness, + }; + } + emit(i, r); + cursor += itemLenAlongShort; + } + return stripThickness; + } + + /// + /// Render a sunburst as concentric arcs. Without full hierarchy info we + /// just draw a single ring with one slice per value (like a pie chart + /// with a large hole). Good enough for previews. + /// + public void RenderCxSunburstSvg(StringBuilder sb, ChartInfo info, + int marginLeft, int marginTop, int plotW, int plotH) + { + if (info.Series.Count == 0) return; + var values = info.Series[0].values; + var cats = info.Categories.Length == values.Length ? info.Categories : new string[values.Length]; + var total = values.Sum(); + if (total <= 0) return; + + var cx = marginLeft + plotW / 2.0; + var cy = marginTop + plotH / 2.0; + var rOuter = Math.Min(plotW, plotH) / 2.0 - 10; + var rInner = rOuter * 0.35; + + var palette = DefaultColors.Length > 0 ? DefaultColors : FallbackColors; + var startAngle = -Math.PI / 2; // start at 12 o'clock + for (int i = 0; i < values.Length; i++) + { + var sweep = (values[i] / total) * 2 * Math.PI; + if (sweep <= 0) continue; + var endAngle = startAngle + sweep; + var largeArc = sweep > Math.PI ? 1 : 0; + + var x1 = cx + rOuter * Math.Cos(startAngle); + var y1 = cy + rOuter * Math.Sin(startAngle); + var x2 = cx + rOuter * Math.Cos(endAngle); + var y2 = cy + rOuter * Math.Sin(endAngle); + var ix1 = cx + rInner * Math.Cos(endAngle); + var iy1 = cy + rInner * Math.Sin(endAngle); + var ix2 = cx + rInner * Math.Cos(startAngle); + var iy2 = cy + rInner * Math.Sin(startAngle); + + var d = $"M {x1:F1},{y1:F1} A {rOuter:F1},{rOuter:F1} 0 {largeArc} 1 {x2:F1},{y2:F1} " + + $"L {ix1:F1},{iy1:F1} A {rInner:F1},{rInner:F1} 0 {largeArc} 0 {ix2:F1},{iy2:F1} Z"; + var color = palette[i % palette.Length]; + sb.AppendLine($" "); + + // Label in the middle of the arc + var midAngle = startAngle + sweep / 2; + var labelR = (rOuter + rInner) / 2; + var lx = cx + labelR * Math.Cos(midAngle); + var ly = cy + labelR * Math.Sin(midAngle); + var label = cats[i] ?? ""; + if (sweep > 0.25 && !string.IsNullOrEmpty(label)) + sb.AppendLine($" {HtmlEncode(label)}"); + + startAngle = endAngle; + } + } + + /// + /// Render a box-whisker chart. For each series: box (Q1–Q3), median line, + /// whiskers extending to the last non-outlier value within 1.5×IQR of the + /// fence, outlier data points drawn as open circles, and a mean marker (×). + /// + public void RenderCxBoxWhiskerSvg(StringBuilder sb, ChartInfo info, + int marginLeft, int marginTop, int plotW, int plotH) + { + if (info.Series.Count == 0) return; + + // Compute stats per series + var stats = info.Series.Select(s => ComputeBoxStats(s.values)).ToList(); + if (stats.All(s => s == null)) return; + + // Global scale includes outliers + var globalMin = stats.Where(s => s != null).Min(s => s!.Value.allMin); + var globalMax = stats.Where(s => s != null).Max(s => s!.Value.allMax); + if (Math.Abs(globalMax - globalMin) < 1e-9) globalMax = globalMin + 1; + // Add 5% padding so top/bottom outliers aren't clipped at the edge + var pad = (globalMax - globalMin) * 0.05; + globalMin -= pad; + globalMax += pad; + + var bw = (double)plotW / info.Series.Count; + var boxW = bw * 0.5; + + double yCoord(double v) => marginTop + plotH - ((v - globalMin) / (globalMax - globalMin)) * plotH; + + // Y axis: a few tick labels for context + for (int t = 0; t <= 4; t++) + { + var v = globalMin + pad + (globalMax - globalMin - 2 * pad) * t / 4; + var y = yCoord(v); + sb.AppendLine($" "); + sb.AppendLine($" {FormatNumber(v)}"); + } + + for (int si = 0; si < info.Series.Count; si++) + { + if (stats[si] is not { } s) continue; + var color = info.Colors[si % info.Colors.Count]; + var cxCenter = marginLeft + bw * (si + 0.5); + var boxX = cxCenter - boxW / 2; + + var yWLow = yCoord(s.whiskerLow); + var yWHigh = yCoord(s.whiskerHigh); + var yQ1 = yCoord(s.q1); + var yQ3 = yCoord(s.q3); + var yMed = yCoord(s.median); + var yMean = yCoord(s.mean); + + // Whisker vertical line: Q1→whiskerLow and Q3→whiskerHigh + sb.AppendLine($" "); + sb.AppendLine($" "); + // Whisker caps (horizontal ticks at fence endpoints) + var capHalf = boxW * 0.3; + sb.AppendLine($" "); + sb.AppendLine($" "); + // Box Q1..Q3 + sb.AppendLine($" "); + // Median line + sb.AppendLine($" "); + // Mean marker: × symbol + var mx = 4.0; + sb.AppendLine($" "); + sb.AppendLine($" "); + + // Outlier circles + const double r = 3.5; + foreach (var ov in s.outliers) + { + var yo = yCoord(ov); + sb.AppendLine($" "); + } + + // Series label + sb.AppendLine($" {HtmlEncode(info.Series[si].name)}"); + } + } + + private record struct BoxStats( + double whiskerLow, double q1, double median, double q3, double whiskerHigh, + double mean, double allMin, double allMax, double[] outliers); + + private static BoxStats? ComputeBoxStats(double[] values) + { + if (values.Length == 0) return null; + var sorted = values.OrderBy(v => v).ToArray(); + double Percentile(double p) + { + if (sorted.Length == 1) return sorted[0]; + var idx = p * (sorted.Length - 1); + var lo = (int)Math.Floor(idx); + var hi = (int)Math.Ceiling(idx); + var frac = idx - lo; + return sorted[lo] * (1 - frac) + sorted[hi] * frac; + } + var q1 = Percentile(0.25); + var q3 = Percentile(0.75); + var iqr = q3 - q1; + var fenceLow = q1 - 1.5 * iqr; + var fenceHigh = q3 + 1.5 * iqr; + + // Whiskers extend to the last data point within the fence + var whiskerLow = sorted.Where(v => v >= fenceLow).DefaultIfEmpty(q1).Min(); + var whiskerHigh = sorted.Where(v => v <= fenceHigh).DefaultIfEmpty(q3).Max(); + var outliers = sorted.Where(v => v < fenceLow || v > fenceHigh).ToArray(); + var mean = sorted.Average(); + + return new BoxStats( + whiskerLow, q1, Percentile(0.5), q3, whiskerHigh, + mean, sorted[0], sorted[^1], outliers); + } + + /// + /// Dispatcher entry for cx chart types that aren't reducible to the + /// regular bar/column pipeline. Histogram → RenderBarChartSvg (handled + /// by the main dispatcher after ExtractCxChartInfo pre-bins the data). + /// + public bool TryRenderCxSpecificType(StringBuilder sb, ChartInfo info, + int marginLeft, int marginTop, int plotW, int plotH) + { + switch (info.ChartType) + { + case "funnel": + RenderCxFunnelSvg(sb, info, marginLeft, marginTop, plotW, plotH); + return true; + case "treemap": + RenderCxTreemapSvg(sb, info, marginLeft, marginTop, plotW, plotH); + return true; + case "sunburst": + RenderCxSunburstSvg(sb, info, marginLeft, marginTop, plotW, plotH); + return true; + case "boxwhisker": + RenderCxBoxWhiskerSvg(sb, info, marginLeft, marginTop, plotW, plotH); + return true; + } + return false; + } +} diff --git a/src/officecli/Core/Chart/ChartSvgRenderer.cs b/src/officecli/Core/Chart/ChartSvgRenderer.cs new file mode 100644 index 000000000..6b497d0e5 --- /dev/null +++ b/src/officecli/Core/Chart/ChartSvgRenderer.cs @@ -0,0 +1,2895 @@ +// Copyright 2025 OfficeCli (officecli.ai) +// SPDX-License-Identifier: Apache-2.0 + +using System.Text; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Drawing.Charts; +using Drawing = DocumentFormat.OpenXml.Drawing; + +namespace OfficeCli.Core; + +/// +/// Shared chart SVG rendering logic used by both PowerPoint and Excel HTML preview. +/// Split across two files: +/// ChartSvgRenderer.cs — regular c:chart extraction + render +/// ChartSvgRenderer.CxExtract.cs — cx:chart extraction + render (histogram, +/// funnel, treemap, sunburst, boxWhisker) +/// +internal partial class ChartSvgRenderer +{ + // 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"; + public string AxisColor { get; set; } = "#B0B8C0"; + public string SecondaryAxisColor { get; set; } = "#aaa"; + public string GridColor { get; set; } = "#333"; + 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) => + text.Replace("&", "&").Replace("<", "<").Replace(">", ">") + .Replace("\"", """).Replace("'", "'"); + + public void RenderBarChartSvg(StringBuilder sb, List<(string name, double[] values)> series, + string[] categories, List colors, int ox, int oy, int pw, int ph, + 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, string? valNumFmt = null, string? plotFillColor = null, + List<(string Name, double Value, string Color, double WidthPt, string Dash)>? referenceLines = null, + bool isWaterfall = false, List? errorBars = null) + { + var allValues = series.SelectMany(s => s.values).ToArray(); + if (allValues.Length == 0) return; + var catCount = Math.Max(categories.Length, series.Max(s => s.values.Length)); + var serCount = series.Count; + if (percentStacked) stacked = true; + + double maxVal; + if (percentStacked) maxVal = 100; + else if (stacked) + { + maxVal = 0; + for (int c = 0; c < catCount; c++) + { + var sum = series.Sum(s => c < s.values.Length ? s.values[c] : 0); + if (sum > maxVal) maxVal = sum; + } + } + else maxVal = allValues.Max(); + if (maxVal <= 0) maxVal = 1; + + double niceMax, tickStep; + int nTicks; + if (!percentStacked) + { + if (ooxmlMax.HasValue && ooxmlMajorUnit.HasValue) + { + niceMax = ooxmlMax.Value; + tickStep = ooxmlMajorUnit.Value; + nTicks = (int)Math.Round(niceMax / tickStep); + } + else (niceMax, tickStep, nTicks) = ComputeNiceAxis(ooxmlMax ?? maxVal); + } + else { niceMax = 100; nTicks = 5; tickStep = 20; } + + if (horizontal) + { + // 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; + if (stacked) { barH = groupH / (1 + gapPct); gap = (groupH - barH) / 2; } + else { barH = groupH / (serCount + gapPct); gap = barH * gapPct / 2; } + + for (int t = 1; t <= nTicks; t++) + { + var gx = plotOx + (double)plotPw * t / nTicks; + sb.AppendLine($" "); + } + sb.AppendLine($" "); + sb.AppendLine($" "); + + for (int c = 0; c < catCount; c++) + { + var dataIdx = catCount - 1 - c; + double stackX = 0; + var catSum = percentStacked ? series.Sum(s => dataIdx < s.values.Length ? s.values[dataIdx] : 0) : 1; + for (int s = 0; s < serCount; s++) + { + var rawVal = dataIdx < series[s].values.Length ? series[s].values[dataIdx] : 0; + var val = percentStacked && catSum > 0 ? (rawVal / catSum) * 100 : rawVal; + var barW = (val / niceMax) * plotPw; + if (stacked) + { + var bx = plotOx + (stackX / niceMax) * plotPw; + var by = oy + c * groupH + gap; + sb.AppendLine($" "); + // Label at segment center — skip if segment narrower than ~2 chars to avoid overflow + if (showDataLabels && barW > DataLabelFontPx * 1.6) + { + var vlabel = rawVal % 1 == 0 ? $"{(int)rawVal}" : $"{rawVal:0.#}"; + sb.AppendLine($" {vlabel}"); + } + stackX += val; + } + else + { + var bx = plotOx; + var by = oy + c * groupH + gap + (serCount - 1 - s) * barH; + sb.AppendLine($" "); + } + } + } + for (int c = 0; c < catCount; c++) + { + var dataIdx = catCount - 1 - c; + var label = dataIdx < categories.Length ? categories[dataIdx] : ""; + var ly = oy + c * groupH + groupH / 2; + sb.AppendLine($" {HtmlEncode(label)}"); + } + for (int t = 0; t <= nTicks; t++) + { + var val = tickStep * t; + var label = percentStacked ? $"{(int)val}%" : FormatAxisValue(val, valNumFmt); + var tx = plotOx + (double)plotPw * t / nTicks; + sb.AppendLine($" {label}"); + } + // Reference-line overlays: horizontal bars → vertical line at value position on the X (value) axis. + // For percentStacked charts, the value axis is 0–1 in OOXML but we display 0–100, so scale accordingly. + if (referenceLines != null) + foreach (var rl in referenceLines) + { + var v = percentStacked ? rl.Value * 100 : rl.Value; + if (v < 0 || v > niceMax) continue; + var rx = plotOx + (v / niceMax) * plotPw; + var strokeColor = rl.Color.StartsWith("#") ? rl.Color : "#" + rl.Color; + var dashArray = RefLineDashArray(rl.Dash); + sb.AppendLine($" "); + } + } + else + { + var groupW = (double)pw / Math.Max(catCount, 1); + var gapPct = (ooxmlGapWidth ?? 150) / 100.0; + double barW, gap; + if (stacked) { barW = groupW / (1 + gapPct); gap = (groupW - barW) / 2; } + else { barW = groupW / (serCount + gapPct); gap = barW * gapPct / 2; } + + for (int t = 1; t <= nTicks; t++) + { + var gy = oy + ph - (double)ph * t / nTicks; + sb.AppendLine($" "); + } + sb.AppendLine($" "); + sb.AppendLine($" "); + + // Track waterfall connector positions for drawing connecting lines + var wfPrevTopY = double.NaN; + + for (int c = 0; c < catCount; c++) + { + double stackY = 0; + var catSum = percentStacked ? series.Sum(s => c < s.values.Length ? s.values[c] : 0) : 1; + for (int s = 0; s < serCount; s++) + { + var rawVal = c < series[s].values.Length ? series[s].values[c] : 0; + var val = percentStacked && catSum > 0 ? (rawVal / catSum) * 100 : rawVal; + var barH = (val / niceMax) * ph; + if (stacked) + { + var bx = ox + c * groupW + gap; + var by = oy + ph - (stackY / niceMax) * ph - barH; + // For waterfall: skip rendering Base series (s=0), only render Increase/Decrease + if (!isWaterfall || s > 0) + { + if (barH > 0.5) + sb.AppendLine($" "); + if (showDataLabels && barH > DataLabelFontPx + 2) + { + var vlabel = FormatAxisValue(rawVal, valNumFmt); + sb.AppendLine($" {vlabel}"); + } + } + // Waterfall connector line from previous bar's top to this bar's top + if (isWaterfall && s == 0 && c > 0 && !double.IsNaN(wfPrevTopY)) + { + var connY = oy + ph - (stackY / niceMax) * ph; + var prevBx = ox + (c - 1) * groupW + gap + barW; + sb.AppendLine($" "); + } + stackY += val; + } + else + { + var bx = ox + c * groupW + gap + s * barW; + var by = oy + ph - barH; + sb.AppendLine($" "); + if (showDataLabels) + { + var vlabel = FormatAxisValue(rawVal, valNumFmt); + sb.AppendLine($" {vlabel}"); + } + } + } + // Track waterfall top position for connector line + if (isWaterfall) + wfPrevTopY = oy + ph - (stackY / niceMax) * ph; + } + // Error bars on vertical (column) bar charts + if (errorBars != null && !stacked) + { + for (int s = 0; s < serCount; s++) + { + var eb = s < errorBars.Count ? errorBars[s] : null; + if (eb == null) continue; + var ebColor = eb.Color ?? "#333"; + var capW = Math.Max(2, barW * 0.3); + double errAmount = eb.Value; + if (eb.ValueType is "stdDev" or "stdErr") + { + var vals = series[s].values; + var mean = vals.Average(); + var variance = vals.Sum(v => (v - mean) * (v - mean)) / vals.Length; + var stddev = Math.Sqrt(variance); + errAmount = eb.ValueType == "stdErr" ? stddev / Math.Sqrt(vals.Length) : stddev; + } + for (int c = 0; c < catCount; c++) + { + var rawVal = c < series[s].values.Length ? series[s].values[c] : 0; + var bx = ox + c * groupW + gap + s * barW + barW / 2; + var byTop = oy + ph - (rawVal / niceMax) * ph; + double plusErr = eb.ValueType == "percentage" ? Math.Abs(rawVal) * eb.Value / 100.0 : errAmount; + double minusErr = plusErr; + var showPlus = eb.BarType is "both" or "plus"; + var showMinus = eb.BarType is "both" or "minus"; + var yTop = showPlus ? oy + ph - ((rawVal + plusErr) / niceMax) * ph : byTop; + var yBot = showMinus ? oy + ph - ((rawVal - minusErr) / niceMax) * ph : byTop; + sb.AppendLine($" "); + if (showPlus) + sb.AppendLine($" "); + if (showMinus) + sb.AppendLine($" "); + } + } + } + for (int c = 0; c < catCount; c++) + { + var label = c < categories.Length ? categories[c] : ""; + var lx = ox + c * groupW + groupW / 2; + sb.AppendLine($" {HtmlEncode(label)}"); + } + for (int t = 0; t <= nTicks; t++) + { + var val = tickStep * t; + var label = percentStacked ? $"{(int)val}%" : FormatAxisValue(val, valNumFmt); + var ty = oy + ph - (double)ph * t / nTicks; + sb.AppendLine($" {label}"); + } + // Reference-line overlays: vertical bars/columns → horizontal line at value position on the Y (value) axis. + if (referenceLines != null) + foreach (var rl in referenceLines) + { + var v = percentStacked ? rl.Value * 100 : rl.Value; + if (v < 0 || v > niceMax) continue; + var ry = oy + ph - (v / niceMax) * ph; + var strokeColor = rl.Color.StartsWith("#") ? rl.Color : "#" + rl.Color; + var dashArray = RefLineDashArray(rl.Dash); + sb.AppendLine($" "); + } + } + } + + public void RenderLineChartSvg(StringBuilder sb, List<(string name, double[] values)> series, + string[] categories, List colors, int ox, int oy, int pw, int ph, + bool showDataLabels = false, List? markerShapes = null, List? markerSizes = null, + double? logBase = null, bool isReversed = false, + bool hasDropLines = false, bool hasHighLowLines = false, bool hasUpDownBars = false, + string? upBarColor = null, string? downBarColor = null, + double? axisMin = null, double? axisMax = null, double? majorUnit = null, string? valNumFmt = null, + List<(string Name, double Value, string Color, double WidthPt, string Dash)>? referenceLines = null, + List? smooth = null, List? lineDashes = null, List? lineWidths = null, + string? dropLineColor = null, double dropLineWidth = 0.7, string? dropLineDash = null, + string? highLowLineColor = null, double highLowLineWidth = 1, + List? trendlines = null, List? errorBars = null) + { + var allValues = series.SelectMany(s => s.values).ToArray(); + if (allValues.Length == 0) return; + var dataMax = allValues.Max(); + var dataMin = allValues.Where(v => v > 0).DefaultIfEmpty(1).Min(); + if (dataMax <= 0) dataMax = 1; + var catCount = Math.Max(categories.Length, series.Max(s => s.values.Length)); + + bool isLog = logBase.HasValue && logBase.Value > 1; + + // Compute axis scale + double niceMax, niceMin, tickStep; + int nTicks; + if (isLog) + { + var logB = logBase!.Value; + niceMin = Math.Floor(Math.Log(dataMin) / Math.Log(logB)); + niceMax = Math.Ceiling(Math.Log(dataMax) / Math.Log(logB)); + if (niceMin >= niceMax) niceMax = niceMin + 1; + nTicks = (int)(niceMax - niceMin); + tickStep = 1; + } + else + { + var computeMax = axisMax ?? dataMax; + (niceMax, tickStep, nTicks) = ComputeNiceAxis(computeMax); + if (axisMax.HasValue) niceMax = axisMax.Value; + niceMin = axisMin ?? 0; + if (majorUnit.HasValue && majorUnit.Value > 0) + { + tickStep = majorUnit.Value; + nTicks = (int)Math.Ceiling((niceMax - niceMin) / tickStep); + } + } + + // Value-to-Y mapping + double MapY(double val) + { + double ratio; + if (isLog) + { + var logB = logBase!.Value; + var logVal = val > 0 ? Math.Log(val) / Math.Log(logB) : niceMin; + ratio = (logVal - niceMin) / (niceMax - niceMin); + } + else + { + ratio = (niceMax - niceMin) > 0 ? (val - niceMin) / (niceMax - niceMin) : 0; + } + ratio = Math.Max(0, Math.Min(1, ratio)); + return isReversed ? oy + ratio * ph : oy + ph - ratio * ph; + } + + // Gridlines + for (int t = 1; t <= nTicks; t++) + { + double tickVal = isLog ? niceMin + t : niceMin + tickStep * t; + var gy = MapY(isLog ? Math.Pow(logBase!.Value, tickVal) : tickVal); + sb.AppendLine($" "); + } + sb.AppendLine($" "); + sb.AppendLine($" "); + + // Compute all point coordinates first (needed for high-low/up-down) + var allPoints = new List>(); + for (int s = 0; s < series.Count; s++) + { + var pts = new List<(double x, double y, double val)>(); + for (int c = 0; c < series[s].values.Length && c < catCount; c++) + { + var px = ox + (catCount > 1 ? (double)pw * c / (catCount - 1) : pw / 2.0); + var py = MapY(series[s].values[c]); + pts.Add((px, py, series[s].values[c])); + } + allPoints.Add(pts); + } + + // High-low lines (vertical line from highest to lowest value at each category) + if (hasHighLowLines && series.Count >= 2) + { + for (int c = 0; c < catCount; c++) + { + var yVals = allPoints.Where(p => c < p.Count).Select(p => p[c].y).ToArray(); + if (yVals.Length >= 2) + { + var px = allPoints[0][c].x; + var hlColor = highLowLineColor ?? "#666"; + sb.AppendLine($" "); + } + } + } + + // Up-down bars (between first and last series at each category) + if (hasUpDownBars && series.Count >= 2) + { + var barW = Math.Max(4, pw / catCount * 0.4); + for (int c = 0; c < catCount; c++) + { + if (c >= allPoints[0].Count || c >= allPoints[^1].Count) continue; + var first = allPoints[0][c]; + var last = allPoints[^1][c]; + var isUp = first.val <= last.val; + var color = isUp ? (upBarColor ?? "4CAF50") : (downBarColor ?? "F44336"); + if (!color.StartsWith("#")) color = "#" + color; + var topY = Math.Min(first.y, last.y); + var botY = Math.Max(first.y, last.y); + var h = Math.Max(1, botY - topY); + sb.AppendLine($" "); + } + } + + // Draw lines and markers + for (int s = 0; s < series.Count; s++) + { + var pts = allPoints[s]; + if (pts.Count == 0) continue; + var lineColor = colors[s % colors.Count]; + var isSmooth = smooth != null && s < smooth.Count && smooth[s]; + var dashName = lineDashes != null && s < lineDashes.Count ? lineDashes[s] : "solid"; + var dashAttr = dashName != "solid" ? $" stroke-dasharray=\"{RefLineDashArray(dashName)}\"" : ""; + var lw = lineWidths != null && s < lineWidths.Count ? lineWidths[s] : 2; + + if (isSmooth && pts.Count >= 2) + { + // Catmull-Rom to cubic Bezier smooth path + var d = new StringBuilder(); + d.Append($"M{pts[0].x:0.#},{pts[0].y:0.#}"); + for (int i = 0; i < pts.Count - 1; i++) + { + var p0 = i > 0 ? pts[i - 1] : pts[i]; + var p1 = pts[i]; + var p2 = pts[i + 1]; + var p3 = i + 2 < pts.Count ? pts[i + 2] : pts[i + 1]; + var cp1x = p1.x + (p2.x - p0.x) / 6.0; + var cp1y = p1.y + (p2.y - p0.y) / 6.0; + var cp2x = p2.x - (p3.x - p1.x) / 6.0; + var cp2y = p2.y - (p3.y - p1.y) / 6.0; + d.Append($" C{cp1x:0.#},{cp1y:0.#} {cp2x:0.#},{cp2y:0.#} {p2.x:0.#},{p2.y:0.#}"); + } + sb.AppendLine($" "); + } + else + { + var pointStr = string.Join(" ", pts.Select(p => $"{p.x:0.#},{p.y:0.#}")); + sb.AppendLine($" "); + } + + // Drop lines (vertical from each data point down to X axis) + if (hasDropLines) + { + var baseY = isReversed ? oy : oy + ph; + var dlColor = dropLineColor ?? "#888"; + var dlDash = dropLineDash != null ? RefLineDashArray(dropLineDash) : "3,2"; + foreach (var pt in pts) + sb.AppendLine($" "); + } + + var shape = markerShapes != null && s < markerShapes.Count ? markerShapes[s] : "circle"; + var mSize = markerSizes != null && s < markerSizes.Count ? markerSizes[s] * 0.6 : 3; + for (int p = 0; p < pts.Count; p++) + { + sb.AppendLine($" {RenderMarkerSvg(shape, pts[p].x, pts[p].y, mSize, lineColor)}"); + if (showDataLabels) + { + var val = pts[p].val; + var vlabel = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"; + sb.AppendLine($" {vlabel}"); + } + } + } + + // Error bars + if (errorBars != null) + { + for (int s = 0; s < series.Count; s++) + { + var eb = s < errorBars.Count ? errorBars[s] : null; + if (eb == null) continue; + var pts = allPoints[s]; + var ebColor = eb.Color ?? "#666"; + var capW = 4.0; // half-width of the cap line + + // Compute error amount per point + double errAmount = eb.Value; + if (eb.ValueType is "stdDev" or "stdErr") + { + var vals = series[s].values; + var mean = vals.Average(); + var variance = vals.Sum(v => (v - mean) * (v - mean)) / vals.Length; + var stddev = Math.Sqrt(variance); + errAmount = eb.ValueType == "stdErr" ? stddev / Math.Sqrt(vals.Length) : stddev; + } + + for (int p = 0; p < pts.Count; p++) + { + var val = pts[p].val; + double plusErr, minusErr; + if (eb.ValueType == "percentage") + { + plusErr = minusErr = Math.Abs(val) * eb.Value / 100.0; + } + else + { + plusErr = minusErr = errAmount; + } + + var showPlus = eb.BarType is "both" or "plus"; + var showMinus = eb.BarType is "both" or "minus"; + + var yTop = showPlus ? MapY(val + plusErr) : pts[p].y; + var yBot = showMinus ? MapY(val - minusErr) : pts[p].y; + + // Vertical line + sb.AppendLine($" "); + // Top cap + if (showPlus) + sb.AppendLine($" "); + // Bottom cap + if (showMinus) + sb.AppendLine($" "); + } + } + } + + // Trendlines + if (trendlines != null) + { + for (int s = 0; s < series.Count; s++) + { + var tl = s < trendlines.Count ? trendlines[s] : null; + if (tl == null) continue; + var pts = allPoints[s]; + if (pts.Count < 2) continue; + var lineColor = tl.Color ?? colors[s % colors.Count]; + var dashArr = tl.Dash != "solid" ? $" stroke-dasharray=\"{RefLineDashArray(tl.Dash)}\"" : ""; + + // Build x/y data arrays (using category indices as x, values as y) + var xData = new double[pts.Count]; + var yData = new double[pts.Count]; + for (int i = 0; i < pts.Count; i++) + { + xData[i] = i + 1; // 1-based like Excel + yData[i] = series[s].values[i]; + } + + // Compute trendline function + Func? trendFn = null; + string? eqText = null; + double rSquared = 0; + + switch (tl.Type) + { + case "linear": + { + var (slope, intercept) = FitLinear(xData, yData); + trendFn = x => slope * x + intercept; + eqText = $"y = {slope:0.####}x {(intercept >= 0 ? "+" : "−")} {Math.Abs(intercept):0.####}"; + rSquared = ComputeRSquared(xData, yData, trendFn); + break; + } + case "exp": + { + var (a, b) = FitExponential(xData, yData); + if (!double.IsNaN(a)) + { + trendFn = x => a * Math.Exp(b * x); + eqText = $"y = {a:0.####}e^({b:0.####}x)"; + rSquared = ComputeRSquared(xData, yData, trendFn); + } + break; + } + case "log": + { + var (a, b) = FitLogarithmic(xData, yData); + if (!double.IsNaN(a)) + { + trendFn = x => a * Math.Log(x) + b; + eqText = $"y = {a:0.####}ln(x) {(b >= 0 ? "+" : "−")} {Math.Abs(b):0.####}"; + rSquared = ComputeRSquared(xData, yData, trendFn); + } + break; + } + case "poly": + { + var coeffs = FitPolynomial(xData, yData, tl.Order); + if (coeffs != null) + { + trendFn = x => + { + double result = 0; + for (int i = 0; i < coeffs.Length; i++) + result += coeffs[i] * Math.Pow(x, i); + return result; + }; + var eqParts = new List(); + for (int i = coeffs.Length - 1; i >= 0; i--) + { + if (i == 0) eqParts.Add($"{coeffs[i]:0.####}"); + else if (i == 1) eqParts.Add($"{coeffs[i]:0.####}x"); + else eqParts.Add($"{coeffs[i]:0.####}x^{i}"); + } + eqText = "y = " + string.Join(" + ", eqParts).Replace("+ -", "− "); + rSquared = ComputeRSquared(xData, yData, trendFn); + } + break; + } + case "power": + { + var (a, b) = FitPower(xData, yData); + if (!double.IsNaN(a)) + { + trendFn = x => a * Math.Pow(x, b); + eqText = $"y = {a:0.####}x^{b:0.####}"; + rSquared = ComputeRSquared(xData, yData, trendFn); + } + break; + } + case "movingAvg": + { + // Moving average: render as polyline of averaged points + var period = Math.Max(2, tl.Period); + var maPoints = new List<(double x, double y)>(); + for (int i = period - 1; i < xData.Length; i++) + { + double sum = 0; + for (int j = 0; j < period; j++) sum += yData[i - j]; + var avgVal = sum / period; + var px = ox + (catCount > 1 ? (double)pw * i / (catCount - 1) : pw / 2.0); + var py = MapY(avgVal); + maPoints.Add((px, py)); + } + if (maPoints.Count >= 2) + { + var maPath = string.Join(" ", maPoints.Select(p => $"{p.x:0.#},{p.y:0.#}")); + sb.AppendLine($" "); + } + continue; // no equation/R² for moving average + } + } + + if (trendFn == null) continue; + + // Render trendline curve + var xMin = xData[0] - tl.Backward; + var xMax = xData[^1] + tl.Forward; + var steps = 50; + var tlPoints = new List<(double px, double py)>(); + for (int i = 0; i <= steps; i++) + { + var x = xMin + (xMax - xMin) * i / steps; + var y = trendFn(x); + if (double.IsNaN(y) || double.IsInfinity(y)) continue; + // Map x to pixel: x is 1-based category index + var px = ox + (catCount > 1 ? pw * (x - 1) / (catCount - 1) : pw / 2.0); + var py = MapY(y); + tlPoints.Add((px, py)); + } + + if (tlPoints.Count >= 2) + { + var pathStr = string.Join(" ", tlPoints.Select(p => $"{p.px:0.#},{p.py:0.#}")); + sb.AppendLine($" "); + } + + // Equation / R² label + if (tl.DisplayEquation || tl.DisplayRSquared) + { + var labelParts = new List(); + if (tl.DisplayEquation && eqText != null) labelParts.Add(eqText); + if (tl.DisplayRSquared) labelParts.Add($"R² = {rSquared:0.####}"); + var label = string.Join(" ", labelParts); + // Position label near the end of the trendline + var labelX = tlPoints.Count > 0 ? tlPoints[^1].px - 4 : ox + pw; + var labelY = tlPoints.Count > 0 ? tlPoints[^1].py - 8 : oy + 12; + sb.AppendLine($" {HtmlEncode(label)}"); + } + } + } + + // Reference lines + if (referenceLines != null) + { + foreach (var rl in referenceLines) + { + var ry = MapY(rl.Value); + var dashArr = RefLineDashArray(rl.Dash); + sb.AppendLine($" "); + } + } + + // Category labels + for (int c = 0; c < catCount; c++) + { + var label = c < categories.Length ? categories[c] : ""; + var lx = ox + (catCount > 1 ? (double)pw * c / (catCount - 1) : pw / 2.0); + sb.AppendLine($" {HtmlEncode(label)}"); + } + + // Value axis labels + for (int t = 0; t <= nTicks; t++) + { + double tickVal; + string label; + if (isLog) + { + var exp = niceMin + t; + tickVal = Math.Pow(logBase!.Value, exp); + label = FormatAxisValue(tickVal, valNumFmt); + } + else + { + tickVal = niceMin + tickStep * t; + label = FormatAxisValue(tickVal, valNumFmt); + } + var ty = MapY(tickVal); + sb.AppendLine($" {label}"); + } + } + + 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, + bool showVal = false, bool showPercent = false) + { + var values = series.FirstOrDefault().values ?? []; + if (values.Length == 0) return; + var total = values.Sum(); + if (total <= 0) return; + + var cx = svgW / 2.0; + var cy = svgH / 2.0; + var r = Math.Min(svgW, svgH) * 0.42; + var innerR = r * holeRatio; + var startAngle = -Math.PI / 2; + + for (int i = 0; i < values.Length; i++) + { + var sliceAngle = 2 * Math.PI * values[i] / total; + var endAngle = startAngle + sliceAngle; + var color = i < colors.Count ? colors[i] : DefaultColors[i % DefaultColors.Length]; + + if (values.Length == 1 && holeRatio <= 0) + sb.AppendLine($" "); + else if (holeRatio > 0) + { + var ox1 = cx + r * Math.Cos(startAngle); var oy1 = cy + r * Math.Sin(startAngle); + var ox2 = cx + r * Math.Cos(endAngle); var oy2 = cy + r * Math.Sin(endAngle); + var ix1 = cx + innerR * Math.Cos(endAngle); var iy1 = cy + innerR * Math.Sin(endAngle); + var ix2 = cx + innerR * Math.Cos(startAngle); var iy2 = cy + innerR * Math.Sin(startAngle); + var largeArc = sliceAngle > Math.PI ? 1 : 0; + sb.AppendLine($" "); + } + else + { + var x1 = cx + r * Math.Cos(startAngle); var y1 = cy + r * Math.Sin(startAngle); + var x2 = cx + r * Math.Cos(endAngle); var y2 = cy + r * Math.Sin(endAngle); + var largeArc = sliceAngle > Math.PI ? 1 : 0; + sb.AppendLine($" "); + } + startAngle = endAngle; + } + if (showDataLabels) + { + var labelAngle = -Math.PI / 2; + var labelR = holeRatio > 0 ? r * (1 + holeRatio) / 2 : r * 0.65; + for (int i = 0; i < values.Length; i++) + { + var sliceAngle = 2 * Math.PI * values[i] / total; + var midAngle = labelAngle + sliceAngle / 2; + var lx = cx + labelR * Math.Cos(midAngle); + var ly = cy + labelR * Math.Sin(midAngle); + var pct = values[i] / total * 100; + 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}"); + labelAngle += sliceAngle; + } + } + } + + public void RenderAreaChartSvg(StringBuilder sb, List<(string name, double[] values)> series, + string[] categories, List colors, int ox, int oy, int pw, int ph, bool stacked = false) + { + if (series.Count == 0) return; + var catCount = Math.Max(categories.Length, series.Max(s => s.values.Length)); + if (catCount == 0) return; + + var cumulative = new double[series.Count, catCount]; + for (int c = 0; c < catCount; c++) + { + double runningSum = 0; + for (int s = 0; s < series.Count; s++) + { + var val = c < series[s].values.Length ? series[s].values[c] : 0; + runningSum += stacked ? val : 0; + cumulative[s, c] = stacked ? runningSum : val; + } + } + var allAreaVals = series.SelectMany(s => s.values).DefaultIfEmpty(0).ToArray(); + var maxVal = 0.0; + var minVal = 0.0; + if (stacked) { for (int c = 0; c < catCount; c++) maxVal = Math.Max(maxVal, cumulative[series.Count - 1, c]); } + else { maxVal = allAreaVals.Max(); minVal = Math.Min(0.0, allAreaVals.Min()); } + if (maxVal <= minVal) maxVal = minVal + 1; + var (niceMax, tickInterval, tickCount) = ComputeNiceAxis(Math.Abs(maxVal) > Math.Abs(minVal) ? maxVal : -minVal); + // For non-stacked charts with negative values, expand the axis to cover minVal + var niceMin = minVal < 0 ? -ComputeNiceAxis(-minVal).niceMax : 0.0; + var axisRange = niceMax - niceMin; + + // Helper: map a data value to a y-coordinate within [oy, oy+ph] + double DataToY(double v) => oy + ph - (v - niceMin) / axisRange * ph; + double ZeroY() => DataToY(0.0); + + for (int t = 1; t <= tickCount; t++) + { + var gy = oy + ph - (double)ph * t / tickCount; + sb.AppendLine($" "); + } + sb.AppendLine($" "); + sb.AppendLine($" "); + + if (stacked) + { + for (int s = series.Count - 1; s >= 0; s--) + { + var topPoints = new List(); + var bottomPoints = new List(); + for (int c = 0; c < catCount; c++) + { + var px = ox + (catCount > 1 ? (double)pw * c / (catCount - 1) : pw / 2.0); + topPoints.Add($"{px:0.#},{oy + ph - (cumulative[s, c] / niceMax) * ph:0.#}"); + var bottomVal = s > 0 ? cumulative[s - 1, c] : 0; + bottomPoints.Add($"{px:0.#},{oy + ph - (bottomVal / niceMax) * ph:0.#}"); + } + bottomPoints.Reverse(); + sb.AppendLine($" "); + } + } + else + { + var baseY = ZeroY(); + var renderOrder = Enumerable.Range(0, series.Count).OrderByDescending(s => series[s].values.DefaultIfEmpty(0).Max()).ToList(); + foreach (var s in renderOrder) + { + var topPoints = new List(); + for (int c = 0; c < catCount; c++) + { + var px = ox + (catCount > 1 ? (double)pw * c / (catCount - 1) : pw / 2.0); + var val = c < series[s].values.Length ? series[s].values[c] : 0; + topPoints.Add($"{px:0.#},{DataToY(val):0.#}"); + } + var firstX = ox + (catCount > 1 ? 0 : pw / 2.0); + var lastIdx = Math.Min(series[s].values.Length - 1, catCount - 1); + var lastX = ox + (catCount > 1 ? (double)pw * lastIdx / (catCount - 1) : pw / 2.0); + sb.AppendLine($" "); + } + } + for (int c = 0; c < catCount; c++) + { + var label = c < categories.Length ? categories[c] : ""; + var lx = ox + (catCount > 1 ? (double)pw * c / (catCount - 1) : pw / 2.0); + sb.AppendLine($" {HtmlEncode(label)}"); + } + for (int t = 0; t <= tickCount; t++) + { + var val = tickInterval * t; + var label = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"; + var ty = oy + ph - (double)ph * t / tickCount; + sb.AppendLine($" {label}"); + } + } + + public void RenderRadarChartSvg(StringBuilder sb, List<(string name, double[] values)> series, + string[] categories, List colors, int svgW, int svgH, int catLabelFontSize = 0, + string radarStyle = "filled") + { + var catCount = Math.Max(categories.Length, series.Max(s => s.values.Length)); + if (catCount < 3) return; + var allValues = series.SelectMany(s => s.values).ToArray(); + if (allValues.Length == 0) return; + var maxVal = allValues.Max(); + if (maxVal <= 0) maxVal = 1; + + var labelSize = catLabelFontSize > 0 ? catLabelFontSize : 11; + var cx = svgW / 2.0; + var cy = svgH / 2.0; + var r = Math.Min(svgW, svgH) * 0.33; + + for (int ring = 1; ring <= 5; ring++) + { + var rr = r * ring / 5; + var gridPoints = new List(); + for (int c = 0; c < catCount; c++) + { + 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($" "); + } + for (int c = 0; c < catCount; c++) + { + var angle = -Math.PI / 2 + 2 * Math.PI * c / catCount; + sb.AppendLine($" "); + } + for (int s = 0; s < series.Count; s++) + { + var points = new List(); + for (int c = 0; c < series[s].values.Length && c < catCount; c++) + { + var angle = -Math.PI / 2 + 2 * Math.PI * c / catCount; + var val = series[s].values[c] / maxVal * r; + points.Add($"{cx + val * Math.Cos(angle):0.#},{cy + val * Math.Sin(angle):0.#}"); + } + if (points.Count > 0) + { + var serColor = colors[s % colors.Count]; + var isFilled = radarStyle == "filled"; + var fillAttr = isFilled ? $"fill=\"{serColor}\" fill-opacity=\"0.2\"" : "fill=\"none\""; + sb.AppendLine($" "); + // Markers for marker and standard styles (standard gets small dots, marker gets circles) + var showMarkers = radarStyle != "filled"; + var markerR = radarStyle == "marker" ? 4 : 2; + if (showMarkers) + { + foreach (var pt in points) + { + var parts = pt.Split(','); + sb.AppendLine($" "); + } + } + } + } + foreach (var frac in new[] { 0.2, 0.4, 0.6, 0.8, 1.0 }) + { + var val = maxVal * frac; + var tickLabel = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"; + sb.AppendLine($" {tickLabel}"); + } + var labelOffset = Math.Max(18, r * 0.15); + for (int c = 0; c < catCount; c++) + { + var label = c < categories.Length ? categories[c] : ""; + var angle = -Math.PI / 2 + 2 * Math.PI * c / catCount; + var lx = cx + (r + labelOffset) * Math.Cos(angle); + var ly = cy + (r + labelOffset) * Math.Sin(angle); + var anchor = Math.Abs(Math.Cos(angle)) < 0.1 ? "middle" : (Math.Cos(angle) > 0 ? "start" : "end"); + sb.AppendLine($" {HtmlEncode(label)}"); + } + } + + public void RenderBubbleChartSvg(StringBuilder sb, PlotArea plotArea, + List<(string name, double[] values)> series, string[] categories, List colors, + int ox, int oy, int pw, int ph) + { + var bubbleSeries = plotArea.Descendants() + .Where(e => e.LocalName == "ser" && e.Parent?.LocalName == "bubbleChart").ToList(); + + var allX = new List(); var allY = new List(); var allSize = new List(); + var seriesData = new List<(double[] x, double[] y, double[] size)>(); + + for (int s = 0; s < bubbleSeries.Count; s++) + { + var ser = bubbleSeries[s]; + var xVals = ChartHelper.ReadNumericData(ser.Elements().FirstOrDefault(e => e.LocalName == "xVal")) ?? []; + var yVals = ChartHelper.ReadNumericData(ser.Elements().FirstOrDefault(e => e.LocalName == "yVal")) ?? []; + var sizeVals = ChartHelper.ReadNumericData(ser.Elements().FirstOrDefault(e => e.LocalName == "bubbleSize")) ?? yVals; + seriesData.Add((xVals, yVals, sizeVals)); + allX.AddRange(xVals); allY.AddRange(yVals); allSize.AddRange(sizeVals); + } + if (seriesData.Count == 0) + { + foreach (var s in series) + { + var xVals = Enumerable.Range(0, s.values.Length).Select(i => (double)i).ToArray(); + seriesData.Add((xVals, s.values, s.values)); + allX.AddRange(xVals); allY.AddRange(s.values); allSize.AddRange(s.values); + } + } + if (allY.Count == 0) return; + var minX = allX.Min(); var maxX = allX.Max(); if (maxX <= minX) maxX = minX + 1; + var minY = allY.Min(); var maxY = allY.Max(); if (maxY <= minY) maxY = minY + 1; + var maxSz = allSize.Count > 0 ? allSize.Max() : 1; if (maxSz <= 0) maxSz = 1; + var bubbleScaleEl = plotArea.Descendants().FirstOrDefault(); + var bubbleScale = bubbleScaleEl?.Val?.HasValue == true ? bubbleScaleEl.Val.Value / 100.0 : 1.0; + var maxRadius = Math.Min(pw, ph) * 0.12 * bubbleScale; + + for (int t = 1; t <= 4; t++) + { + var gy = oy + ph - (double)ph * t / 4; + sb.AppendLine($" "); + } + sb.AppendLine($" "); + sb.AppendLine($" "); + + for (int s = 0; s < seriesData.Count; s++) + { + var (xVals, yVals, sizeVals) = seriesData[s]; + var count = Math.Min(xVals.Length, yVals.Length); + for (int i = 0; i < count; i++) + { + var bx = ox + ((xVals[i] - minX) / (maxX - minX)) * pw; + var by = oy + ph - ((yVals[i] - minY) / (maxY - minY)) * ph; + var sz = i < sizeVals.Length ? sizeVals[i] : yVals[i]; + var r = Math.Sqrt(Math.Max(0, sz) / maxSz) * maxRadius + maxRadius * 0.15; + sb.AppendLine($" "); + } + } + for (int t = 0; t <= 4; t++) + { + var val = minX + (maxX - minX) * t / 4; + var label = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"; + sb.AppendLine($" {label}"); + } + for (int t = 0; t <= 4; t++) + { + var val = minY + (maxY - minY) * t / 4; + var label = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"; + sb.AppendLine($" {label}"); + } + } + + public void RenderComboChartSvg(StringBuilder sb, PlotArea plotArea, + List<(string name, double[] values)> seriesList, string[] categories, List colors, + int ox, int oy, int pw, int ph) + { + var barIndices = new HashSet(); + var lineIndices = new HashSet(); + var areaIndices = new HashSet(); + var secondaryIndices = new HashSet(); // series on secondary Y-axis + + // Detect which axis IDs are secondary (right-side value axis) + var secondaryAxIds = new HashSet(); + var valAxes = plotArea.Elements().ToList(); + if (valAxes.Count >= 2) + { + // The secondary value axis is the one with axPos="r" + // Use .InnerText because AxisPositionValues.ToString() is broken in Open XML SDK v3+ + foreach (var va in valAxes) + { + var posText = va.GetFirstChild()?.Val?.InnerText; + if (posText == "r") + { + var id = va.GetFirstChild()?.Val?.Value; + if (id.HasValue) secondaryAxIds.Add(id.Value); + } + } + // Fallback: if no explicit right axis found, treat 2nd valAx as secondary + if (secondaryAxIds.Count == 0 && valAxes.Count >= 2) + { + var id = valAxes[1].GetFirstChild()?.Val?.Value; + if (id.HasValue) secondaryAxIds.Add(id.Value); + } + } + + var idx = 0; + foreach (var chartEl in plotArea.ChildElements) + { + var serElements = chartEl.Descendants().Where(e => e.LocalName == "ser").ToList(); + if (serElements.Count == 0) continue; + var localName = chartEl.LocalName.ToLowerInvariant(); + var isBar = localName.Contains("bar"); + var isArea = localName.Contains("area"); + + // Check if this chart group uses a secondary axis + var axIds = chartEl.ChildElements + .Where(e => e.LocalName == "axId") + .Select(e => e.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value) + .Where(v => v != null) + .Select(v => uint.TryParse(v, out var u) ? u : 0) + .ToHashSet(); + var isSecondary = axIds.Overlaps(secondaryAxIds); + + foreach (var _ in serElements) + { + if (isBar) barIndices.Add(idx); + else if (isArea) areaIndices.Add(idx); + else lineIndices.Add(idx); + if (isSecondary) secondaryIndices.Add(idx); + idx++; + } + } + + // Separate primary and secondary values for independent axis scaling + var primaryValues = seriesList.Where((_, i) => !secondaryIndices.Contains(i)).SelectMany(s => s.values).ToArray(); + var secondaryValues = seriesList.Where((_, i) => secondaryIndices.Contains(i)).SelectMany(s => s.values).ToArray(); + if (primaryValues.Length == 0 && secondaryValues.Length == 0) return; + + var priMax = primaryValues.Length > 0 ? primaryValues.Max() : 0; if (priMax <= 0) priMax = 1; + var (priNiceMax, _, _) = ComputeNiceAxis(priMax); + var hasSecondary = secondaryValues.Length > 0; + double secNiceMax = 1; + if (hasSecondary) + { + var secMax = secondaryValues.Max(); if (secMax <= 0) secMax = 1; + (secNiceMax, _, _) = ComputeNiceAxis(secMax); + } + + var catCount = Math.Max(categories.Length, seriesList.Max(s => s.values.Length)); + + // Axes + sb.AppendLine($" "); + sb.AppendLine($" "); + + // Bar series (primary axis) + var barSeries = barIndices.Where(i => i < seriesList.Count).ToList(); + if (barSeries.Count > 0) + { + var groupW = (double)pw / Math.Max(catCount, 1); + var barW = groupW * 0.5 / barSeries.Count; + var gap = (groupW - barSeries.Count * barW) / 2; + for (int bi = 0; bi < barSeries.Count; bi++) + { + var s = barSeries[bi]; + var axMax = secondaryIndices.Contains(s) ? secNiceMax : priNiceMax; + for (int c = 0; c < seriesList[s].values.Length && c < catCount; c++) + { + var val = seriesList[s].values[c]; + var barH = (val / axMax) * ph; + sb.AppendLine($" "); + } + } + } + // Area series + foreach (var s in areaIndices.Where(i => i < seriesList.Count)) + { + var axMax = secondaryIndices.Contains(s) ? secNiceMax : priNiceMax; + var points = new List(); + for (int c = 0; c < seriesList[s].values.Length && c < catCount; c++) + { + var px = ox + (catCount > 1 ? (double)pw * c / (catCount - 1) : pw / 2.0); + points.Add($"{px:0.#},{oy + ph - (seriesList[s].values[c] / axMax) * ph:0.#}"); + } + if (points.Count > 0) + { + var firstX = ox + (catCount > 1 ? 0 : pw / 2.0); + var lastX = ox + (catCount > 1 ? (double)pw * (seriesList[s].values.Length - 1) / (catCount - 1) : pw / 2.0); + sb.AppendLine($" "); + sb.AppendLine($" "); + } + } + // Line series (may use secondary axis) + foreach (var s in lineIndices.Where(i => i < seriesList.Count)) + { + var axMax = secondaryIndices.Contains(s) ? secNiceMax : priNiceMax; + var points = new List(); + for (int c = 0; c < seriesList[s].values.Length && c < catCount; c++) + { + var px = ox + (catCount > 1 ? (double)pw * c / (catCount - 1) : pw / 2.0); + points.Add($"{px:0.#},{oy + ph - (seriesList[s].values[c] / axMax) * ph:0.#}"); + } + if (points.Count > 0) + { + sb.AppendLine($" "); + foreach (var pt in points) + { + var parts = pt.Split(','); + sb.AppendLine($" "); + } + } + } + // Category labels + for (int c = 0; c < catCount; c++) + { + var label = c < categories.Length ? categories[c] : ""; + var lx = ox + (double)pw * c / Math.Max(catCount, 1) + (double)pw / Math.Max(catCount, 1) / 2; + sb.AppendLine($" {HtmlEncode(label)}"); + } + // Primary Y-axis labels (left) + for (int t = 0; t <= AxisTickCount; t++) + { + var val = priNiceMax * t / AxisTickCount; + var label = FormatAxisValue(val); + sb.AppendLine($" {label}"); + } + // Secondary Y-axis labels (overlaid on left in lighter color) + if (hasSecondary) + { + var secFontPx = Math.Max(ValFontPx - 1, CatFontPx); + for (int t = 0; t <= AxisTickCount; t++) + { + var val = secNiceMax * t / AxisTickCount; + var label = FormatAxisValue(val); + sb.AppendLine($" {label}"); + } + } + } + + 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) + { + var allValues = series.SelectMany(s => s.values).ToArray(); + if (allValues.Length == 0) return; + var maxVal = allValues.Max(); var minVal = allValues.Min(); + if (maxVal <= minVal) maxVal = minVal + 1; + var range = maxVal - minVal; + var catCount = Math.Max(categories.Length, series.Max(s => s.values.Length)); + + var upColor = "#FFFFFF"; var downColor = "#000000"; // OOXML spec defaults + var stockChart = plotArea.GetFirstChild(); + if (stockChart != null) + { + var upFill = stockChart.Descendants().FirstOrDefault(e => e.LocalName == "upBars") + ?.Descendants().FirstOrDefault()?.GetFirstChild()?.Val?.Value; + if (upFill != null) upColor = $"#{upFill}"; + var downFill = stockChart.Descendants().FirstOrDefault(e => e.LocalName == "downBars") + ?.Descendants().FirstOrDefault()?.GetFirstChild()?.Val?.Value; + if (downFill != null) downColor = $"#{downFill}"; + } + + sb.AppendLine($" "); + sb.AppendLine($" "); + + var groupW = (double)pw / Math.Max(catCount, 1); + if (series.Count >= 4) + { + for (int c = 0; c < catCount; c++) + { + var open = c < series[0].values.Length ? series[0].values[c] : 0; + var high = c < series[1].values.Length ? series[1].values[c] : 0; + var low = c < series[2].values.Length ? series[2].values[c] : 0; + var close = c < series[3].values.Length ? series[3].values[c] : 0; + var ccx = ox + c * groupW + groupW / 2; + var yHigh = oy + ph - ((high - minVal) / range) * ph; + var yLow = oy + ph - ((low - minVal) / range) * ph; + var yOpen = oy + ph - ((open - minVal) / range) * ph; + var yClose = oy + ph - ((close - minVal) / range) * ph; + var color = close >= open ? upColor : downColor; + var barW = groupW * 0.5; + sb.AppendLine($" "); + var bodyTop = Math.Min(yOpen, yClose); var bodyH = Math.Max(Math.Abs(yOpen - yClose), 1); + sb.AppendLine($" "); + } + } + else { RenderLineChartSvg(sb, series, categories, colors, ox, oy, pw, ph); return; } + + for (int c = 0; c < catCount; c++) + { + var label = c < categories.Length ? categories[c] : ""; + sb.AppendLine($" {HtmlEncode(label)}"); + } + for (int t = 0; t <= 4; t++) + { + var val = minVal + range * t / 4; + var label = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"; + sb.AppendLine($" {label}"); + } + } + + public static (double niceMax, double tickStep, int nTicks) ComputeNiceAxis(double maxVal) + { + if (maxVal <= 0) maxVal = 1; + // Guard against subnormal/denormal values where Log10 returns -Infinity + if (!double.IsFinite(maxVal) || maxVal < 1e-10) maxVal = 1; + var mag = Math.Pow(10, Math.Floor(Math.Log10(maxVal))); + if (!double.IsFinite(mag) || mag == 0) mag = 1; + var res = maxVal / mag; + var tickStep = res <= 1.5 ? 0.2 * mag : res <= 4 ? 0.5 * mag : res <= 8 ? 1.0 * mag : 2.0 * mag; + var niceMax = Math.Ceiling(maxVal / tickStep) * tickStep; + if (niceMax < maxVal * 1.05) niceMax += tickStep; + var nTicks = (int)Math.Round(niceMax / tickStep); + if (nTicks < 2) nTicks = 2; + return (niceMax, tickStep, nTicks); + } + + // ==================== Shared Chart Info & Rendering ==================== + + /// All metadata extracted from an OOXML chart, used by the shared rendering pipeline. + public class ChartInfo + { + /// Original PlotArea element, needed by combo/bubble/stock renderers. + public PlotArea? PlotArea { get; set; } + public string ChartType { get; set; } = "column"; + public string[] Categories { get; set; } = []; + public List<(string name, double[] values)> Series { get; set; } = []; + public List Colors { get; set; } = []; + 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; } + public bool IsWaterfall { get; set; } + public bool Is3D { get; set; } + public int RotateX { get; set; } + public int RotateY { get; set; } + public int Perspective { get; set; } + public double? AxisMax { get; set; } + public double? AxisMin { get; set; } + 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; } + /// #7f: OOXML c:legendPos InnerText — "b" (bottom, default), + /// "t" (top), "r" (right), "l" (left), "tr" (top-right). Rendering + /// adapts the wrapper layout to each position. + public string LegendPos { get; set; } = "b"; + 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; + /// Reference-line overlays (horizontal dashed lines at constant values). + /// Filled by ExtractChartInfo from any ref-line-only LineChart in the plot area. + public List<(string Name, double Value, string Color, double WidthPt, string Dash)> ReferenceLines { get; set; } = []; + + // --- Marker shapes per series (circle, diamond, square, triangle, star, x, plus, dash, dot, none) --- + public List MarkerShapes { get; set; } = []; + public List MarkerSizes { get; set; } = []; + + // --- Smooth line (cubic spline) per series --- + public List Smooth { get; set; } = []; + + // --- Dash pattern per series (solid, dash, dot, dashDot, lgDash, etc.) --- + public List LineDashes { get; set; } = []; + + // --- Line width per series (in points, from a:ln w="...") --- + public List LineWidths { get; set; } = []; + + // --- Axis features --- + public double? LogBase { get; set; } + public bool IsReversed { get; set; } + + // --- Line elements --- + public bool HasDropLines { get; set; } + public string? DropLineColor { get; set; } + public double DropLineWidth { get; set; } = 0.7; + public string? DropLineDash { get; set; } + public bool HasHighLowLines { get; set; } + public string? HighLowLineColor { get; set; } + public double HighLowLineWidth { get; set; } = 1; + public bool HasUpDownBars { get; set; } + public string? UpBarColor { get; set; } + public string? DownBarColor { get; set; } + + // --- Data table --- + public bool HasDataTable { get; set; } + + // --- Radar style (standard, marker, filled) --- + public string RadarStyle { get; set; } = "filled"; + + // --- Trendlines per series --- + public List Trendlines { get; set; } = []; + + // --- Error bars per series --- + public List ErrorBars { get; set; } = []; + } + + /// Trendline metadata extracted from OOXML for SVG rendering. + public class TrendlineInfo + { + public string Type { get; set; } = "linear"; // linear, exp, log, poly, power, movingAvg + public int Order { get; set; } = 2; // polynomial order + public int Period { get; set; } = 2; // moving average period + public double Forward { get; set; } // forward extrapolation + public double Backward { get; set; } // backward extrapolation + public double? Intercept { get; set; } + public bool DisplayEquation { get; set; } + public bool DisplayRSquared { get; set; } + public string? Color { get; set; } + public double Width { get; set; } = 1.5; + public string Dash { get; set; } = "dash"; + } + + /// Error bar metadata extracted from OOXML for SVG rendering. + public class ErrorBarInfo + { + public string ValueType { get; set; } = "fixedValue"; // fixedValue, percentage, stdDev, stdErr + public string Direction { get; set; } = "y"; // x, y + public string BarType { get; set; } = "both"; // both, plus, minus + public double Value { get; set; } = 1; // the error amount + public string? Color { get; set; } + public double Width { get; set; } = 1; + } + + /// + /// Remove reference-line overlay series from a data series list, matching the + /// OOXML series iteration order. Callers that override + /// with locally-resolved data (e.g. ExcelHandler cell-ref resolution) must re-apply + /// this filter or the ref-line series will be double-rendered as a bar/line segment. + /// + public static List<(string name, double[] values)> FilterReferenceLineSeries( + OpenXmlElement? plotArea, + List<(string name, double[] values)> series) + { + if (plotArea is not PlotArea pa || series.Count == 0) return series; + var mask = ChartHelper.ReadReferenceLineMask(pa); + if (!mask.Any(m => m)) return series; + return series.Where((_, i) => i >= mask.Count || !mask[i]).ToList(); + } + + /// Extract all chart metadata from OOXML PlotArea and Chart elements. + public static ChartInfo ExtractChartInfo(OpenXmlElement plotArea, OpenXmlElement? chart) + { + var info = new ChartInfo(); + info.PlotArea = plotArea as PlotArea; + if (info.PlotArea == null) return info; + + // Chart type, categories, series + info.ChartType = ChartHelper.DetectChartType(info.PlotArea) ?? "column"; + info.Categories = ChartHelper.ReadCategories(info.PlotArea) ?? []; + info.Series = ChartHelper.ReadAllSeries(info.PlotArea); + info.ReferenceLines = ChartHelper.ReadReferenceLines(info.PlotArea); + + // Filter reference-line series out of the renderer's data series list. They + // are drawn as overlays via info.ReferenceLines so they must not contribute to + // axis scale, stacking, colors, or legend. ReadAllSeries itself stays inclusive + // so the user-facing Get()/Query() path continues to surface ref-line series. + info.Series = FilterReferenceLineSeries(info.PlotArea, info.Series); + + if (info.Series.Count == 0 && info.ReferenceLines.Count == 0) return info; + + info.Is3D = info.ChartType.Contains("3d"); + info.IsWaterfall = info.ChartType == "waterfall"; + info.IsStacked = info.ChartType.Contains("stacked") || info.ChartType.Contains("Stacked") || info.IsWaterfall; + info.IsPercent = info.ChartType.Contains("percent") || info.ChartType.Contains("Percent"); + + // View3D parameters + if (chart != null) + { + var view3dEl = chart.Elements().FirstOrDefault(e => e.LocalName == "view3D"); + if (view3dEl != null) + { + var rotXEl = view3dEl.Elements().FirstOrDefault(e => e.LocalName == "rotX"); + var rotYEl = view3dEl.Elements().FirstOrDefault(e => e.LocalName == "rotY"); + var perspEl = view3dEl.Elements().FirstOrDefault(e => e.LocalName == "perspective"); + if (rotXEl != null && int.TryParse(rotXEl.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value, out var rx)) info.RotateX = rx; + if (rotYEl != null && int.TryParse(rotYEl.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value, out var ry)) info.RotateY = ry; + if (perspEl != null && int.TryParse(perspEl.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value, out var pv)) info.Perspective = pv; + } + } + + // Locate chart type element (barChart, lineChart, pieChart, etc.) + var chartTypeEl = plotArea.Elements().FirstOrDefault(e => + e.LocalName is "barChart" or "bar3DChart" or "lineChart" or "line3DChart" + or "pieChart" or "pie3DChart" or "doughnutChart" or "areaChart" or "area3DChart" + or "scatterChart" or "radarChart" or "bubbleChart" or "ofPieChart" + or "stockChart"); + + // Colors + var isPieType = info.ChartType.Contains("pie") || info.ChartType.Contains("doughnut"); + var serElements = chartTypeEl?.Elements().Where(e => e.LocalName == "ser").ToList() ?? []; + info.Colors = ExtractColors(serElements, info.Series, isPieType, info.ChartType); + + // Title + var titleEl = chart?.Elements().FirstOrDefault(e => e.LocalName == "title"); + if (titleEl != null) + { + var titleRuns = titleEl.Descendants() + .Select(r => r.GetFirstChild()?.Text) + .Where(t => t != null); + info.Title = string.Join("", titleRuns); + 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 + var dLbls = chartTypeEl?.Elements().FirstOrDefault(e => e.LocalName == "dLbls") + ?? plotArea.Descendants().FirstOrDefault(e => e.LocalName == "dLbls"); + if (dLbls != null) + { + 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 + if (info.ChartType.Contains("doughnut")) + { + 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 : 10) / 100.0; // OOXML spec default: 10% + } + + // Axis info + var valAxis = plotArea.Elements().FirstOrDefault(e => e.LocalName == "valAx"); + var catAxis = plotArea.Elements().FirstOrDefault(e => e.LocalName == "catAx"); + + if (valAxis != null) + { + 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) + { + var maxEl = scaling.Elements().FirstOrDefault(e => e.LocalName == "max"); + var minEl = scaling.Elements().FirstOrDefault(e => e.LocalName == "min"); + if (maxEl != null && double.TryParse(maxEl.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value, out var maxV)) + info.AxisMax = maxV; + if (minEl != null && double.TryParse(minEl.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value, out var minV)) + info.AxisMin = minV; + } + var majorUnit = valAxis.Elements().FirstOrDefault(e => e.LocalName == "majorUnit"); + if (majorUnit != null && double.TryParse(majorUnit.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value, out var mu)) + info.MajorUnit = mu; + + // Log scale + var logBaseEl = scaling?.Elements().FirstOrDefault(e => e.LocalName == "logBase"); + if (logBaseEl != null && double.TryParse(logBaseEl.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value, out var lb)) + info.LogBase = lb; + + // Axis orientation (reversed) + var orientEl = scaling?.Elements().FirstOrDefault(e => e.LocalName == "orientation"); + var orientVal = orientEl?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value; + info.IsReversed = orientVal == "maxMin"; + + // 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) + { + 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 + var gapWidthEl = plotArea.Descendants().FirstOrDefault(e => e.LocalName == "gapWidth"); + if (gapWidthEl != null) + { + var gv = gapWidthEl.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value; + if (gv != null && int.TryParse(gv, out var gw)) info.GapWidth = gw; + } + + // Plot / chart fill + var plotSpPr = plotArea.Elements().FirstOrDefault(e => e.LocalName == "spPr"); + info.PlotFillColor = ExtractFillColor(plotSpPr); + var chartSpPr = chart?.Parent?.Elements().FirstOrDefault(e => e.LocalName == "spPr"); + info.ChartFillColor = ExtractFillColor(chartSpPr); + + // Legend + var legendEl = chart?.Elements().FirstOrDefault(e => e.LocalName == "legend"); + if (legendEl != null) + { + var deleteEl = legendEl.Elements().FirstOrDefault(e => e.LocalName == "delete"); + var delVal = deleteEl?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value; + info.HasLegend = delVal != "1"; + 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); + // #7f: honor . + var posEl = legendEl.Elements().FirstOrDefault(e => e.LocalName == "legendPos"); + var posVal = posEl?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value; + if (!string.IsNullOrEmpty(posVal)) info.LegendPos = posVal!; + } + else + { + info.HasLegend = info.Series.Count > 1 || isPieType || info.ReferenceLines.Count > 0; + } + + // Marker shapes, smooth, and dash per series + if (chartTypeEl != null) + { + // Chart-level smooth (lineChart > smooth val="1") + var chartSmooth = chartTypeEl.Elements().FirstOrDefault(e => e.LocalName == "smooth"); + var chartSmoothVal = chartSmooth?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value; + var chartIsSmooth = chartSmoothVal == "1" || chartSmoothVal == "true"; + + foreach (var ser in serElements) + { + var marker = ser.Elements().FirstOrDefault(e => e.LocalName == "marker"); + var symbol = marker?.Elements().FirstOrDefault(e => e.LocalName == "symbol"); + var symbolVal = symbol?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value ?? "circle"; + info.MarkerShapes.Add(symbolVal); + var sizeEl = marker?.Elements().FirstOrDefault(e => e.LocalName == "size"); + var sizeVal = sizeEl?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value; + info.MarkerSizes.Add(sizeVal != null && int.TryParse(sizeVal, out var ms) ? ms : 5); + + // Per-series smooth (overrides chart-level) + var serSmooth = ser.Elements().FirstOrDefault(e => e.LocalName == "smooth"); + var serSmoothVal = serSmooth?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value; + info.Smooth.Add(serSmooth != null + ? (serSmoothVal == "1" || serSmoothVal == "true") + : chartIsSmooth); + + // Per-series dash pattern and line width + var spPr = ser.Elements().FirstOrDefault(e => e.LocalName == "spPr"); + var ln = spPr?.Elements().FirstOrDefault(e => e.LocalName == "ln"); + var prstDash = ln?.Elements().FirstOrDefault(e => e.LocalName == "prstDash"); + var dashVal = prstDash?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value; + info.LineDashes.Add(dashVal ?? "solid"); + + // Per-series line width (a:ln w="..." in EMU, convert to pt: 1pt = 12700 EMU) + var lnWidth = ln?.GetAttributes().FirstOrDefault(a => a.LocalName == "w").Value; + info.LineWidths.Add(lnWidth != null && int.TryParse(lnWidth, out var lw) ? Math.Round(lw / 12700.0, 1) : 2); + + // Per-series trendline + var trendlineEl = ser.Elements().FirstOrDefault(e => e.LocalName == "trendline"); + if (trendlineEl != null) + { + var tlInfo = new TrendlineInfo(); + var tlType = trendlineEl.Elements().FirstOrDefault(e => e.LocalName == "trendlineType"); + tlInfo.Type = tlType?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value ?? "linear"; + var polyOrder = trendlineEl.Elements().FirstOrDefault(e => e.LocalName == "order"); + if (polyOrder != null && int.TryParse(polyOrder.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value, out var po)) + tlInfo.Order = po; + var period = trendlineEl.Elements().FirstOrDefault(e => e.LocalName == "period"); + if (period != null && int.TryParse(period.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value, out var per)) + tlInfo.Period = per; + var fwd = trendlineEl.Elements().FirstOrDefault(e => e.LocalName == "forward"); + if (fwd != null && double.TryParse(fwd.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value, + System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var fv)) + tlInfo.Forward = fv; + var bwd = trendlineEl.Elements().FirstOrDefault(e => e.LocalName == "backward"); + if (bwd != null && double.TryParse(bwd.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value, + System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var bv)) + tlInfo.Backward = bv; + var intercept = trendlineEl.Elements().FirstOrDefault(e => e.LocalName == "intercept"); + if (intercept != null && double.TryParse(intercept.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value, + System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var iv)) + tlInfo.Intercept = iv; + var dispEq = trendlineEl.Elements().FirstOrDefault(e => e.LocalName == "dispEq"); + tlInfo.DisplayEquation = dispEq?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value == "1"; + var dispRSqr = trendlineEl.Elements().FirstOrDefault(e => e.LocalName == "dispRSqr"); + tlInfo.DisplayRSquared = dispRSqr?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value == "1"; + // Trendline styling + var tlSpPr = trendlineEl.Elements().FirstOrDefault(e => e.LocalName == "spPr"); + var tlLn = tlSpPr?.Elements().FirstOrDefault(e => e.LocalName == "ln"); + tlInfo.Color = ExtractLineColor(tlSpPr); + if (tlLn?.GetAttributes().FirstOrDefault(a => a.LocalName == "w").Value is string tlw + && int.TryParse(tlw, out var tlwPt)) + tlInfo.Width = Math.Round(tlwPt / 12700.0, 1); + var tlDash = tlLn?.Elements().FirstOrDefault(e => e.LocalName == "prstDash"); + tlInfo.Dash = tlDash?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value ?? "dash"; + info.Trendlines.Add(tlInfo); + } + else + info.Trendlines.Add(null); + + // Per-series error bars + var errBarsEl = ser.Elements().FirstOrDefault(e => e.LocalName == "errBars"); + if (errBarsEl != null) + { + var ebInfo = new ErrorBarInfo(); + var ebType = errBarsEl.Elements().FirstOrDefault(e => e.LocalName == "errValType"); + ebInfo.ValueType = ebType?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value ?? "fixedValue"; + var ebDir = errBarsEl.Elements().FirstOrDefault(e => e.LocalName == "errDir"); + ebInfo.Direction = ebDir?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value ?? "y"; + var ebBarType = errBarsEl.Elements().FirstOrDefault(e => e.LocalName == "errBarType"); + ebInfo.BarType = ebBarType?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value ?? "both"; + // Read error value from Plus/Minus > NumLit > NumericPoint > v + var plusEl = errBarsEl.Elements().FirstOrDefault(e => e.LocalName == "plus"); + var numPt = plusEl?.Descendants().FirstOrDefault(e => e.LocalName == "v"); + if (numPt != null && double.TryParse(numPt.InnerText, + System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var ebVal)) + ebInfo.Value = ebVal; + // Error bar styling + var ebSpPr = errBarsEl.Elements().FirstOrDefault(e => e.LocalName == "spPr"); + ebInfo.Color = ExtractLineColor(ebSpPr); + var ebLn = ebSpPr?.Elements().FirstOrDefault(e => e.LocalName == "ln"); + if (ebLn?.GetAttributes().FirstOrDefault(a => a.LocalName == "w").Value is string ebw + && int.TryParse(ebw, out var ebwPt)) + ebInfo.Width = Math.Round(ebwPt / 12700.0, 1); + info.ErrorBars.Add(ebInfo); + } + else + info.ErrorBars.Add(null); + } + + // Line elements: dropLines, hiLowLines, upDownBars + var dropLinesEl = chartTypeEl.Elements().FirstOrDefault(e => e.LocalName == "dropLines"); + info.HasDropLines = dropLinesEl != null; + if (dropLinesEl != null) + { + var dlSpPr = dropLinesEl.Elements().FirstOrDefault(e => e.LocalName == "spPr"); + var dlLn = dlSpPr?.Elements().FirstOrDefault(e => e.LocalName == "ln"); + info.DropLineColor = ExtractLineColor(dlSpPr); + if (dlLn?.GetAttributes().FirstOrDefault(a => a.LocalName == "w").Value is string dlw + && int.TryParse(dlw, out var dlwPt)) + info.DropLineWidth = Math.Round(dlwPt / 12700.0, 1); + var dlDash = dlLn?.Elements().FirstOrDefault(e => e.LocalName == "prstDash"); + info.DropLineDash = dlDash?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value; + } + var hiLowEl = chartTypeEl.Elements().FirstOrDefault(e => e.LocalName == "hiLowLines"); + info.HasHighLowLines = hiLowEl != null; + if (hiLowEl != null) + { + var hlSpPr = hiLowEl.Elements().FirstOrDefault(e => e.LocalName == "spPr"); + var hlLn = hlSpPr?.Elements().FirstOrDefault(e => e.LocalName == "ln"); + info.HighLowLineColor = ExtractLineColor(hlSpPr); + if (hlLn?.GetAttributes().FirstOrDefault(a => a.LocalName == "w").Value is string hlw + && int.TryParse(hlw, out var hlwPt)) + info.HighLowLineWidth = Math.Round(hlwPt / 12700.0, 1); + } + var upDownBars = chartTypeEl.Elements().FirstOrDefault(e => e.LocalName == "upDownBars"); + info.HasUpDownBars = upDownBars != null; + if (upDownBars != null) + { + var upSpPr = upDownBars.Elements().FirstOrDefault(e => e.LocalName == "upBars") + ?.Elements().FirstOrDefault(e => e.LocalName == "spPr"); + var dnSpPr = upDownBars.Elements().FirstOrDefault(e => e.LocalName == "downBars") + ?.Elements().FirstOrDefault(e => e.LocalName == "spPr"); + info.UpBarColor = ExtractFillColor(upSpPr) ?? "4CAF50"; + info.DownBarColor = ExtractFillColor(dnSpPr) ?? "F44336"; + } + } + + // Data table + var dataTableEl = chart?.Descendants().FirstOrDefault(e => e.LocalName == "dTable"); + info.HasDataTable = dataTableEl != null; + + // Radar style + var radarChartEl = plotArea.Elements().FirstOrDefault(e => e.LocalName == "radarChart"); + if (radarChartEl != null) + { + var rsEl = radarChartEl.Elements().FirstOrDefault(e => e.LocalName == "radarStyle"); + var rsVal = rsEl?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value; + info.RadarStyle = rsVal ?? "marker"; + } + + return info; + } + + /// Extract series colors (per-point for pie/doughnut, stroke for line/scatter, fill for others). + private static List ExtractColors(List serElements, List<(string name, double[] values)> series, + bool isPieType, string chartType) + { + var colors = new List(); + + if (isPieType && serElements.Count > 0) + { + // Pie/doughnut: colors are per data point (dPt), not per series + var ser = serElements[0]; + var dPts = ser.Elements().Where(e => e.LocalName == "dPt").ToList(); + var catCount = series.FirstOrDefault().values?.Length ?? 0; + for (int i = 0; i < catCount; i++) + { + var dPt = dPts.FirstOrDefault(d => + { + var idxEl = d.Elements().FirstOrDefault(e => e.LocalName == "idx"); + if (idxEl == null) return false; + 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}" : FallbackColors[i % FallbackColors.Length]); + } + } + else + { + // Detect line/scatter series for stroke color extraction + var isLineType = chartType.Contains("line") || chartType == "scatter"; + for (int i = 0; i < series.Count; i++) + { + string? rgb = null; + if (i < serElements.Count) + { + var spPr = serElements[i].Elements().FirstOrDefault(e => e.LocalName == "spPr"); + if (isLineType) + { + // For line/scatter, prefer stroke color from a:ln > a:solidFill + var ln = spPr?.Elements().FirstOrDefault(e => e.LocalName == "ln"); + rgb = ExtractFillColor(ln); + } + // Fallback to solidFill + rgb ??= ExtractFillColor(spPr); + } + colors.Add(rgb != null ? $"#{rgb}" : FallbackColors[i % FallbackColors.Length]); + } + } + return colors; + } + + /// Extract hex color (without #) from solidFill > srgbClr inside an spPr or ln element. + private static string? ExtractFillColor(OpenXmlElement? container) + { + if (container == null) return null; + var solidFill = container.Elements().FirstOrDefault(e => e.LocalName == "solidFill"); + var srgb = solidFill?.Elements().FirstOrDefault(e => e.LocalName == "srgbClr"); + var v = srgb?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value; + // Reject non-hex values — the return flows into $"#{...}" inline SVG + // fill/style attributes. Same XSS class as w:color / w:shd / border. + if (v == null) return null; + if (v.Length is not (3 or 6 or 8)) return null; + foreach (var c in v) + if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) return null; + return v; + } + + /// 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 HexOrNull(val); + } + + /// 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 HexOrNull(val); + } + + // Hex-only stripper: reject non-hex so these chart-color getters can't + // become XSS sinks when their return flows into SVG style/fill/stroke + // attributes downstream in Excel/PPTX/Word previews. + private static string? HexOrNull(string? v) + { + if (v == null) return null; + if (v.Length is not (3 or 6 or 8)) return null; + foreach (var c in v) + if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) return null; + return v; + } + + /// 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 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; + + 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($" "); + + // cx extended chart types (funnel / treemap / sunburst / boxWhisker) + // dispatch to dedicated emitters before the regular bar/line/pie + // branches — otherwise they fall through to the column fallback and + // render as generic bar charts. Histogram intentionally falls through + // here: it uses the regular column pipeline after ExtractCxChartInfo + // has pre-binned the values into categories. + if (TryRenderCxSpecificType(sb, info, marginLeft, marginTop, plotW, plotH)) + return; + + if (chartType.Contains("pie") || chartType.Contains("doughnut")) + { + if (info.Is3D) + RenderPie3DSvg(sb, info.Series, info.Categories, info.Colors, svgW, svgH, + info.ShowDataLabels, info.ShowDataLabelVal, info.ShowDataLabelPercent, + info.RotateX > 0 ? info.RotateX : 30); + else + RenderPieChartSvg(sb, info.Series, info.Categories, info.Colors, svgW, svgH, info.HoleRatio, info.ShowDataLabels, + info.ShowDataLabelVal, info.ShowDataLabelPercent); + } + else if (chartType.Contains("area")) + { + var areaW = plotW - (int)(plotW * 0.03); + if (info.Is3D) + RenderArea3DSvg(sb, info.Series, info.Categories, info.Colors, marginLeft, marginTop, areaW, plotH, + info.IsStacked, info.RotateX, info.RotateY); + else + RenderAreaChartSvg(sb, info.Series, info.Categories, info.Colors, marginLeft, marginTop, areaW, plotH, info.IsStacked); + } + else if (chartType == "combo") + { + RenderComboChartSvg(sb, info.PlotArea!, info.Series, info.Categories, info.Colors, marginLeft, marginTop, plotW, plotH); + } + else if (chartType.Contains("radar")) + { + RenderRadarChartSvg(sb, info.Series, info.Categories, info.Colors, svgW, svgH, CatFontPx, info.RadarStyle); + } + else if (chartType == "bubble") + { + RenderBubbleChartSvg(sb, info.PlotArea!, info.Series, info.Categories, info.Colors, marginLeft, marginTop, plotW, plotH); + } + else if (chartType == "stock") + { + RenderStockChartSvg(sb, info.PlotArea!, info.Series, info.Categories, info.Colors, marginLeft, marginTop, plotW, plotH); + } + else if (chartType.Contains("line") || chartType == "scatter") + { + if (info.Is3D) + RenderLine3DSvg(sb, info.Series, info.Categories, info.Colors, marginLeft, marginTop, plotW, plotH); + else + RenderLineChartSvg(sb, info.Series, info.Categories, info.Colors, marginLeft, marginTop, plotW, plotH, + info.ShowDataLabels, info.MarkerShapes, info.MarkerSizes, info.LogBase, info.IsReversed, + info.HasDropLines, info.HasHighLowLines, info.HasUpDownBars, + info.UpBarColor, info.DownBarColor, info.AxisMin, info.AxisMax, info.MajorUnit, info.ValNumFmt, + info.ReferenceLines, info.Smooth, info.LineDashes, info.LineWidths, + info.DropLineColor, info.DropLineWidth, info.DropLineDash, + info.HighLowLineColor, info.HighLowLineWidth, + info.Trendlines, info.ErrorBars); + } + else + { + // 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) + RenderBar3DSvg(sb, info.Series, info.Categories, info.Colors, barMarginLeft, marginTop, barPlotW, plotH, isHorizontal, + info.IsStacked, info.IsPercent, info.AxisMax, info.AxisMin, info.MajorUnit, + info.GapWidth, info.ShowDataLabels, info.ValNumFmt, + info.ReferenceLines, info.RotateX, info.RotateY); + else + 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, info.ValNumFmt, + isHorizontal ? info.PlotFillColor : null, info.ReferenceLines, + info.IsWaterfall, info.ErrorBars); + } + + // 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"); + // #7f: legendPos "r" / "l" / "tr" stack swatches vertically; "b" / "t" + // keep the horizontal row layout but the caller wraps with flex so + // they appear above / below the SVG. + var isVertical = info.LegendPos is "r" or "l" or "tr"; + var layoutCss = isVertical + ? "display:flex;flex-direction:column;gap:6px;padding:4px 6px;align-items:flex-start" + : "display:flex;flex-wrap:wrap;justify-content:center;gap:16px;padding:4px 0"; + // Whitelist legendPos: ST_LegendPos values are short tokens, so + // reject anything outside the schema to stop an adversarial + // from escaping the attr. + var safePos = info.LegendPos is "r" or "l" or "t" or "b" or "tr" or "ctr" ? info.LegendPos : ""; + sb.Append($"
    "); + if (isPieType && info.Categories.Length > 0) + { + for (int i = 0; i < info.Categories.Length; i++) + { + var color = i < info.Colors.Count ? info.Colors[i] : DefaultColors[i % DefaultColors.Length]; + sb.Append($"{HtmlEncode(info.Categories[i])}"); + } + } + else + { + // Office convention: horizontal bar charts render legend in reverse of + // declaration order so stacking reads top-to-bottom matching legend order. + // CONSISTENCY(chart-legend-order): vertical bar/column, line, area keep + // declaration order. + var isHorizBarLegend = info.ChartType.Contains("bar") && !info.ChartType.Contains("column"); + for (int k = 0; k < info.Series.Count; k++) + { + int i = isHorizBarLegend ? info.Series.Count - 1 - k : k; + var color = i < info.Colors.Count ? info.Colors[i] : DefaultColors[i % DefaultColors.Length]; + sb.Append($"{HtmlEncode(info.Series[i].name)}"); + } + // Reference-line entries render as a dashed swatch beside the regular series. + foreach (var rl in info.ReferenceLines) + { + var color = rl.Color.StartsWith("#") ? rl.Color : "#" + rl.Color; + var name = string.IsNullOrEmpty(rl.Name) ? "Ref" : rl.Name; + sb.Append($"{HtmlEncode(name)}"); + } + } + sb.AppendLine("
    "); + } + + /// Render a data table below the chart (HTML table showing raw series values). + public void RenderDataTableHtml(StringBuilder sb, ChartInfo info) + { + if (!info.HasDataTable) return; + sb.AppendLine("
    "); + sb.AppendLine("
    "); + // Header row: categories + sb.Append(" "); + foreach (var cat in info.Categories) + sb.Append($""); + sb.AppendLine(""); + // Series rows + for (int s = 0; s < info.Series.Count; s++) + { + var color = s < info.Colors.Count ? info.Colors[s] : DefaultColors[s % DefaultColors.Length]; + sb.Append($" "); + for (int c = 0; c < info.Categories.Length; c++) + { + var val = c < info.Series[s].values.Length ? info.Series[s].values[c] : 0; + var label = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"; + sb.Append($""); + } + sb.AppendLine(""); + } + sb.AppendLine("
    {HtmlEncode(cat)}
    {HtmlEncode(info.Series[s].name)}{label}
    "); + sb.AppendLine(" "); + } + + // ==================== Reference Line Helpers ==================== + + /// Map an OOXML PresetLineDashValues InnerText (e.g. "sysDash", "lgDashDot") to + /// an SVG stroke-dasharray value. Falls back to a generic dashed pattern for unknowns. + private static string RefLineDashArray(string dashName) => dashName.ToLowerInvariant() switch + { + "solid" => "none", + "dot" or "sysdot" => "1,2", + "dash" or "sysdash" => "5,3", + "dashdot" or "sysdashdot" => "5,3,1,3", + "lgdash" or "longdash" => "8,3", + "lgdashdot" or "longdashdot" => "8,3,1,3", + "lgdashdotdot" or "longdashdotdot" => "8,3,1,3,1,3", + _ => "5,3" + }; + + // ==================== 3D Chart Helpers ==================== + + /// Darken or lighten a hex color by a factor (0.0-2.0, 1.0=unchanged) + private static string RenderMarkerSvg(string shape, double cx, double cy, double r, string color) + { + return shape switch + { + "diamond" => $"", + "square" => $"", + "triangle" => $"", + "star" => BuildStarPath(cx, cy, r, color), + "x" => $"", + "plus" => $"", + "dash" => $"", + "dot" => $"", + "none" => "", + _ => $"", // circle or auto + }; + } + + private static string BuildStarPath(double cx, double cy, double r, string color) + { + var sb = new StringBuilder(); + sb.Append($""); + return sb.ToString(); + } + + private static string AdjustColor(string hexColor, double factor) + { + var hex = hexColor.TrimStart('#'); + if (hex.Length < 6) return hexColor; + var r = (int)Math.Clamp(int.Parse(hex[..2], System.Globalization.NumberStyles.HexNumber) * factor, 0, 255); + var g = (int)Math.Clamp(int.Parse(hex[2..4], System.Globalization.NumberStyles.HexNumber) * factor, 0, 255); + var b = (int)Math.Clamp(int.Parse(hex[4..6], System.Globalization.NumberStyles.HexNumber) * factor, 0, 255); + return $"#{r:X2}{g:X2}{b:X2}"; + } + + // 3D isometric offsets (defaults for 0/0 view3D) + private const double Depth3D = 12; + private const double DxIso = 8; + private const double DyIso = -6; + + /// Compute 3D isometric offsets from view3D parameters. + private static (double dx, double dy) Compute3DOffsets(int rotateX, int rotateY, double baseDepth = 10) + { + if (rotateX == 0 && rotateY == 0) return (DxIso, DyIso); + var ry = Math.Clamp(rotateY, 0, 360) * Math.PI / 180; + var rx = Math.Clamp(rotateX, 0, 90) * Math.PI / 180; + var dx = baseDepth * Math.Sin(ry) * 0.9; + var dy = -baseDepth * Math.Sin(rx) * 0.7; + if (Math.Abs(dx) < 2) dx = dx >= 0 ? 2 : -2; + if (Math.Abs(dy) < 2) dy = -2; + return (dx, dy); + } + + private void RenderBar3DSvg(StringBuilder sb, List<(string name, double[] values)> series, + string[] categories, List colors, int ox, int oy, int pw, int ph, bool horizontal, + bool stacked = false, bool percentStacked = false, + double? ooxmlMax = null, double? ooxmlMin = null, double? ooxmlMajorUnit = null, + int? ooxmlGapWidth = null, bool showDataLabels = false, string? valNumFmt = null, + List<(string Name, double Value, string Color, double WidthPt, string Dash)>? referenceLines = null, + int rotateX = 15, int rotateY = 20) + { + var allValues = series.SelectMany(s => s.values).ToArray(); + if (allValues.Length == 0) return; + var catCount = Math.Max(categories.Length, series.Max(s => s.values.Length)); + var serCount = series.Count; + var (dx3d, dy3d) = Compute3DOffsets(rotateX, rotateY); + + // Compute axis range (mirrors 2D RenderBarChartSvg logic) + double maxVal, minVal = 0; + if (stacked || percentStacked) + { + var catSums = new double[catCount]; + for (int c = 0; c < catCount; c++) + catSums[c] = series.Sum(s => c < s.values.Length ? s.values[c] : 0); + maxVal = percentStacked ? 100 : catSums.Max(); + } + else + maxVal = allValues.Max(); + + if (ooxmlMax.HasValue) maxVal = ooxmlMax.Value; + if (ooxmlMin.HasValue) minVal = ooxmlMin.Value; + if (maxVal <= minVal) maxVal = minVal + 1; + var range = maxVal - minVal; + + // Grid ticks + int tickCount; + double majorUnit; + if (ooxmlMajorUnit.HasValue && ooxmlMajorUnit.Value > 0) { majorUnit = ooxmlMajorUnit.Value; tickCount = (int)(range / majorUnit); } + else { var (nm, _, nu) = ComputeNiceAxis(maxVal); maxVal = nm; range = maxVal - minVal; majorUnit = nu > 0 ? nu : range / 4; tickCount = majorUnit > 0 ? (int)(range / majorUnit) : 4; } + + void Draw3DBar(double bx, double by, double barW2, double barH2, string color) + { + if (barH2 < 0.5) return; + var sideColor = AdjustColor(color, 0.65); + var topColor = AdjustColor(color, 1.25); + // Front face + sb.AppendLine($" "); + // Top face + sb.AppendLine($" "); + // Right side face + sb.AppendLine($" "); + } + + if (horizontal) + { + 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); + var barH = stacked || percentStacked ? groupH * 0.5 : groupH * 0.5 / serCount; + var gap = groupH * 0.2; + + // Gridlines + for (int t = 1; t <= tickCount; t++) + { + var gx = plotOx + (double)plotPw * t / tickCount; + sb.AppendLine($" "); + } + sb.AppendLine($" "); + sb.AppendLine($" "); + + for (int c = 0; c < catCount; c++) + { + if (stacked || percentStacked) + { + var catTotal = series.Sum(s => c < s.values.Length ? s.values[c] : 0); + double cumX = 0; + for (int s = 0; s < serCount; s++) + { + var val = c < series[s].values.Length ? series[s].values[c] : 0; + var normVal = percentStacked && catTotal > 0 ? val / catTotal * 100 : val; + var segW = (normVal / range) * plotPw; + var by = oy + c * groupH + gap; + var color = colors[s % colors.Count]; + Draw3DBar(plotOx + cumX, by, segW, barH, color); + cumX += segW; + } + } + else + { + for (int s = 0; s < serCount; s++) + { + if (c >= series[s].values.Length) continue; + var val = series[s].values[c]; + var barW2 = ((val - minVal) / range) * plotPw; + var by = oy + c * groupH + gap + s * barH; + Draw3DBar(plotOx, by, barW2, barH, colors[s % colors.Count]); + } + } + } + for (int c = 0; c < catCount; c++) + { + var label = c < categories.Length ? categories[c] : ""; + sb.AppendLine($" {HtmlEncode(label)}"); + } + for (int t = 0; t <= tickCount; t++) + { + var val = minVal + majorUnit * t; + var label = FormatAxisValue(val, valNumFmt); + sb.AppendLine($" {label}"); + } + } + else + { + var gapPct = ooxmlGapWidth.HasValue ? ooxmlGapWidth.Value / 100.0 : 1.5; + var groupW = (double)pw / Math.Max(catCount, 1); + double barW; + if (stacked || percentStacked) + barW = groupW / (1 + gapPct); + else + barW = groupW / (serCount + gapPct); + var gapW = (groupW - (stacked || percentStacked ? barW : barW * serCount)) / 2; + + // Gridlines + for (int t = 1; t <= tickCount; t++) + { + var gy = oy + ph - (double)ph * t / tickCount; + sb.AppendLine($" "); + } + sb.AppendLine($" "); + sb.AppendLine($" "); + + // Reference lines + if (referenceLines != null) + { + foreach (var rl in referenceLines) + { + var rly = oy + ph - ((rl.Value - minVal) / range) * ph; + var rlDash = rl.Dash == "dash" ? "stroke-dasharray=\"6,3\"" : rl.Dash == "dot" ? "stroke-dasharray=\"2,2\"" : ""; + sb.AppendLine($" "); + } + } + + for (int c = 0; c < catCount; c++) + { + if (stacked || percentStacked) + { + var catTotal = series.Sum(s => c < s.values.Length ? s.values[c] : 0); + double cumH = 0; + for (int s = 0; s < serCount; s++) + { + var val = c < series[s].values.Length ? series[s].values[c] : 0; + var normVal = percentStacked && catTotal > 0 ? val / catTotal * 100 : val; + var segH = ((normVal) / range) * ph; + var bx = ox + c * groupW + gapW; + var by = oy + ph - cumH - segH; + Draw3DBar(bx, by, barW, segH, colors[s % colors.Count]); + if (showDataLabels && segH > 10) + { + var vlabel = FormatAxisValue(val, valNumFmt); + sb.AppendLine($" {vlabel}"); + } + cumH += segH; + } + } + else + { + for (int s = 0; s < serCount; s++) + { + if (c >= series[s].values.Length) continue; + var val = series[s].values[c]; + var barH2 = ((val - minVal) / range) * ph; + var bx = ox + c * groupW + gapW + s * barW; + var by = oy + ph - barH2; + Draw3DBar(bx, by, barW, barH2, colors[s % colors.Count]); + if (showDataLabels) + { + var vlabel = FormatAxisValue(val, valNumFmt); + sb.AppendLine($" {vlabel}"); + } + } + } + } + // Category labels + for (int c = 0; c < catCount; c++) + { + var label = c < categories.Length ? categories[c] : ""; + sb.AppendLine($" {HtmlEncode(label)}"); + } + // Value axis labels + for (int t = 0; t <= tickCount; t++) + { + var val = minVal + majorUnit * t; + var label = FormatAxisValue(val, valNumFmt); + var ty = oy + ph - ((val - minVal) / range) * ph; + sb.AppendLine($" {label}"); + } + } + } + + private void RenderPie3DSvg(StringBuilder sb, List<(string name, double[] values)> series, + string[] categories, List colors, int svgW, int svgH, + bool showDataLabels = false, bool showVal = false, bool showPercent = false, + int rotateX = 30) + { + var values = series.FirstOrDefault().values ?? []; + if (values.Length == 0) return; + var total = values.Sum(); + if (total <= 0) return; + + var cx = svgW / 2.0; + var cy = svgH / 2.0; + var rx = Math.Min(svgW, svgH) * 0.35; + // Use rotateX to control squash: higher angle = more tilted = more elliptical + var tilt = Math.Clamp(rotateX > 0 ? rotateX : 30, 5, 80) * Math.PI / 180; + var ry = rx * Math.Cos(tilt); + var depth = rx * 0.08 + rx * 0.12 * (Math.Sin(tilt)); + var startAngle = -Math.PI / 2; + + var slices = new List<(int idx, double start, double end, string color)>(); + var angle = startAngle; + for (int i = 0; i < values.Length; i++) + { + var sliceAngle = 2 * Math.PI * values[i] / total; + var color = i < colors.Count ? colors[i] : DefaultColors[i % DefaultColors.Length]; + slices.Add((i, angle, angle + sliceAngle, color)); + angle += sliceAngle; + } + + // Side walls — sort by midpoint closeness to PI (front) for correct z-order + var wallSlices = slices.Where(s => s.start < Math.PI && s.end > 0).OrderBy(s => + { + var mid = (s.start + s.end) / 2; + return -Math.Abs(mid - Math.PI / 2); // draw furthest from front first + }).ToList(); + + foreach (var (idx, start, end, color) in wallSlices) + { + var sideColor = AdjustColor(color, 0.6); + var clampedStart = Math.Max(start, -0.01); + var clampedEnd = Math.Min(end, Math.PI + 0.01); + var steps = Math.Max(8, (int)((clampedEnd - clampedStart) / 0.1)); + var pathPoints = new StringBuilder(); + pathPoints.Append($"M {cx + rx * Math.Cos(clampedStart):0.#},{cy + ry * Math.Sin(clampedStart):0.#} "); + for (int step = 0; step <= steps; step++) + { + var a = clampedStart + (clampedEnd - clampedStart) * step / steps; + pathPoints.Append($"L {cx + rx * Math.Cos(a):0.#},{cy + ry * Math.Sin(a):0.#} "); + } + for (int step = steps; step >= 0; step--) + { + var a = clampedStart + (clampedEnd - clampedStart) * step / steps; + pathPoints.Append($"L {cx + rx * Math.Cos(a):0.#},{cy + ry * Math.Sin(a) + depth:0.#} "); + } + pathPoints.Append("Z"); + sb.AppendLine($" "); + } + + // Top face slices + startAngle = -Math.PI / 2; + for (int i = 0; i < values.Length; i++) + { + var sliceAngle = 2 * Math.PI * values[i] / total; + var endAngle = startAngle + sliceAngle; + var color = i < colors.Count ? colors[i] : DefaultColors[i % DefaultColors.Length]; + + if (values.Length == 1) + sb.AppendLine($" "); + else + { + var x1 = cx + rx * Math.Cos(startAngle); + var y1 = cy + ry * Math.Sin(startAngle); + var x2 = cx + rx * Math.Cos(endAngle); + var y2 = cy + ry * Math.Sin(endAngle); + var largeArc = sliceAngle > Math.PI ? 1 : 0; + sb.AppendLine($" "); + } + + // Data labels + var midAngle = startAngle + sliceAngle / 2; + var labelR = rx * 0.65; + var lx = cx + labelR * Math.Cos(midAngle); + var ly = cy + (labelR * Math.Cos(tilt)) * Math.Sin(midAngle); + var pct = total > 0 ? values[i] / total * 100 : 0; + + if (showDataLabels || showVal || showPercent) + { + var parts = new List(); + if (showVal) parts.Add(values[i] % 1 == 0 ? $"{(int)values[i]}" : $"{values[i]:0.#}"); + if (showPercent) parts.Add($"{pct:0}%"); + if (parts.Count == 0) parts.Add($"{pct:0}%"); // default to percent + var labelText = string.Join("\n", parts); + sb.AppendLine($" {HtmlEncode(labelText)}"); + } + else + { + // Category name label + var catLabel = i < categories.Length ? categories[i] : ""; + if (!string.IsNullOrEmpty(catLabel)) + sb.AppendLine($" {HtmlEncode(catLabel)}"); + } + + startAngle = endAngle; + } + } + + private void RenderLine3DSvg(StringBuilder sb, List<(string name, double[] values)> series, + string[] categories, List colors, int ox, int oy, int pw, int ph) + { + var allValues = series.SelectMany(s => s.values).ToArray(); + if (allValues.Length == 0) return; + var (maxVal, _, _) = ComputeNiceAxis(allValues.Max()); + var catCount = Math.Max(categories.Length, series.Max(s => s.values.Length)); + + sb.AppendLine($" "); + sb.AppendLine($" "); + + for (int s = series.Count - 1; s >= 0; s--) + { + var color = colors[s % colors.Count]; + var shadowColor = AdjustColor(color, 0.5); + var points = new List<(double x, double y)>(); + for (int c = 0; c < series[s].values.Length && c < catCount; c++) + { + var px = ox + (catCount > 1 ? (double)pw * c / (catCount - 1) : pw / 2.0); + var py = oy + ph - (series[s].values[c] / maxVal) * ph; + points.Add((px, py)); + } + if (points.Count > 1) + { + var ribbon = new StringBuilder(); + ribbon.Append("M "); + for (int p = 0; p < points.Count; p++) + ribbon.Append($"{points[p].x:0.#},{points[p].y:0.#} L "); + for (int p = points.Count - 1; p >= 0; p--) + ribbon.Append($"{points[p].x + DxIso:0.#},{points[p].y + DyIso:0.#} L "); + ribbon.Length -= 2; + ribbon.Append(" Z"); + sb.AppendLine($" "); + + var linePoints = string.Join(" ", points.Select(p => $"{p.x:0.#},{p.y:0.#}")); + sb.AppendLine($" "); + foreach (var pt in points) + sb.AppendLine($" "); + } + } + + for (int c = 0; c < catCount; c++) + { + var label = c < categories.Length ? categories[c] : ""; + var lx = ox + (catCount > 1 ? (double)pw * c / (catCount - 1) : pw / 2.0); + sb.AppendLine($" {HtmlEncode(label)}"); + } + + // Y-axis value labels + for (int t = 0; t <= 4; t++) + { + var val = maxVal * t / 4; + var label = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"; + var ty = oy + ph - (double)ph * t / 4; + sb.AppendLine($" {label}"); + } + } + + private void RenderArea3DSvg(StringBuilder sb, List<(string name, double[] values)> series, + string[] categories, List colors, int ox, int oy, int pw, int ph, + bool stacked = false, int rotateX = 15, int rotateY = 20) + { + var allValues = series.SelectMany(s => s.values).ToArray(); + if (allValues.Length == 0) return; + var catCount = Math.Max(categories.Length, series.Max(s => s.values.Length)); + var serCount = series.Count; + + double maxVal; + if (stacked) + { + var catSums = new double[catCount]; + for (int c = 0; c < catCount; c++) + catSums[c] = series.Sum(s => c < s.values.Length ? s.values[c] : 0); + maxVal = catSums.Max(); + } + else + maxVal = allValues.Max(); + var (niceMax, _, _) = ComputeNiceAxis(maxVal); + maxVal = niceMax; + if (maxVal <= 0) maxVal = 1; + + // 3D layout: reserve space for depth lanes + // Each series gets a "lane" along the depth (diagonal) direction + var laneCount = stacked ? 1 : serCount; + var laneStep = Math.Min(pw, ph) * 0.10; // step between lane starts (includes gap) + var laneThickness = laneStep * 0.55; // actual wall thickness (rest is gap) + var totalDepthX = laneStep * laneCount * 0.7; // total horizontal depth shift + var totalDepthY = -laneStep * laneCount * 0.5; // total vertical depth shift (upward) + + // Shrink front plot area to make room for depth + var plotW = (int)(pw - totalDepthX); + var plotH = (int)(ph + totalDepthY); // totalDepthY is negative + + // Axes & gridlines on the front plane + for (int t = 1; t <= 4; t++) + { + var gy = oy + plotH - (double)plotH * t / 4; + sb.AppendLine($" "); + } + sb.AppendLine($" "); + sb.AppendLine($" "); + + // Draw depth guide lines on the floor (baseline) to show perspective + for (int c = 0; c < catCount; c++) + { + var frontX = ox + (catCount > 1 ? (double)plotW * c / (catCount - 1) : plotW / 2.0); + var backX = frontX + totalDepthX; + var backY = oy + plotH + totalDepthY; + sb.AppendLine($" "); + } + + var stackBase = new double[catCount]; + + // Draw back-to-front: back series first (farthest), front series last (nearest) + for (int si = (stacked ? 0 : serCount - 1); stacked ? si < serCount : si >= 0; si += stacked ? 1 : -1) + { + var color = colors[si % colors.Count]; + var wallColor = AdjustColor(color, 0.6); + var topColor = AdjustColor(color, 0.85); + + // Compute this series' lane position + int lane = stacked ? 0 : si; + // Front edge of this lane (start of wall) + var laneDx = laneStep * lane * 0.7; + var laneDy = -laneStep * lane * 0.5; + // Back edge of this lane (end of wall = front + thickness) + var nextDx = laneDx + laneThickness * 0.7; + var nextDy = laneDy - laneThickness * 0.5; + + // Front edge points (data line at this lane's Z) + var frontPts = new List<(double x, double y)>(); + // Back edge points (same data but shifted deeper) + var backPts = new List<(double x, double y)>(); + + for (int c = 0; c < catCount; c++) + { + var val = c < series[si].values.Length ? series[si].values[c] : 0; + var baseVal = stacked ? stackBase[c] : 0; + var topVal = baseVal + val; + var dataH = (topVal / maxVal) * plotH; + var baseH = (baseVal / maxVal) * plotH; + + var frontBaseX = ox + (catCount > 1 ? (double)plotW * c / (catCount - 1) : plotW / 2.0); + + var fx = frontBaseX + laneDx; + var fy = oy + plotH - dataH + laneDy; + frontPts.Add((fx, fy)); + + var bx = frontBaseX + nextDx; + var by = oy + plotH - dataH + nextDy; + backPts.Add((bx, by)); + } + + if (frontPts.Count < 2) continue; + + // 1) Top ribbon: polygon connecting front data edge to back data edge (shows "roof" of the wall) + var topPath = new StringBuilder("M "); + foreach (var pt in frontPts) topPath.Append($"{pt.x:0.#},{pt.y:0.#} L "); + for (int p = backPts.Count - 1; p >= 0; p--) + topPath.Append($"{backPts[p].x:0.#},{backPts[p].y:0.#} L "); + topPath.Length -= 2; + topPath.Append(" Z"); + sb.AppendLine($" "); + + // 2) Front face: area from front baseline up to front data line + var frontBaseY = oy + plotH + laneDy; + var areaPath = new StringBuilder($"M {frontPts[0].x:0.#},{frontBaseY + (stacked ? -(stackBase[0] / maxVal) * plotH : 0):0.#} "); + foreach (var pt in frontPts) areaPath.Append($"L {pt.x:0.#},{pt.y:0.#} "); + areaPath.Append($"L {frontPts[^1].x:0.#},{frontBaseY + (stacked ? -(stackBase[catCount - 1] / maxVal) * plotH : 0):0.#} "); + if (stacked) + { + for (int c = catCount - 1; c >= 0; c--) + { + var baseX = ox + laneDx + (catCount > 1 ? (double)plotW * c / (catCount - 1) : plotW / 2.0); + var baseY2 = oy + plotH + laneDy - (stackBase[c] / maxVal) * plotH; + areaPath.Append($"L {baseX:0.#},{baseY2:0.#} "); + } + } + areaPath.Append("Z"); + sb.AppendLine($" "); + + // 3) Front edge line + sb.AppendLine($" $"{p.x:0.#},{p.y:0.#}"))}\" fill=\"none\" stroke=\"{AdjustColor(color, 0.7)}\" stroke-width=\"1.5\"/>"); + + // 4) Right-side wall (last category): connects front-right to back-right edge + { + var frX = frontPts[^1].x; var frY = frontPts[^1].y; + var brX = backPts[^1].x; var brY = backPts[^1].y; + var frBaseY2 = frontBaseY + (stacked ? -(stackBase[catCount - 1] / maxVal) * plotH : 0); + var brBaseY = oy + plotH + nextDy + (stacked ? -(stackBase[catCount - 1] / maxVal) * plotH : 0); + sb.AppendLine($" "); + } + + if (stacked) + { + for (int c = 0; c < catCount; c++) + stackBase[c] += c < series[si].values.Length ? series[si].values[c] : 0; + } + } + + // Category labels + for (int c = 0; c < catCount; c++) + { + var label = c < categories.Length ? categories[c] : ""; + var lx = ox + (catCount > 1 ? (double)plotW * c / (catCount - 1) : plotW / 2.0); + sb.AppendLine($" {HtmlEncode(label)}"); + } + // Value axis + for (int t = 0; t <= 4; t++) + { + var val = maxVal * t / 4; + var label = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"; + var ty = oy + plotH - (double)plotH * t / 4; + sb.AppendLine($" {label}"); + } + } + + // ==================== Trendline Regression Math ==================== + + /// Least-squares linear regression: y = slope * x + intercept. + private static (double slope, double intercept) FitLinear(double[] x, double[] y) + { + int n = x.Length; + double sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0; + for (int i = 0; i < n; i++) + { + sumX += x[i]; sumY += y[i]; + sumXY += x[i] * y[i]; sumX2 += x[i] * x[i]; + } + var denom = n * sumX2 - sumX * sumX; + if (Math.Abs(denom) < 1e-15) return (0, sumY / n); + var slope = (n * sumXY - sumX * sumY) / denom; + var intercept = (sumY - slope * sumX) / n; + return (slope, intercept); + } + + /// Exponential fit: y = a * e^(b*x). Uses ln(y) linear regression. + private static (double a, double b) FitExponential(double[] x, double[] y) + { + // Filter to positive y values only + var validIdx = Enumerable.Range(0, y.Length).Where(i => y[i] > 0).ToArray(); + if (validIdx.Length < 2) return (double.NaN, double.NaN); + var lnY = validIdx.Select(i => Math.Log(y[i])).ToArray(); + var xv = validIdx.Select(i => x[i]).ToArray(); + var (slope, intercept) = FitLinear(xv, lnY); + return (Math.Exp(intercept), slope); + } + + /// Logarithmic fit: y = a * ln(x) + b. Uses ln(x) linear regression. + private static (double a, double b) FitLogarithmic(double[] x, double[] y) + { + var validIdx = Enumerable.Range(0, x.Length).Where(i => x[i] > 0).ToArray(); + if (validIdx.Length < 2) return (double.NaN, double.NaN); + var lnX = validIdx.Select(i => Math.Log(x[i])).ToArray(); + var yv = validIdx.Select(i => y[i]).ToArray(); + var (slope, intercept) = FitLinear(lnX, yv); + return (slope, intercept); + } + + /// Power fit: y = a * x^b. Uses ln(x),ln(y) linear regression. + private static (double a, double b) FitPower(double[] x, double[] y) + { + var validIdx = Enumerable.Range(0, x.Length).Where(i => x[i] > 0 && y[i] > 0).ToArray(); + if (validIdx.Length < 2) return (double.NaN, double.NaN); + var lnX = validIdx.Select(i => Math.Log(x[i])).ToArray(); + var lnY = validIdx.Select(i => Math.Log(y[i])).ToArray(); + var (slope, intercept) = FitLinear(lnX, lnY); + return (Math.Exp(intercept), slope); + } + + /// Polynomial fit: y = c0 + c1*x + c2*x² + ... using normal equations. + private static double[]? FitPolynomial(double[] x, double[] y, int order) + { + int n = x.Length; + order = Math.Min(order, n - 1); + if (order < 1) return null; + int m = order + 1; + + // Build normal equations: (X^T X) c = X^T y + var xtx = new double[m, m]; + var xty = new double[m]; + for (int i = 0; i < n; i++) + { + var xPow = new double[2 * order + 1]; + xPow[0] = 1; + for (int p = 1; p <= 2 * order; p++) xPow[p] = xPow[p - 1] * x[i]; + for (int r = 0; r < m; r++) + { + for (int c = 0; c < m; c++) xtx[r, c] += xPow[r + c]; + xty[r] += xPow[r] * y[i]; + } + } + + // Gaussian elimination with partial pivoting + var aug = new double[m, m + 1]; + for (int r = 0; r < m; r++) + { + for (int c = 0; c < m; c++) aug[r, c] = xtx[r, c]; + aug[r, m] = xty[r]; + } + for (int col = 0; col < m; col++) + { + int pivotRow = col; + for (int r = col + 1; r < m; r++) + if (Math.Abs(aug[r, col]) > Math.Abs(aug[pivotRow, col])) pivotRow = r; + if (pivotRow != col) + for (int c = 0; c <= m; c++) (aug[col, c], aug[pivotRow, c]) = (aug[pivotRow, c], aug[col, c]); + if (Math.Abs(aug[col, col]) < 1e-15) return null; + for (int r = col + 1; r < m; r++) + { + var factor = aug[r, col] / aug[col, col]; + for (int c = col; c <= m; c++) aug[r, c] -= factor * aug[col, c]; + } + } + // Back substitution + var coeffs = new double[m]; + for (int r = m - 1; r >= 0; r--) + { + coeffs[r] = aug[r, m]; + for (int c = r + 1; c < m; c++) coeffs[r] -= aug[r, c] * coeffs[c]; + coeffs[r] /= aug[r, r]; + } + return coeffs; + } + + /// Compute R² (coefficient of determination). + private static double ComputeRSquared(double[] x, double[] y, Func fn) + { + var mean = y.Average(); + double ssTot = 0, ssRes = 0; + for (int i = 0; i < y.Length; i++) + { + ssTot += (y[i] - mean) * (y[i] - mean); + var predicted = fn(x[i]); + ssRes += (y[i] - predicted) * (y[i] - predicted); + } + return ssTot > 0 ? 1 - ssRes / ssTot : 0; + } +} diff --git a/src/officecli/Core/ChartExBuilder.cs b/src/officecli/Core/ChartExBuilder.cs deleted file mode 100644 index bf347e09f..000000000 --- a/src/officecli/Core/ChartExBuilder.cs +++ /dev/null @@ -1,237 +0,0 @@ -// Copyright 2025 OfficeCli (officecli.ai) -// SPDX-License-Identifier: Apache-2.0 - -using DocumentFormat.OpenXml; -using DocumentFormat.OpenXml.Packaging; -using Drawing = DocumentFormat.OpenXml.Drawing; -using CX = DocumentFormat.OpenXml.Office2016.Drawing.ChartDrawing; - -namespace OfficeCli.Core; - -/// -/// Builder for cx:chart (Office 2016 extended chart types): -/// funnel, treemap, sunburst, boxWhisker, histogram, waterfall (native). -/// -internal static class ChartExBuilder -{ - internal static readonly HashSet ExtendedChartTypes = new(StringComparer.OrdinalIgnoreCase) - { - "funnel", "treemap", "sunburst", "boxwhisker", "histogram" - }; - - internal static bool IsExtendedChartType(string chartType) - { - var normalized = chartType.ToLowerInvariant().Replace(" ", "").Replace("_", "").Replace("-", ""); - return ExtendedChartTypes.Contains(normalized); - } - - /// - /// Build a cx:chartSpace for an extended chart type. - /// - internal static CX.ChartSpace BuildExtendedChartSpace( - string chartType, - string? title, - string[]? categories, - List<(string name, double[] values)> seriesData, - Dictionary properties) - { - var normalized = chartType.ToLowerInvariant().Replace(" ", "").Replace("_", "").Replace("-", ""); - - var chartSpace = new CX.ChartSpace(); - - // 1. Build ChartData - var chartData = new CX.ChartData(); - for (int si = 0; si < seriesData.Count; si++) - { - var data = BuildDataBlock((uint)si, normalized, categories, seriesData[si].values); - chartData.AppendChild(data); - } - chartSpace.AppendChild(chartData); - - // 2. Build Chart - var chart = new CX.Chart(); - - if (!string.IsNullOrEmpty(title)) - { - var chartTitle = new CX.ChartTitle(); - chartTitle.AppendChild(new CX.Text( - new CX.RichTextBody( - new Drawing.BodyProperties(), - new Drawing.Paragraph( - new Drawing.Run( - new Drawing.RunProperties { Language = "en-US" }, - new Drawing.Text(title)))))); - chart.AppendChild(chartTitle); - } - - var plotArea = new CX.PlotArea(); - var plotAreaRegion = new CX.PlotAreaRegion(); - - var layoutId = normalized switch - { - "funnel" => "funnel", - "treemap" => "treemap", - "sunburst" => "sunburst", - "boxwhisker" => "boxWhisker", - "histogram" => "clusteredColumn", - _ => "funnel" - }; - - for (int si = 0; si < seriesData.Count; si++) - { - var series = new CX.Series { LayoutId = new EnumValue( - ParseSeriesLayout(layoutId)) }; - series.AppendChild(new CX.Text( - new CX.TextData( - new CX.Formula(""), - new CX.VXsdstring(seriesData[si].name)))); - series.AppendChild(new CX.DataId { Val = (uint)si }); - - // Chart-type specific layoutPr - var layoutPr = BuildLayoutProperties(normalized, properties, seriesData[si].values.Length); - if (layoutPr != null) - series.AppendChild(layoutPr); - - plotAreaRegion.AppendChild(series); - } - - plotArea.AppendChild(plotAreaRegion); - - // Add axes for chart types that need them - if (normalized is "boxwhisker" or "histogram") - { - plotArea.AppendChild(new CX.Axis(new CX.CategoryAxisScaling()) { Id = 0 }); - plotArea.AppendChild(new CX.Axis(new CX.ValueAxisScaling()) { Id = 1 }); - } - - chart.AppendChild(plotArea); - chartSpace.AppendChild(chart); - - return chartSpace; - } - - private static CX.Data BuildDataBlock(uint id, string chartType, string[]? categories, double[] values) - { - var data = new CX.Data { Id = id }; - - // String dimension for categories (if provided) - if (categories != null && chartType is "funnel" or "treemap" or "sunburst" or "boxwhisker") - { - var strDim = new CX.StringDimension { Type = CX.StringDimensionType.Cat }; - var strLvl = new CX.StringLevel { PtCount = (uint)categories.Length }; - for (int i = 0; i < categories.Length; i++) - strLvl.AppendChild(new CX.ChartStringValue(categories[i]) { Index = (uint)i }); - strDim.AppendChild(strLvl); - data.AppendChild(strDim); - } - - // Numeric dimension - var numType = chartType is "treemap" or "sunburst" - ? CX.NumericDimensionType.Size - : CX.NumericDimensionType.Val; - var numDim = new CX.NumericDimension { Type = numType }; - var numLvl = new CX.NumericLevel { PtCount = (uint)values.Length, FormatCode = "General" }; - for (int i = 0; i < values.Length; i++) - numLvl.AppendChild(new CX.NumericValue(values[i].ToString("G")) { Idx = (uint)i }); - numDim.AppendChild(numLvl); - data.AppendChild(numDim); - - return data; - } - - private static CX.SeriesLayoutProperties? BuildLayoutProperties( - string chartType, Dictionary properties, int valueCount) - { - switch (chartType) - { - case "treemap": - { - var lp = new CX.SeriesLayoutProperties(); - var parentLayout = properties.GetValueOrDefault("parentLabelLayout") ?? "overlapping"; - lp.AppendChild(new CX.ParentLabelLayout - { - ParentLabelLayoutVal = parentLayout.ToLowerInvariant() switch - { - "none" => CX.ParentLabelLayoutVal.None, - "banner" => CX.ParentLabelLayoutVal.Banner, - _ => CX.ParentLabelLayoutVal.Overlapping - } - }); - return lp; - } - case "boxwhisker": - { - var lp = new CX.SeriesLayoutProperties(); - lp.AppendChild(new CX.SeriesElementVisibilities - { - MeanLine = false, MeanMarker = true, - Nonoutliers = false, Outliers = true - }); - var method = properties.GetValueOrDefault("quartileMethod") ?? "exclusive"; - lp.AppendChild(new CX.Statistics - { - QuartileMethod = method.ToLowerInvariant() switch - { - "inclusive" => CX.QuartileMethod.Inclusive, - _ => CX.QuartileMethod.Exclusive - } - }); - return lp; - } - case "histogram": - { - var lp = new CX.SeriesLayoutProperties(); - if (properties.TryGetValue("binCount", out var binCountStr) && - uint.TryParse(binCountStr, out var binCount)) - { - var binning = new CX.Binning(); - binning.AppendChild(new CX.BinCountXsdunsignedInt(binCount.ToString())); - lp.AppendChild(binning); - } - else - { - lp.AppendChild(new CX.Aggregation()); - } - return lp; - } - default: - return null; - } - } - - private static CX.SeriesLayout ParseSeriesLayout(string layoutId) - { - return layoutId switch - { - "funnel" => CX.SeriesLayout.Funnel, - "treemap" => CX.SeriesLayout.Treemap, - "sunburst" => CX.SeriesLayout.Sunburst, - "boxWhisker" => CX.SeriesLayout.BoxWhisker, - "clusteredColumn" => CX.SeriesLayout.ClusteredColumn, - "paretoLine" => CX.SeriesLayout.ParetoLine, - "regionMap" => CX.SeriesLayout.RegionMap, - _ => CX.SeriesLayout.Funnel - }; - } - - /// - /// Detect if a cx:chartSpace contains an extended chart type and return the type name. - /// - internal static string? DetectExtendedChartType(CX.ChartSpace chartSpace) - { - var series = chartSpace.Descendants().FirstOrDefault(); - var layoutId = series?.LayoutId?.InnerText; - if (layoutId == null) return null; - return layoutId switch - { - "funnel" => "funnel", - "treemap" => "treemap", - "sunburst" => "sunburst", - "boxWhisker" => "boxWhisker", - "clusteredColumn" => "histogram", - "paretoLine" => "pareto", - "regionMap" => "regionMap", - _ => layoutId - }; - } -} diff --git a/src/officecli/Core/ChartSvgRenderer.cs b/src/officecli/Core/ChartSvgRenderer.cs deleted file mode 100644 index f7713574f..000000000 --- a/src/officecli/Core/ChartSvgRenderer.cs +++ /dev/null @@ -1,784 +0,0 @@ -// Copyright 2025 OfficeCli (officecli.ai) -// SPDX-License-Identifier: Apache-2.0 - -using System.Text; -using DocumentFormat.OpenXml; -using DocumentFormat.OpenXml.Drawing.Charts; -using Drawing = DocumentFormat.OpenXml.Drawing; - -namespace OfficeCli.Core; - -/// -/// Shared chart SVG rendering logic used by both PowerPoint and Excel HTML preview. -/// -internal class ChartSvgRenderer -{ - // Default chart colors matching Office theme accent colors - public static readonly string[] DefaultColors = [ - "#4472C4", "#ED7D31", "#A5A5A5", "#FFC000", "#5B9BD5", "#70AD47", - "#264478", "#9E480E", "#636363", "#997300", "#255E91", "#43682B" - ]; - - // Chart styling — configurable per chart instance - public string ValueColor { get; set; } = "#D0D8E0"; - public string CatColor { get; set; } = "#C8D0D8"; - public string AxisColor { get; set; } = "#B0B8C0"; - public string SecondaryAxisColor { get; set; } = "#aaa"; - public string GridColor { get; set; } = "#333"; - public string AxisLineColor { get; set; } = "#555"; - public int ValFontPx { get; set; } = 9; - public int CatFontPx { get; set; } = 9; - public int AxisTickCount { get; set; } = 4; - - public static string HtmlEncode(string text) => - text.Replace("&", "&").Replace("<", "<").Replace(">", ">") - .Replace("\"", """).Replace("'", "'"); - - public void RenderBarChartSvg(StringBuilder sb, List<(string name, double[] values)> series, - string[] categories, List colors, int ox, int oy, int pw, int ph, - 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) - { - var allValues = series.SelectMany(s => s.values).ToArray(); - if (allValues.Length == 0) return; - var catCount = Math.Max(categories.Length, series.Max(s => s.values.Length)); - var serCount = series.Count; - if (percentStacked) stacked = true; - - double maxVal; - if (percentStacked) maxVal = 100; - else if (stacked) - { - maxVal = 0; - for (int c = 0; c < catCount; c++) - { - var sum = series.Sum(s => c < s.values.Length ? s.values[c] : 0); - if (sum > maxVal) maxVal = sum; - } - } - else maxVal = allValues.Max(); - if (maxVal <= 0) maxVal = 1; - - double niceMax, tickStep; - int nTicks; - if (!percentStacked) - { - if (ooxmlMax.HasValue && ooxmlMajorUnit.HasValue) - { - niceMax = ooxmlMax.Value; - tickStep = ooxmlMajorUnit.Value; - nTicks = (int)Math.Round(niceMax / tickStep); - } - else (niceMax, tickStep, nTicks) = ComputeNiceAxis(ooxmlMax ?? maxVal); - } - else { niceMax = 100; nTicks = 5; tickStep = 20; } - - if (horizontal) - { - var hLabelMargin = 50; - var plotOx = ox + hLabelMargin; - var plotPw = pw - hLabelMargin; - var groupH = (double)ph / Math.Max(catCount, 1); - var gapPct = (ooxmlGapWidth ?? 150) / 100.0; - double barH, gap; - if (stacked) { barH = groupH / (1 + gapPct); gap = (groupH - barH) / 2; } - else { barH = groupH / (serCount + gapPct); gap = barH * gapPct / 2; } - - for (int t = 1; t <= nTicks; t++) - { - var gx = plotOx + (double)plotPw * t / nTicks; - sb.AppendLine($" "); - } - sb.AppendLine($" "); - sb.AppendLine($" "); - - for (int c = 0; c < catCount; c++) - { - var dataIdx = catCount - 1 - c; - double stackX = 0; - var catSum = percentStacked ? series.Sum(s => dataIdx < s.values.Length ? s.values[dataIdx] : 0) : 1; - for (int s = 0; s < serCount; s++) - { - var rawVal = dataIdx < series[s].values.Length ? series[s].values[dataIdx] : 0; - var val = percentStacked && catSum > 0 ? (rawVal / catSum) * 100 : rawVal; - var barW = (val / niceMax) * plotPw; - if (stacked) - { - var bx = plotOx + (stackX / niceMax) * plotPw; - var by = oy + c * groupH + gap; - sb.AppendLine($" "); - stackX += val; - } - else - { - var bx = plotOx; - var by = oy + c * groupH + gap + (serCount - 1 - s) * barH; - sb.AppendLine($" "); - } - } - } - for (int c = 0; c < catCount; c++) - { - var dataIdx = catCount - 1 - c; - var label = dataIdx < categories.Length ? categories[dataIdx] : ""; - var ly = oy + c * groupH + groupH / 2; - sb.AppendLine($" {HtmlEncode(label)}"); - } - for (int t = 0; t <= nTicks; t++) - { - var val = tickStep * t; - var label = percentStacked ? $"{(int)val}%" : (val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"); - var tx = plotOx + (double)plotPw * t / nTicks; - sb.AppendLine($" {label}"); - } - } - else - { - var groupW = (double)pw / Math.Max(catCount, 1); - var gapPct = (ooxmlGapWidth ?? 150) / 100.0; - double barW, gap; - if (stacked) { barW = groupW / (1 + gapPct); gap = (groupW - barW) / 2; } - else { barW = groupW / (serCount + gapPct); gap = barW * gapPct / 2; } - - for (int t = 1; t <= nTicks; t++) - { - var gy = oy + ph - (double)ph * t / nTicks; - sb.AppendLine($" "); - } - sb.AppendLine($" "); - sb.AppendLine($" "); - - for (int c = 0; c < catCount; c++) - { - double stackY = 0; - var catSum = percentStacked ? series.Sum(s => c < s.values.Length ? s.values[c] : 0) : 1; - for (int s = 0; s < serCount; s++) - { - var rawVal = c < series[s].values.Length ? series[s].values[c] : 0; - var val = percentStacked && catSum > 0 ? (rawVal / catSum) * 100 : rawVal; - var barH = (val / niceMax) * ph; - if (stacked) - { - var bx = ox + c * groupW + gap; - var by = oy + ph - (stackY / niceMax) * ph - barH; - sb.AppendLine($" "); - stackY += val; - } - else - { - var bx = ox + c * groupW + gap + s * barW; - var by = oy + ph - barH; - sb.AppendLine($" "); - if (showDataLabels) - { - var vlabel = rawVal % 1 == 0 ? $"{(int)rawVal}" : $"{rawVal:0.#}"; - sb.AppendLine($" {vlabel}"); - } - } - } - } - for (int c = 0; c < catCount; c++) - { - var label = c < categories.Length ? categories[c] : ""; - var lx = ox + c * groupW + groupW / 2; - sb.AppendLine($" {HtmlEncode(label)}"); - } - for (int t = 0; t <= nTicks; t++) - { - var val = tickStep * t; - var label = percentStacked ? $"{(int)val}%" : (val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"); - var ty = oy + ph - (double)ph * t / nTicks; - sb.AppendLine($" {label}"); - } - } - } - - public void RenderLineChartSvg(StringBuilder sb, List<(string name, double[] values)> series, - string[] categories, List colors, int ox, int oy, int pw, int ph, bool showDataLabels = false) - { - var allValues = series.SelectMany(s => s.values).ToArray(); - if (allValues.Length == 0) return; - var maxVal = allValues.Max(); - if (maxVal <= 0) maxVal = 1; - var catCount = Math.Max(categories.Length, series.Max(s => s.values.Length)); - var (niceMax, tickStep, nTicks) = ComputeNiceAxis(maxVal); - - for (int t = 1; t <= nTicks; t++) - { - var gy = oy + ph - (double)ph * t / nTicks; - sb.AppendLine($" "); - } - sb.AppendLine($" "); - sb.AppendLine($" "); - - for (int s = 0; s < series.Count; s++) - { - var points = new List(); - for (int c = 0; c < series[s].values.Length && c < catCount; c++) - { - var px = ox + (catCount > 1 ? (double)pw * c / (catCount - 1) : pw / 2.0); - var py = oy + ph - (series[s].values[c] / niceMax) * ph; - points.Add($"{px:0.#},{py:0.#}"); - } - if (points.Count > 0) - { - sb.AppendLine($" "); - for (int p = 0; p < points.Count; p++) - { - var parts = points[p].Split(','); - sb.AppendLine($" "); - if (showDataLabels) - { - var val = series[s].values[p]; - var vlabel = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"; - sb.AppendLine($" {vlabel}"); - } - } - } - } - for (int c = 0; c < catCount; c++) - { - var label = c < categories.Length ? categories[c] : ""; - var lx = ox + (catCount > 1 ? (double)pw * c / (catCount - 1) : pw / 2.0); - sb.AppendLine($" {HtmlEncode(label)}"); - } - for (int t = 0; t <= nTicks; t++) - { - var val = tickStep * t; - var label = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"; - var ty = oy + ph - (double)ph * t / nTicks; - sb.AppendLine($" {label}"); - } - } - - 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) - { - var values = series.FirstOrDefault().values ?? []; - if (values.Length == 0) return; - var total = values.Sum(); - if (total <= 0) return; - - var cx = svgW / 2.0; - var cy = svgH / 2.0; - var r = Math.Min(svgW, svgH) * 0.42; - var innerR = r * holeRatio; - var startAngle = -Math.PI / 2; - - for (int i = 0; i < values.Length; i++) - { - var sliceAngle = 2 * Math.PI * values[i] / total; - var endAngle = startAngle + sliceAngle; - var color = i < colors.Count ? colors[i] : DefaultColors[i % DefaultColors.Length]; - - if (values.Length == 1 && holeRatio <= 0) - sb.AppendLine($" "); - else if (holeRatio > 0) - { - var ox1 = cx + r * Math.Cos(startAngle); var oy1 = cy + r * Math.Sin(startAngle); - var ox2 = cx + r * Math.Cos(endAngle); var oy2 = cy + r * Math.Sin(endAngle); - var ix1 = cx + innerR * Math.Cos(endAngle); var iy1 = cy + innerR * Math.Sin(endAngle); - var ix2 = cx + innerR * Math.Cos(startAngle); var iy2 = cy + innerR * Math.Sin(startAngle); - var largeArc = sliceAngle > Math.PI ? 1 : 0; - sb.AppendLine($" "); - } - else - { - var x1 = cx + r * Math.Cos(startAngle); var y1 = cy + r * Math.Sin(startAngle); - var x2 = cx + r * Math.Cos(endAngle); var y2 = cy + r * Math.Sin(endAngle); - var largeArc = sliceAngle > Math.PI ? 1 : 0; - sb.AppendLine($" "); - } - startAngle = endAngle; - } - if (showDataLabels) - { - var labelAngle = -Math.PI / 2; - var labelR = holeRatio > 0 ? r * (1 + holeRatio) / 2 : r * 0.65; - for (int i = 0; i < values.Length; i++) - { - var sliceAngle = 2 * Math.PI * values[i] / total; - var midAngle = labelAngle + sliceAngle / 2; - 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}%" : ""; - if (!string.IsNullOrEmpty(label)) - sb.AppendLine($" {label}"); - labelAngle += sliceAngle; - } - } - } - - public void RenderAreaChartSvg(StringBuilder sb, List<(string name, double[] values)> series, - string[] categories, List colors, int ox, int oy, int pw, int ph, bool stacked = false) - { - if (series.Count == 0) return; - var catCount = Math.Max(categories.Length, series.Max(s => s.values.Length)); - if (catCount == 0) return; - - var cumulative = new double[series.Count, catCount]; - for (int c = 0; c < catCount; c++) - { - double runningSum = 0; - for (int s = 0; s < series.Count; s++) - { - var val = c < series[s].values.Length ? series[s].values[c] : 0; - runningSum += stacked ? val : 0; - cumulative[s, c] = stacked ? runningSum : val; - } - } - var maxVal = 0.0; - if (stacked) { for (int c = 0; c < catCount; c++) maxVal = Math.Max(maxVal, cumulative[series.Count - 1, c]); } - else maxVal = series.SelectMany(s => s.values).DefaultIfEmpty(0).Max(); - if (maxVal <= 0) maxVal = 1; - var (niceMax, tickInterval, tickCount) = ComputeNiceAxis(maxVal); - - for (int t = 1; t <= tickCount; t++) - { - var gy = oy + ph - (double)ph * t / tickCount; - sb.AppendLine($" "); - } - sb.AppendLine($" "); - sb.AppendLine($" "); - - if (stacked) - { - for (int s = series.Count - 1; s >= 0; s--) - { - var topPoints = new List(); - var bottomPoints = new List(); - for (int c = 0; c < catCount; c++) - { - var px = ox + (catCount > 1 ? (double)pw * c / (catCount - 1) : pw / 2.0); - topPoints.Add($"{px:0.#},{oy + ph - (cumulative[s, c] / niceMax) * ph:0.#}"); - var bottomVal = s > 0 ? cumulative[s - 1, c] : 0; - bottomPoints.Add($"{px:0.#},{oy + ph - (bottomVal / niceMax) * ph:0.#}"); - } - bottomPoints.Reverse(); - sb.AppendLine($" "); - } - } - else - { - var renderOrder = Enumerable.Range(0, series.Count).OrderByDescending(s => series[s].values.DefaultIfEmpty(0).Max()).ToList(); - foreach (var s in renderOrder) - { - var topPoints = new List(); - for (int c = 0; c < catCount; c++) - { - var px = ox + (catCount > 1 ? (double)pw * c / (catCount - 1) : pw / 2.0); - var val = c < series[s].values.Length ? series[s].values[c] : 0; - topPoints.Add($"{px:0.#},{oy + ph - (val / niceMax) * ph:0.#}"); - } - var firstX = ox + (catCount > 1 ? 0 : pw / 2.0); - var lastIdx = Math.Min(series[s].values.Length - 1, catCount - 1); - var lastX = ox + (catCount > 1 ? (double)pw * lastIdx / (catCount - 1) : pw / 2.0); - sb.AppendLine($" "); - } - } - for (int c = 0; c < catCount; c++) - { - var label = c < categories.Length ? categories[c] : ""; - var lx = ox + (catCount > 1 ? (double)pw * c / (catCount - 1) : pw / 2.0); - sb.AppendLine($" {HtmlEncode(label)}"); - } - for (int t = 0; t <= tickCount; t++) - { - var val = tickInterval * t; - var label = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"; - var ty = oy + ph - (double)ph * t / tickCount; - sb.AppendLine($" {label}"); - } - } - - public void RenderRadarChartSvg(StringBuilder sb, List<(string name, double[] values)> series, - string[] categories, List colors, int svgW, int svgH, int catLabelFontSize = 0) - { - var catCount = Math.Max(categories.Length, series.Max(s => s.values.Length)); - if (catCount < 3) return; - var allValues = series.SelectMany(s => s.values).ToArray(); - if (allValues.Length == 0) return; - var maxVal = allValues.Max(); - if (maxVal <= 0) maxVal = 1; - - var labelSize = catLabelFontSize > 0 ? catLabelFontSize : 11; - var cx = svgW / 2.0; - var cy = svgH / 2.0; - var r = Math.Min(svgW, svgH) * 0.33; - - for (int ring = 1; ring <= 5; ring++) - { - var rr = r * ring / 5; - var gridPoints = new List(); - for (int c = 0; c < catCount; c++) - { - 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($" "); - } - for (int c = 0; c < catCount; c++) - { - var angle = -Math.PI / 2 + 2 * Math.PI * c / catCount; - sb.AppendLine($" "); - } - for (int s = 0; s < series.Count; s++) - { - var points = new List(); - for (int c = 0; c < series[s].values.Length && c < catCount; c++) - { - var angle = -Math.PI / 2 + 2 * Math.PI * c / catCount; - var val = series[s].values[c] / maxVal * r; - points.Add($"{cx + val * Math.Cos(angle):0.#},{cy + val * Math.Sin(angle):0.#}"); - } - if (points.Count > 0) - { - sb.AppendLine($" "); - foreach (var pt in points) - { - var parts = pt.Split(','); - sb.AppendLine($" "); - } - } - } - foreach (var frac in new[] { 0.2, 0.4, 0.6, 0.8, 1.0 }) - { - var val = maxVal * frac; - var tickLabel = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"; - sb.AppendLine($" {tickLabel}"); - } - var labelOffset = Math.Max(18, r * 0.15); - for (int c = 0; c < catCount; c++) - { - var label = c < categories.Length ? categories[c] : ""; - var angle = -Math.PI / 2 + 2 * Math.PI * c / catCount; - var lx = cx + (r + labelOffset) * Math.Cos(angle); - var ly = cy + (r + labelOffset) * Math.Sin(angle); - var anchor = Math.Abs(Math.Cos(angle)) < 0.1 ? "middle" : (Math.Cos(angle) > 0 ? "start" : "end"); - sb.AppendLine($" {HtmlEncode(label)}"); - } - } - - public void RenderBubbleChartSvg(StringBuilder sb, PlotArea plotArea, - List<(string name, double[] values)> series, string[] categories, List colors, - int ox, int oy, int pw, int ph) - { - var bubbleSeries = plotArea.Descendants() - .Where(e => e.LocalName == "ser" && e.Parent?.LocalName == "bubbleChart").ToList(); - - var allX = new List(); var allY = new List(); var allSize = new List(); - var seriesData = new List<(double[] x, double[] y, double[] size)>(); - - for (int s = 0; s < bubbleSeries.Count; s++) - { - var ser = bubbleSeries[s]; - var xVals = ChartHelper.ReadNumericData(ser.Elements().FirstOrDefault(e => e.LocalName == "xVal")) ?? []; - var yVals = ChartHelper.ReadNumericData(ser.Elements().FirstOrDefault(e => e.LocalName == "yVal")) ?? []; - var sizeVals = ChartHelper.ReadNumericData(ser.Elements().FirstOrDefault(e => e.LocalName == "bubbleSize")) ?? yVals; - seriesData.Add((xVals, yVals, sizeVals)); - allX.AddRange(xVals); allY.AddRange(yVals); allSize.AddRange(sizeVals); - } - if (seriesData.Count == 0) - { - foreach (var s in series) - { - var xVals = Enumerable.Range(0, s.values.Length).Select(i => (double)i).ToArray(); - seriesData.Add((xVals, s.values, s.values)); - allX.AddRange(xVals); allY.AddRange(s.values); allSize.AddRange(s.values); - } - } - if (allY.Count == 0) return; - var minX = allX.Min(); var maxX = allX.Max(); if (maxX <= minX) maxX = minX + 1; - var minY = allY.Min(); var maxY = allY.Max(); if (maxY <= minY) maxY = minY + 1; - var maxSz = allSize.Count > 0 ? allSize.Max() : 1; if (maxSz <= 0) maxSz = 1; - var bubbleScaleEl = plotArea.Descendants().FirstOrDefault(); - var bubbleScale = bubbleScaleEl?.Val?.HasValue == true ? bubbleScaleEl.Val.Value / 100.0 : 1.0; - var maxRadius = Math.Min(pw, ph) * 0.12 * bubbleScale; - - for (int t = 1; t <= 4; t++) - { - var gy = oy + ph - (double)ph * t / 4; - sb.AppendLine($" "); - } - sb.AppendLine($" "); - sb.AppendLine($" "); - - for (int s = 0; s < seriesData.Count; s++) - { - var (xVals, yVals, sizeVals) = seriesData[s]; - var count = Math.Min(xVals.Length, yVals.Length); - for (int i = 0; i < count; i++) - { - var bx = ox + ((xVals[i] - minX) / (maxX - minX)) * pw; - var by = oy + ph - ((yVals[i] - minY) / (maxY - minY)) * ph; - var sz = i < sizeVals.Length ? sizeVals[i] : yVals[i]; - var r = Math.Sqrt(Math.Max(0, sz) / maxSz) * maxRadius + maxRadius * 0.15; - sb.AppendLine($" "); - } - } - for (int t = 0; t <= 4; t++) - { - var val = minX + (maxX - minX) * t / 4; - var label = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"; - sb.AppendLine($" {label}"); - } - for (int t = 0; t <= 4; t++) - { - var val = minY + (maxY - minY) * t / 4; - var label = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"; - sb.AppendLine($" {label}"); - } - } - - public void RenderComboChartSvg(StringBuilder sb, PlotArea plotArea, - List<(string name, double[] values)> seriesList, string[] categories, List colors, - int ox, int oy, int pw, int ph) - { - var barIndices = new HashSet(); - var lineIndices = new HashSet(); - var areaIndices = new HashSet(); - var secondaryIndices = new HashSet(); // series on secondary Y-axis - - // Detect which axis IDs are secondary (right-side value axis) - var secondaryAxIds = new HashSet(); - var valAxes = plotArea.Elements().ToList(); - if (valAxes.Count >= 2) - { - // The secondary value axis is the one with axPos="r" - // Use .InnerText because AxisPositionValues.ToString() is broken in Open XML SDK v3+ - foreach (var va in valAxes) - { - var posText = va.GetFirstChild()?.Val?.InnerText; - if (posText == "r") - { - var id = va.GetFirstChild()?.Val?.Value; - if (id.HasValue) secondaryAxIds.Add(id.Value); - } - } - // Fallback: if no explicit right axis found, treat 2nd valAx as secondary - if (secondaryAxIds.Count == 0 && valAxes.Count >= 2) - { - var id = valAxes[1].GetFirstChild()?.Val?.Value; - if (id.HasValue) secondaryAxIds.Add(id.Value); - } - } - - var idx = 0; - foreach (var chartEl in plotArea.ChildElements) - { - var serElements = chartEl.Descendants().Where(e => e.LocalName == "ser").ToList(); - if (serElements.Count == 0) continue; - var localName = chartEl.LocalName.ToLowerInvariant(); - var isBar = localName.Contains("bar"); - var isArea = localName.Contains("area"); - - // Check if this chart group uses a secondary axis - var axIds = chartEl.ChildElements - .Where(e => e.LocalName == "axId") - .Select(e => e.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value) - .Where(v => v != null) - .Select(v => uint.TryParse(v, out var u) ? u : 0) - .ToHashSet(); - var isSecondary = axIds.Overlaps(secondaryAxIds); - - foreach (var _ in serElements) - { - if (isBar) barIndices.Add(idx); - else if (isArea) areaIndices.Add(idx); - else lineIndices.Add(idx); - if (isSecondary) secondaryIndices.Add(idx); - idx++; - } - } - - // Separate primary and secondary values for independent axis scaling - var primaryValues = seriesList.Where((_, i) => !secondaryIndices.Contains(i)).SelectMany(s => s.values).ToArray(); - var secondaryValues = seriesList.Where((_, i) => secondaryIndices.Contains(i)).SelectMany(s => s.values).ToArray(); - if (primaryValues.Length == 0 && secondaryValues.Length == 0) return; - - var priMax = primaryValues.Length > 0 ? primaryValues.Max() : 0; if (priMax <= 0) priMax = 1; - var (priNiceMax, _, _) = ComputeNiceAxis(priMax); - var hasSecondary = secondaryValues.Length > 0; - double secNiceMax = 1; - if (hasSecondary) - { - var secMax = secondaryValues.Max(); if (secMax <= 0) secMax = 1; - (secNiceMax, _, _) = ComputeNiceAxis(secMax); - } - - var catCount = Math.Max(categories.Length, seriesList.Max(s => s.values.Length)); - - // Axes - sb.AppendLine($" "); - sb.AppendLine($" "); - - // Bar series (primary axis) - var barSeries = barIndices.Where(i => i < seriesList.Count).ToList(); - if (barSeries.Count > 0) - { - var groupW = (double)pw / Math.Max(catCount, 1); - var barW = groupW * 0.5 / barSeries.Count; - var gap = (groupW - barSeries.Count * barW) / 2; - for (int bi = 0; bi < barSeries.Count; bi++) - { - var s = barSeries[bi]; - var axMax = secondaryIndices.Contains(s) ? secNiceMax : priNiceMax; - for (int c = 0; c < seriesList[s].values.Length && c < catCount; c++) - { - var val = seriesList[s].values[c]; - var barH = (val / axMax) * ph; - sb.AppendLine($" "); - } - } - } - // Area series - foreach (var s in areaIndices.Where(i => i < seriesList.Count)) - { - var axMax = secondaryIndices.Contains(s) ? secNiceMax : priNiceMax; - var points = new List(); - for (int c = 0; c < seriesList[s].values.Length && c < catCount; c++) - { - var px = ox + (catCount > 1 ? (double)pw * c / (catCount - 1) : pw / 2.0); - points.Add($"{px:0.#},{oy + ph - (seriesList[s].values[c] / axMax) * ph:0.#}"); - } - if (points.Count > 0) - { - var firstX = ox + (catCount > 1 ? 0 : pw / 2.0); - var lastX = ox + (catCount > 1 ? (double)pw * (seriesList[s].values.Length - 1) / (catCount - 1) : pw / 2.0); - sb.AppendLine($" "); - sb.AppendLine($" "); - } - } - // Line series (may use secondary axis) - foreach (var s in lineIndices.Where(i => i < seriesList.Count)) - { - var axMax = secondaryIndices.Contains(s) ? secNiceMax : priNiceMax; - var points = new List(); - for (int c = 0; c < seriesList[s].values.Length && c < catCount; c++) - { - var px = ox + (catCount > 1 ? (double)pw * c / (catCount - 1) : pw / 2.0); - points.Add($"{px:0.#},{oy + ph - (seriesList[s].values[c] / axMax) * ph:0.#}"); - } - if (points.Count > 0) - { - sb.AppendLine($" "); - foreach (var pt in points) - { - var parts = pt.Split(','); - sb.AppendLine($" "); - } - } - } - // Category labels - for (int c = 0; c < catCount; c++) - { - var label = c < categories.Length ? categories[c] : ""; - var lx = ox + (double)pw * c / Math.Max(catCount, 1) + (double)pw / Math.Max(catCount, 1) / 2; - sb.AppendLine($" {HtmlEncode(label)}"); - } - // Primary Y-axis labels (left) - for (int t = 0; t <= AxisTickCount; t++) - { - var val = priNiceMax * t / AxisTickCount; - var label = FormatAxisValue(val); - sb.AppendLine($" {label}"); - } - // Secondary Y-axis labels (overlaid on left in lighter color) - if (hasSecondary) - { - var secFontPx = Math.Max(ValFontPx - 1, CatFontPx); - for (int t = 0; t <= AxisTickCount; t++) - { - var val = secNiceMax * t / AxisTickCount; - var label = FormatAxisValue(val); - sb.AppendLine($" {label}"); - } - } - } - - private static string FormatAxisValue(double val) - { - 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.#}"; - } - - 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) - { - var allValues = series.SelectMany(s => s.values).ToArray(); - if (allValues.Length == 0) return; - var maxVal = allValues.Max(); var minVal = allValues.Min(); - if (maxVal <= minVal) maxVal = minVal + 1; - var range = maxVal - minVal; - var catCount = Math.Max(categories.Length, series.Max(s => s.values.Length)); - - var upColor = "#2ECC71"; var downColor = "#E74C3C"; - var stockChart = plotArea.GetFirstChild(); - if (stockChart != null) - { - var upFill = stockChart.Descendants().FirstOrDefault(e => e.LocalName == "upBars") - ?.Descendants().FirstOrDefault()?.GetFirstChild()?.Val?.Value; - if (upFill != null) upColor = $"#{upFill}"; - var downFill = stockChart.Descendants().FirstOrDefault(e => e.LocalName == "downBars") - ?.Descendants().FirstOrDefault()?.GetFirstChild()?.Val?.Value; - if (downFill != null) downColor = $"#{downFill}"; - } - - sb.AppendLine($" "); - sb.AppendLine($" "); - - var groupW = (double)pw / Math.Max(catCount, 1); - if (series.Count >= 4) - { - for (int c = 0; c < catCount; c++) - { - var open = c < series[0].values.Length ? series[0].values[c] : 0; - var high = c < series[1].values.Length ? series[1].values[c] : 0; - var low = c < series[2].values.Length ? series[2].values[c] : 0; - var close = c < series[3].values.Length ? series[3].values[c] : 0; - var ccx = ox + c * groupW + groupW / 2; - var yHigh = oy + ph - ((high - minVal) / range) * ph; - var yLow = oy + ph - ((low - minVal) / range) * ph; - var yOpen = oy + ph - ((open - minVal) / range) * ph; - var yClose = oy + ph - ((close - minVal) / range) * ph; - var color = close >= open ? upColor : downColor; - var barW = groupW * 0.5; - sb.AppendLine($" "); - var bodyTop = Math.Min(yOpen, yClose); var bodyH = Math.Max(Math.Abs(yOpen - yClose), 1); - sb.AppendLine($" "); - } - } - else { RenderLineChartSvg(sb, series, categories, colors, ox, oy, pw, ph); return; } - - for (int c = 0; c < catCount; c++) - { - var label = c < categories.Length ? categories[c] : ""; - sb.AppendLine($" {HtmlEncode(label)}"); - } - for (int t = 0; t <= 4; t++) - { - var val = minVal + range * t / 4; - var label = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"; - sb.AppendLine($" {label}"); - } - } - - public static (double niceMax, double tickStep, int nTicks) ComputeNiceAxis(double maxVal) - { - if (maxVal <= 0) maxVal = 1; - var mag = Math.Pow(10, Math.Floor(Math.Log10(maxVal))); - var res = maxVal / mag; - var tickStep = res <= 1.5 ? 0.2 * mag : res <= 4 ? 0.5 * mag : res <= 8 ? 1.0 * mag : 2.0 * mag; - var niceMax = Math.Ceiling(maxVal / tickStep) * tickStep; - if (niceMax < maxVal * 1.05) niceMax += tickStep; - var nTicks = (int)Math.Round(niceMax / tickStep); - if (nTicks < 2) nTicks = 2; - return (niceMax, tickStep, nTicks); - } -} diff --git a/src/officecli/Core/ColorMath.cs b/src/officecli/Core/ColorMath.cs index c0284d1e6..b9efca66d 100644 --- a/src/officecli/Core/ColorMath.cs +++ b/src/officecli/Core/ColorMath.cs @@ -8,7 +8,7 @@ namespace OfficeCli.Core; /// Extracted from PowerPointHandler.HtmlPreview.Css and WordHandler.HtmlPreview.Css /// to eliminate duplication. /// -public static class ColorMath +internal static class ColorMath { /// Convert RGB (0-255) to HSL (h: 0-1, s: 0-1, l: 0-1). public static void RgbToHsl(int r, int g, int b, out double h, out double s, out double l) diff --git a/src/officecli/Core/DrawingEffectsHelper.cs b/src/officecli/Core/DrawingEffectsHelper.cs index ef67a6cbf..dc0691537 100644 --- a/src/officecli/Core/DrawingEffectsHelper.cs +++ b/src/officecli/Core/DrawingEffectsHelper.cs @@ -11,7 +11,7 @@ namespace OfficeCli.Core; /// Used by both PPTX and Excel handlers to avoid code duplication. /// Word uses a different namespace (w14) and has its own implementation. /// -public static class DrawingEffectsHelper +internal static class DrawingEffectsHelper { /// /// Build an OuterShadow element from a value string. diff --git a/src/officecli/Core/EmuConverter.cs b/src/officecli/Core/EmuConverter.cs index 92089cb34..c8b24c791 100644 --- a/src/officecli/Core/EmuConverter.cs +++ b/src/officecli/Core/EmuConverter.cs @@ -10,7 +10,7 @@ namespace OfficeCli.Core; /// 1 inch = 914400 EMU, 1 cm = 360000 EMU, 1 pt = 12700 EMU, 1 px = 9525 EMU. /// Accepts: raw EMU integer, or suffixed with cm/in/pt/px. /// -public static class EmuConverter +internal static class EmuConverter { /// /// Parse a dimension/position string into EMU (long). diff --git a/src/officecli/Core/ExcelStyleManager.cs b/src/officecli/Core/ExcelStyleManager.cs index f2696f074..88fb5a1f1 100644 --- a/src/officecli/Core/ExcelStyleManager.cs +++ b/src/officecli/Core/ExcelStyleManager.cs @@ -33,7 +33,7 @@ namespace OfficeCli.Core; /// alignment.vertical - top/center/bottom /// alignment.wrapText - true/false /// -public class ExcelStyleManager +internal class ExcelStyleManager { private readonly WorkbookPart _workbookPart; @@ -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; @@ -179,6 +179,10 @@ public uint ApplyStyle(Cell cell, Dictionary styleProps) alignProps["indent"] = indVal; if (styleProps.TryGetValue("shrinktofit", out var shrinkVal)) alignProps["shrinktofit"] = shrinkVal; + // DEFERRED(xlsx/cell-reading-order) CE10: accept top-level `readingOrder` + // as shorthand for `alignment.readingOrder`. + if (styleProps.TryGetValue("readingorder", out var roVal)) + alignProps["readingorder"] = roVal; if (alignProps.Count > 0) { alignment ??= new Alignment(); @@ -204,6 +208,11 @@ public uint ApplyStyle(Cell cell, Dictionary styleProps) case "shrinktofit" or "shrink": alignment.ShrinkToFit = IsTruthy(value); break; + case "readingorder": + // DEFERRED(xlsx/cell-reading-order) CE10: OOXML values + // 0=context, 1=ltr, 2=rtl. Accept numeric or string forms. + alignment.ReadingOrder = ParseReadingOrder(value); + break; } } applyAlignment = true; @@ -228,10 +237,134 @@ public uint ApplyStyle(Cell cell, Dictionary styleProps) numFmtId, fontId, fillId, borderId, alignment, protection, applyNumFmt, applyFont, applyFill, applyBorder, applyAlignment, applyProtection); - stylesheet.Save(); + // Caller (ExcelHandler) is responsible for saving via _dirtyStylesheet flag. return xfIndex; } + /// + /// Ensure the workbook has the built-in "Hyperlink" cellStyle (builtinId=8) + /// wired up with a blue underlined font, and return the cellXfs index that + /// hyperlink cells should reference via `c/@s`. + /// + /// Creates (idempotently): + /// - a Font with color 0563C1 + underline + /// - a CellStyleFormats xf referencing that font (applyFont=true) + /// - a CellFormats xf inheriting from the cellStyleXf (xfId, applyFont=true) + /// - a CellStyles entry Name="Hyperlink" BuiltinId=8 pointing at the cellStyleXf + /// + /// Returns the cellXfs index to assign to the cell's StyleIndex. + /// + public uint EnsureHyperlinkCellStyle() + { + var stylesheet = EnsureStylesheet(); + + // 1. Reuse existing "Hyperlink" cellStyle if already present. + var cellStyles = stylesheet.CellStyles; + if (cellStyles != null) + { + var existing = cellStyles.Elements() + .FirstOrDefault(cs => cs.BuiltinId?.Value == 8u); + if (existing?.FormatId?.Value != null) + { + // FormatId is the cellStyleXfs index. Find a cellXfs that + // references that cellStyleXf via xfId; if none, create one. + uint styleXfId = existing.FormatId.Value; + var cellFormats = EnsureCellFormats(stylesheet); + int cIdx = 0; + foreach (var xf in cellFormats.Elements()) + { + if (xf.FormatId?.Value == styleXfId + && (xf.ApplyFont?.Value ?? false)) + return (uint)cIdx; + cIdx++; + } + // Create a mirror cellXf pointing at the style xf. + var styleXfs = stylesheet.CellStyleFormats!; + var styleXf = (CellFormat)styleXfs.Elements().ElementAt((int)styleXfId); + var newXf = new CellFormat + { + NumberFormatId = styleXf.NumberFormatId?.Value ?? 0, + FontId = styleXf.FontId?.Value ?? 0, + FillId = styleXf.FillId?.Value ?? 0, + BorderId = styleXf.BorderId?.Value ?? 0, + FormatId = styleXfId, + ApplyFont = true, + }; + cellFormats.Append(newXf); + cellFormats.Count = (uint)cellFormats.Elements().Count(); + return (uint)(cellFormats.Elements().Count() - 1); + } + } + + // 2. Create the hyperlink font (blue + underline), dedup by match. + // Microsoft's canonical hyperlink color is 0563C1 (theme hyperlink). + var hlFontProps = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["color"] = "0563C1", + ["underline"] = "single", + }; + uint hlFontId = GetOrCreateFont(stylesheet, 0, hlFontProps); + + // 3. Ensure CellStyleFormats exists and append a xf for the Hyperlink style. + var cellStyleFormats = stylesheet.CellStyleFormats; + if (cellStyleFormats == null) + { + cellStyleFormats = new CellStyleFormats( + new CellFormat { NumberFormatId = 0, FontId = 0, FillId = 0, BorderId = 0 } + ) { Count = 1 }; + // Insert before CellFormats if possible. + var cf = stylesheet.CellFormats; + if (cf != null) + cf.InsertBeforeSelf(cellStyleFormats); + else + stylesheet.Append(cellStyleFormats); + } + var hlStyleXf = new CellFormat + { + NumberFormatId = 0, + FontId = hlFontId, + FillId = 0, + BorderId = 0, + ApplyFont = true, + }; + cellStyleFormats.Append(hlStyleXf); + cellStyleFormats.Count = (uint)cellStyleFormats.Elements().Count(); + uint hlStyleXfId = (uint)(cellStyleFormats.Elements().Count() - 1); + + // 4. Add a CellFormats (cellXfs) entry that inherits from the style xf. + var cellFormats2 = EnsureCellFormats(stylesheet); + var hlCellXf = new CellFormat + { + NumberFormatId = 0, + FontId = hlFontId, + FillId = 0, + BorderId = 0, + FormatId = hlStyleXfId, + ApplyFont = true, + }; + cellFormats2.Append(hlCellXf); + cellFormats2.Count = (uint)cellFormats2.Elements().Count(); + uint hlCellXfIndex = (uint)(cellFormats2.Elements().Count() - 1); + + // 5. Register the CellStyle name="Hyperlink" builtinId=8. + if (cellStyles == null) + { + cellStyles = new CellStyles( + new CellStyle { Name = "Normal", FormatId = 0, BuiltinId = 0 } + ) { Count = 1 }; + stylesheet.Append(cellStyles); + } + cellStyles.Append(new CellStyle + { + Name = "Hyperlink", + FormatId = hlStyleXfId, + BuiltinId = 8, + }); + cellStyles.Count = (uint)cellStyles.Elements().Count(); + + return hlCellXfIndex; + } + /// /// Identify which keys in a dictionary are style properties. /// @@ -240,15 +373,31 @@ 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" + || lower == "readingorder" || lower.StartsWith("font.") || lower.StartsWith("alignment.") || lower.StartsWith("border."); } + // DEFERRED(xlsx/cell-reading-order) CE10: Parse readingOrder values. + // Accepts numeric (0/1/2) or string (context/contextDependent, ltr/leftToRight, + // rtl/rightToLeft). Returns OOXML val to stamp as readingOrder="N". + private static uint ParseReadingOrder(string value) + { + var v = value.Trim().ToLowerInvariant(); + return v switch + { + "0" or "context" or "contextdependent" => 0u, + "1" or "ltr" or "lefttoright" => 1u, + "2" or "rtl" or "righttoleft" => 2u, + _ => throw new ArgumentException($"Invalid 'readingOrder' value: '{value}'. Expected 0/context, 1/ltr, or 2/rtl.") + }; + } + // ==================== NumberFormat ==================== private static uint GetOrCreateNumFmt(Stylesheet stylesheet, string formatCode) @@ -605,12 +754,18 @@ private static uint GetOrCreateBorder(Stylesheet stylesheet, uint baseBorderId, leftStyle = rightStyle = topStyle = bottomStyle = parsed; } - // Apply "color" shorthand + // Apply "color" shorthand (border.color) and "all.color" (border.all.color) + // Both fan out to all four sides. Per-side colors below can still override. if (borderProps.TryGetValue("color", out var allColor)) { var normalized = NormalizeColor(allColor); leftColor = rightColor = topColor = bottomColor = normalized; } + if (borderProps.TryGetValue("all.color", out var allColor2)) + { + var normalized = NormalizeColor(allColor2); + leftColor = rightColor = topColor = bottomColor = normalized; + } // Apply individual side styles if (borderProps.TryGetValue("left", out var lVal)) leftStyle = ParseBorderStyle(lVal); @@ -788,7 +943,8 @@ private static bool AlignmentMatches(Alignment? a, Alignment? b) (a.WrapText?.Value ?? false) == (b.WrapText?.Value ?? false) && (a.TextRotation?.Value ?? 0) == (b.TextRotation?.Value ?? 0) && (a.Indent?.Value ?? 0) == (b.Indent?.Value ?? 0) && - (a.ShrinkToFit?.Value ?? false) == (b.ShrinkToFit?.Value ?? false); + (a.ShrinkToFit?.Value ?? false) == (b.ShrinkToFit?.Value ?? false) && + (a.ReadingOrder?.Value ?? 0) == (b.ReadingOrder?.Value ?? 0); } // ==================== Helpers ==================== diff --git a/src/officecli/Core/ExtendedPropertiesHandler.cs b/src/officecli/Core/ExtendedPropertiesHandler.cs index 956cba27d..c5237dc9e 100644 --- a/src/officecli/Core/ExtendedPropertiesHandler.cs +++ b/src/officecli/Core/ExtendedPropertiesHandler.cs @@ -9,7 +9,7 @@ namespace OfficeCli.Core; /// /// Shared Extended Properties (app.xml) Get/Set logic for all document types. /// -public static class ExtendedPropertiesHandler +internal static class ExtendedPropertiesHandler { /// /// Populate Format dictionary with extended properties. diff --git a/src/officecli/Core/FileSource.cs b/src/officecli/Core/FileSource.cs new file mode 100644 index 000000000..5c2a18415 --- /dev/null +++ b/src/officecli/Core/FileSource.cs @@ -0,0 +1,144 @@ +// Copyright 2025 OfficeCli (officecli.ai) +// SPDX-License-Identifier: Apache-2.0 + +namespace OfficeCli.Core; + +/// +/// Resolves file sources from local paths, HTTP(S) URLs, or data URIs into a seekable stream. +/// Unified counterpart to for non-image binary files (media, 3D models, CSV, etc.). +/// +/// Supports: +/// - Local file path: "/tmp/model.glb", "C:\media\video.mp4" +/// - HTTP(S) URL: "https://example.com/video.mp4" +/// - Data URI: "data:video/mp4;base64,AAAA..." +/// +/// Returns a MemoryStream (always seekable) and the detected file extension. +/// +internal static class FileSource +{ + /// + /// Resolve a source string into a seekable MemoryStream and file extension (with dot, e.g. ".glb"). + /// Caller is responsible for disposing the returned stream. + /// + public static (MemoryStream Stream, string Extension) Resolve(string source) + { + if (string.IsNullOrWhiteSpace(source)) + throw new ArgumentException("File source cannot be empty"); + + if (source.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + return ResolveDataUri(source); + + if (source.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + source.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + return ResolveUrl(source); + + return ResolveFile(source); + } + + /// + /// Check whether a string looks like a resolvable source (URL, data URI, or existing local file). + /// Useful for distinguishing file/URL sources from inline data (e.g. CSV inline vs file path). + /// + public static bool IsResolvable(string source) + { + if (string.IsNullOrWhiteSpace(source)) return false; + if (source.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) return true; + if (source.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) return true; + if (source.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) return true; + return File.Exists(source); + } + + /// + /// Resolve a source to text lines (for CSV/text data). + /// + public static string[] ResolveLines(string source) + { + var (stream, _) = Resolve(source); + using (stream) + { + using var reader = new StreamReader(stream); + var text = reader.ReadToEnd(); + return text.Split('\n') + .Select(l => l.TrimEnd('\r')) + .ToArray(); + } + } + + private static (MemoryStream, string) ResolveFile(string path) + { + if (!File.Exists(path)) + throw new FileNotFoundException($"File not found: {path}"); + return (new MemoryStream(File.ReadAllBytes(path)), Path.GetExtension(path).ToLowerInvariant()); + } + + private static (MemoryStream, string) ResolveUrl(string url) + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + client.DefaultRequestHeaders.Add("User-Agent", "OfficeCLI"); + + var response = client.GetAsync(url).GetAwaiter().GetResult(); + response.EnsureSuccessStatusCode(); + + var bytes = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult(); + + // Try extension from URL path + var uri = new Uri(url); + var ext = Path.GetExtension(uri.AbsolutePath).ToLowerInvariant(); + + // Fallback: infer from content-type header + if (string.IsNullOrEmpty(ext)) + { + var mime = response.Content.Headers.ContentType?.MediaType; + ext = MimeToExtension(mime); + } + + return (new MemoryStream(bytes), ext); + } + + private static (MemoryStream, string) ResolveDataUri(string dataUri) + { + var commaIdx = dataUri.IndexOf(','); + if (commaIdx < 0) + throw new ArgumentException("Invalid data URI: missing comma separator"); + + var header = dataUri[..commaIdx]; + var data = dataUri[(commaIdx + 1)..]; + + if (!header.Contains("base64", StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("Only base64-encoded data URIs are supported"); + + var mimeStart = header.IndexOf(':') + 1; + var mimeEnd = header.IndexOf(';'); + var mime = mimeEnd > mimeStart ? header[mimeStart..mimeEnd] : header[mimeStart..]; + + var ext = MimeToExtension(mime); + return (new MemoryStream(Convert.FromBase64String(data)), ext); + } + + private static string MimeToExtension(string? mime) + { + if (string.IsNullOrEmpty(mime)) return ""; + return mime.ToLowerInvariant() switch + { + // Video + "video/mp4" => ".mp4", + "video/quicktime" => ".mov", + "video/x-msvideo" or "video/avi" => ".avi", + "video/x-ms-wmv" => ".wmv", + "video/mpeg" => ".mpg", + "video/webm" => ".webm", + // Audio + "audio/mpeg" or "audio/mp3" => ".mp3", + "audio/wav" or "audio/x-wav" => ".wav", + "audio/mp4" or "audio/x-m4a" => ".m4a", + "audio/x-ms-wma" => ".wma", + "audio/ogg" => ".ogg", + // 3D + "model/gltf-binary" => ".glb", + // Text/data + "text/csv" => ".csv", + "text/plain" => ".txt", + _ => "" + }; + } +} diff --git a/src/officecli/Core/FontMetricsReader.cs b/src/officecli/Core/FontMetricsReader.cs index 11683ffc7..7d6141228 100644 --- a/src/officecli/Core/FontMetricsReader.cs +++ b/src/officecli/Core/FontMetricsReader.cs @@ -4,18 +4,17 @@ namespace OfficeCli.Core; /// -/// Lightweight TTF/TTC font metrics reader. Reads only the OS/2 and head tables -/// to extract usWinAscent, usWinDescent, and unitsPerEm — enough to calculate -/// the line-height ratio that Word uses for line spacing. +/// Lightweight TTF/TTC font metrics reader. Reads OS/2, hhea, and head tables +/// to extract usWinAscent, usWinDescent, hheaLineGap, and unitsPerEm — +/// enough to calculate the line-height ratio for CSS rendering. /// -/// Word line spacing formula: actualLineHeight = (winAscent + winDescent) / UPM × fontSize × multiplier -/// CSS line-height is relative to fontSize, so: cssLineHeight = wordMultiplier × ratio -/// where ratio = (winAscent + winDescent) / UPM +/// ratio = (winAscent + winDescent + hheaLineGap) / UPM +/// CSS line-height = lineSpacingMultiplier × ratio /// internal static class FontMetricsReader { /// - /// Line-height ratio = (usWinAscent + usWinDescent) / unitsPerEm. + /// Line-height ratio = (usWinAscent + usWinDescent + hheaLineGap) / unitsPerEm. /// Returns 1.0 if the font file cannot be read. /// public static double GetLineHeightRatio(string fontFilePath, int fontIndex = 0) @@ -41,19 +40,15 @@ public static double GetLineHeightRatio(string fontFilePath, int fontIndex = 0) var winDescent = ReadUInt16BE(reader); var winTotal = (double)(winAscent + winDescent); - // hhea table: ascent at offset 4, descent at offset 6 (int16), lineGap at offset 8 (int16) - // Chrome uses: max(winAscent+winDescent, hheaAscent+|hheaDescent|+hheaLineGap) / UPM - double hheaTotal = 0; + // Include hhea lineGap in the ratio for accurate line height. + int hheaLineGap = 0; if (hheaOffset >= 0) { - fs.Position = hheaOffset + 4; - var hheaAscent = ReadInt16BE(reader); - var hheaDescent = ReadInt16BE(reader); - var hheaLineGap = ReadInt16BE(reader); - hheaTotal = hheaAscent + Math.Abs(hheaDescent) + Math.Max(0, (int)hheaLineGap); + fs.Position = hheaOffset + 8; // lineGap at hhea offset 8 (int16) + hheaLineGap = Math.Max(0, (int)ReadInt16BE(reader)); } - return Math.Max(winTotal, hheaTotal) / upm; + return (winTotal + hheaLineGap) / upm; } catch { @@ -199,8 +194,8 @@ public static double GetRatio(string fontFamily) /// /// Get CSS ascent-override and descent-override percentages for a font. /// These tell the browser to distribute line-height space according to - /// the font's ascent/descent ratio (matching Word's behavior) instead of - /// the CSS default half-leading model. + /// the font's OS/2 ascent/descent ratio instead of the CSS default + /// half-leading model. /// Returns (0, 0) if the font cannot be found. /// public static (double ascentPct, double descentPct) GetAscentDescentOverride(string fontFamily) diff --git a/src/officecli/Core/FormulaEvaluator.Functions.cs b/src/officecli/Core/Formula/FormulaEvaluator.Functions.cs similarity index 81% rename from src/officecli/Core/FormulaEvaluator.Functions.cs rename to src/officecli/Core/Formula/FormulaEvaluator.Functions.cs index 14af4cbc9..8079bc2b6 100644 --- a/src/officecli/Core/FormulaEvaluator.Functions.cs +++ b/src/officecli/Core/Formula/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), @@ -128,7 +129,10 @@ internal partial class FormulaEvaluator "ADDRESS" => EvalAddress(args), "VLOOKUP" => EvalVlookup(args), "HLOOKUP" => EvalHlookup(args), - "LOOKUP" or "OFFSET" or "INDIRECT" => null, // unsupported + "LOOKUP" => EvalLookup(args), + "XLOOKUP" => EvalXlookup(args), + "HYPERLINK" => FR_S(args.Count >= 2 && args[1] is FormulaResult fn ? fn.AsString() : str(0)), + "OFFSET" or "INDIRECT" => null, // unsupported (requires first-class reference values) // ===== Date & Time ===== "TODAY" => FR(DateTime.Today.ToOADate()), "NOW" => FR(DateTime.Now.ToOADate()), @@ -402,6 +406,149 @@ internal partial class FormulaEvaluator return foundCol >= 0 ? (table.Cells[rowIndex - 1, foundCol] ?? FormulaResult.Number(0)) : FormulaResult.Error("#N/A"); } + // LOOKUP(lookup_value, lookup_vector, [result_vector]) + // LOOKUP(lookup_value, array) + // Legacy approximate-match lookup. Assumes lookup_vector is sorted ascending. + // Array form: searches first row if wider than tall (HLOOKUP-like, returns last row); + // otherwise searches first column (VLOOKUP-like, returns last column). + private FormulaResult? EvalLookup(List args) + { + if (args.Count < 2) return null; + var lookupVal = args[0] is FormulaResult r ? r : null; + if (lookupVal == null) return null; + var lv = args[1] is RangeData rd ? rd : null; + if (lv == null) return FormulaResult.Error("#N/A"); + + // Vector form (1D): optionally with a parallel result_vector + if (lv.Rows == 1 || lv.Cols == 1) + { + int found = ApproximateMatchVector(lv, lookupVal); + if (found < 0) return FormulaResult.Error("#N/A"); + + var resultVec = args.Count >= 3 && args[2] is RangeData rv ? rv : lv; + if (resultVec.Rows == 1 && found < resultVec.Cols) + return resultVec.Cells[0, found] ?? FormulaResult.Number(0); + if (resultVec.Cols == 1 && found < resultVec.Rows) + return resultVec.Cells[found, 0] ?? FormulaResult.Number(0); + return FormulaResult.Error("#N/A"); + } + + // Array form: 2D — search first row or first column depending on orientation + if (lv.Cols > lv.Rows) + { + int foundCol = -1; + for (int c = 0; c < lv.Cols; c++) + { + var cell = lv.Cells[0, c]; + if (cell == null) continue; + if (CompareValues(cell, lookupVal) <= 0) foundCol = c; + else break; + } + return foundCol >= 0 + ? (lv.Cells[lv.Rows - 1, foundCol] ?? FormulaResult.Number(0)) + : FormulaResult.Error("#N/A"); + } + else + { + int foundRow = -1; + for (int rr = 0; rr < lv.Rows; rr++) + { + var cell = lv.Cells[rr, 0]; + if (cell == null) continue; + if (CompareValues(cell, lookupVal) <= 0) foundRow = rr; + else break; + } + return foundRow >= 0 + ? (lv.Cells[foundRow, lv.Cols - 1] ?? FormulaResult.Number(0)) + : FormulaResult.Error("#N/A"); + } + } + + private int ApproximateMatchVector(RangeData rd, FormulaResult lookupVal) + { + int found = -1; + if (rd.Rows == 1) + { + for (int c = 0; c < rd.Cols; c++) + { + var cell = rd.Cells[0, c]; + if (cell == null) continue; + if (CompareValues(cell, lookupVal) <= 0) found = c; + else break; + } + } + else + { + for (int rr = 0; rr < rd.Rows; rr++) + { + var cell = rd.Cells[rr, 0]; + if (cell == null) continue; + if (CompareValues(cell, lookupVal) <= 0) found = rr; + else break; + } + } + return found; + } + + // XLOOKUP(lookup_value, lookup_array, return_array, [if_not_found], [match_mode], [search_mode]) + // match_mode: 0=exact (default), -1=exact or next smaller, 1=exact or next larger, 2=wildcard (NYI — treated as exact) + // search_mode: 1=first to last (default), -1=last to first. Binary modes (2/-2) treated as linear. + private FormulaResult? EvalXlookup(List args) + { + if (args.Count < 3) return null; + var lookupVal = args[0] is FormulaResult r ? r : null; + if (lookupVal == null) return null; + var lookupArr = args[1] is RangeData la ? la : null; + var returnArr = args[2] is RangeData ra ? ra : null; + if (lookupArr == null || returnArr == null) return FormulaResult.Error("#N/A"); + + var ifNotFound = args.Count >= 4 && args[3] is FormulaResult inf ? inf : null; + var matchMode = args.Count >= 5 && args[4] is FormulaResult mm ? (int)mm.AsNumber() : 0; + var searchMode = args.Count >= 6 && args[5] is FormulaResult sm ? (int)sm.AsNumber() : 1; + + bool isRow = lookupArr.Rows == 1; + int len = isRow ? lookupArr.Cols : lookupArr.Rows; + int step = searchMode == -1 ? -1 : 1; + int start = step == 1 ? 0 : len - 1; + int end = step == 1 ? len : -1; + + int found = -1; + int bestApprox = -1; + double bestDelta = matchMode == -1 ? double.MinValue : double.MaxValue; + + for (int i = start; i != end; i += step) + { + var cell = isRow ? lookupArr.Cells[0, i] : lookupArr.Cells[i, 0]; + if (cell == null) continue; + var cmp = CompareValues(cell, lookupVal); + if (cmp == 0) { found = i; break; } + if (matchMode == -1 && cmp < 0) + { + var delta = cell.AsNumber() - lookupVal.AsNumber(); + if (delta > bestDelta) { bestDelta = delta; bestApprox = i; } + } + else if (matchMode == 1 && cmp > 0) + { + var delta = cell.AsNumber() - lookupVal.AsNumber(); + if (delta < bestDelta) { bestDelta = delta; bestApprox = i; } + } + } + + if (found < 0) found = bestApprox; + if (found < 0) return ifNotFound ?? FormulaResult.Error("#N/A"); + + // Pull the value at `found` from return_array (same orientation as lookup_array). + if (isRow) + { + if (found < returnArr.Cols) return returnArr.Cells[0, found] ?? FormulaResult.Number(0); + } + else + { + if (found < returnArr.Rows) return returnArr.Cells[found, 0] ?? FormulaResult.Number(0); + } + return FormulaResult.Error("#N/A"); + } + private static FormulaResult? EvalAddress(List args) { if (args.Count < 2) return null; @@ -491,27 +638,44 @@ 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; + + // Helper: extract numeric value from a FormulaResult (null for non-numeric). + // Used by conditional aggregation to keep value-range indices aligned with criteria-range indices + // — AsDoubles/ToDoubleArray collapses non-numerics and shifts indices, which breaks SUMIF/AVERAGEIF alignment. + private static double? AsNumeric(FormulaResult? v) + { + if (v?.IsNumeric == true) return v.NumericValue; + if (v?.IsBool == true) return v.BoolValue!.Value ? 1 : 0; + return 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; - double sum = 0; for (int i = 0; i < range.Length && i < sumRange.Length; i++) if (MatchesCriteria(range[i], criteria)) sum += sumRange[i]; + var range = AsResults(args[0]); var criteria = args[1] is FormulaResult c ? c.AsString() : ""; + var sumRange = args.Count > 2 ? AsResults(args[2]) : range; + 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)) + { var n = AsNumeric(sumRange[i]); if (n.HasValue) sum += n.Value; } return FR(sum); } private FormulaResult? EvalSumIfs(List args) { if (args.Count < 3) return null; - var sumRange = AsDoubles(args[0]); if (sumRange == null) return null; + var sumRange = AsResults(args[0]); if (sumRange == null) return null; double sum = 0; for (int i = 0; i < sumRange.Length; i++) { 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]; + if (match) { var n = AsNumeric(sumRange[i]); if (n.HasValue) sum += n.Value; } } return FR(sum); } @@ -519,20 +683,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,25 +706,28 @@ 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 ? AsResults(args[2]) : range; + 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]); + for (int i = 0; i < range.Length && i < avgRange.Length; i++) + if (MatchesCriteria(range[i], criteria)) + { var n = AsNumeric(avgRange[i]); if (n.HasValue) vals.Add(n.Value); } return vals.Count > 0 ? FR(vals.Average()) : FormulaResult.Error("#DIV/0!"); } private FormulaResult? EvalAverageIfs(List args) { if (args.Count < 3) return null; - var avgRange = AsDoubles(args[0]); if (avgRange == null) return null; + var avgRange = AsResults(args[0]); if (avgRange == null) return null; var vals = new List(); for (int i = 0; i < avgRange.Length; i++) { 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]); + if (match) { var n = AsNumeric(avgRange[i]); if (n.HasValue) vals.Add(n.Value); } } return vals.Count > 0 ? FR(vals.Average()) : FormulaResult.Error("#DIV/0!"); } @@ -568,15 +735,15 @@ internal partial class FormulaEvaluator private FormulaResult? EvalMaxMinIfs(List args, bool isMax) { if (args.Count < 3) return null; - var valRange = AsDoubles(args[0]); if (valRange == null) return null; + var valRange = AsResults(args[0]); if (valRange == null) return null; var vals = new List(); for (int i = 0; i < valRange.Length; i++) { 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]); + if (match) { var n = AsNumeric(valRange[i]); if (n.HasValue) vals.Add(n.Value); } } return vals.Count > 0 ? FR(isMax ? vals.Max() : vals.Min()) : FR(0); } diff --git a/src/officecli/Core/FormulaEvaluator.Helpers.cs b/src/officecli/Core/Formula/FormulaEvaluator.Helpers.cs similarity index 100% rename from src/officecli/Core/FormulaEvaluator.Helpers.cs rename to src/officecli/Core/Formula/FormulaEvaluator.Helpers.cs diff --git a/src/officecli/Core/FormulaEvaluator.cs b/src/officecli/Core/Formula/FormulaEvaluator.cs similarity index 71% rename from src/officecli/Core/FormulaEvaluator.cs rename to src/officecli/Core/Formula/FormulaEvaluator.cs index c3f76d54d..99cc169cd 100644 --- a/src/officecli/Core/FormulaEvaluator.cs +++ b/src/officecli/Core/Formula/FormulaEvaluator.cs @@ -113,6 +113,7 @@ internal partial class FormulaEvaluator private readonly int _depth; private readonly string _sheetKey; // used to qualify cell refs for circular detection private Dictionary? _cellIndex; + private Dictionary? _definedNames; public FormulaEvaluator(SheetData sheetData, WorkbookPart? workbookPart = null) : this(sheetData, workbookPart, new HashSet(StringComparer.OrdinalIgnoreCase), 0, "") { } @@ -137,7 +138,10 @@ private FormulaEvaluator(SheetData sheetData, WorkbookPart? workbookPart, HashSe try { if (_depth == 0) _visiting.Clear(); - return EvaluateFormula(formula); + // Accept both qualified (`_xlfn.SEQUENCE`) and bare (`SEQUENCE`) + // forms. Stored XML uses the qualified form post-R11-2; user code + // and tests still pass the canonical name. + return EvaluateFormula(ModernFunctionQualifier.Unqualify(formula)); } catch { return null; } } @@ -155,7 +159,25 @@ private FormulaEvaluator(SheetData sheetData, WorkbookPart? workbookPart, HashSe private enum TT { Number, String, CellRef, Range, Op, LParen, RParen, Comma, Func, Bool, Compare, SheetCellRef, SheetRange } private record Token(TT Type, string Value); - private static List Tokenize(string formula) + private Dictionary GetDefinedNames() + { + if (_definedNames != null) return _definedNames; + _definedNames = new Dictionary(StringComparer.OrdinalIgnoreCase); + var dns = _workbookPart?.Workbook?.Descendants(); + if (dns != null) + { + foreach (var dn in dns) + { + var name = dn.Name?.Value; + var value = dn.Text; + if (!string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(value)) + _definedNames[name] = value; + } + } + return _definedNames; + } + + private List Tokenize(string formula) { var tokens = new List(); var i = 0; @@ -221,7 +243,28 @@ private static List Tokenize(string formula) } if (char.IsDigit(ch) || ch == '.') - { var ns = ParseNumber(formula, ref i); if (ns != null) { tokens.Add(new Token(TT.Number, ns)); continue; } } + { + var ns = ParseNumber(formula, ref i); + if (ns != null) + { + // Entire-row range like `1:1` or `2:5` — pure digits on both sides of the colon. + // Expand2DRange clamps these to the sheet's populated column range. + if (i < formula.Length && formula[i] == ':' && Regex.IsMatch(ns, @"^\d+$")) + { + var peek = i + 1; + while (peek < formula.Length && char.IsDigit(formula[peek])) peek++; + if (peek > i + 1) + { + var rhsRow = formula[(i + 1)..peek]; + i = peek; + tokens.Add(new Token(TT.Range, $"{ns}:{rhsRow}")); + continue; + } + } + tokens.Add(new Token(TT.Number, ns)); + continue; + } + } if (char.IsLetter(ch) || ch == '_' || ch == '$') { @@ -251,10 +294,45 @@ private static List Tokenize(string formula) { i++; var s2 = i; while (i < formula.Length && (char.IsLetterOrDigit(formula[i]) || formula[i] == '$')) i++; tokens.Add(new Token(TT.Range, $"{stripped}:{StripDollar(formula[s2..i])}")); continue; } + // Entire-column range like `A:A` or `A:C` — left side is letters-only (no row number). + // Expand2DRange clamps these to the sheet's populated row range. + if (i < formula.Length && formula[i] == ':' && Regex.IsMatch(stripped, @"^[A-Z]+$", RegexOptions.IgnoreCase)) + { i++; var s2 = i; while (i < formula.Length && (char.IsLetter(formula[i]) || formula[i] == '$')) i++; + var rhs = StripDollar(formula[s2..i]); + if (Regex.IsMatch(rhs, @"^[A-Z]+$", RegexOptions.IgnoreCase)) + { tokens.Add(new Token(TT.Range, $"{stripped}:{rhs}")); continue; } + throw new NotSupportedException($"Unknown: {stripped}:{rhs}"); } + if (i < formula.Length && formula[i] == '(' && !IsCellRef(stripped)) { tokens.Add(new Token(TT.Func, word.Replace(".", "_").ToUpperInvariant())); continue; } if (IsCellRef(stripped)) { tokens.Add(new Token(TT.CellRef, stripped.ToUpperInvariant())); continue; } + + // Defined name (e.g. `StageTable` → `Data!A2:B7`). + // Resolve to the target range/cell and emit the corresponding token. + var definedNames = GetDefinedNames(); + if (definedNames.TryGetValue(stripped, out var defRef)) + { + var cleaned = StripDollar(defRef).Trim(); + string? dnSheet = null; + var dnCell = cleaned; + var dnBang = cleaned.IndexOf('!'); + if (dnBang > 0) + { + dnSheet = cleaned[..dnBang].Trim('\''); + dnCell = cleaned[(dnBang + 1)..]; + } + if (dnCell.Contains(':')) + tokens.Add(new Token(dnSheet != null ? TT.SheetRange : TT.Range, + dnSheet != null ? $"{dnSheet}!{dnCell}" : dnCell)); + else if (IsCellRef(dnCell)) + tokens.Add(new Token(dnSheet != null ? TT.SheetCellRef : TT.CellRef, + dnSheet != null ? $"{dnSheet}!{dnCell.ToUpperInvariant()}" : dnCell.ToUpperInvariant())); + else + throw new NotSupportedException($"Unknown defined name target: {defRef}"); + continue; + } + throw new NotSupportedException($"Unknown: {word}"); } throw new NotSupportedException($"Unexpected: {ch}"); @@ -413,7 +491,7 @@ private static List Tokenize(string formula) { try { - var evaluated = EvaluateFormula(cell.CellFormula.Text); + var evaluated = EvaluateFormula(ModernFunctionQualifier.Unqualify(cell.CellFormula.Text)); if (evaluated != null) return evaluated; } catch { /* fall through to cached value */ } @@ -448,26 +526,78 @@ private static List Tokenize(string formula) if (_depth > 20) return FormulaResult.Number(0); // depth guard var bangIdx = sheetCellRef.IndexOf('!'); - if (bangIdx < 0 || _workbookPart == null) return FormulaResult.Number(0); + if (bangIdx < 0) return FormulaResult.Number(0); var sheetName = sheetCellRef[..bangIdx]; var cellRef = sheetCellRef[(bangIdx + 1)..]; + var sheetData = GetSheetDataFor(sheetName); + if (sheetData == null) return FormulaResult.Number(0); + + // ResolveCellResult will handle circular detection using qualified ref (sheetKey!cellRef) + var eval = new FormulaEvaluator(sheetData, _workbookPart, _visiting, _depth + 1, sheetName); + return eval.ResolveCellResult(cellRef); + } + + /// + /// Resolve a sheet name to its SheetData (or return _sheetData for null/empty name). + /// + private SheetData? GetSheetDataFor(string? sheetName) + { + if (string.IsNullOrEmpty(sheetName)) return _sheetData; + if (_workbookPart == null) return null; try { - var sheet = _workbookPart.Workbook.Descendants() + var sheet = _workbookPart.Workbook?.Descendants() .FirstOrDefault(s => string.Equals(s.Name?.Value, sheetName, StringComparison.OrdinalIgnoreCase)); - if (sheet?.Id?.Value == null) return FormulaResult.Number(0); - + if (sheet?.Id?.Value == null) return null; var wsPart = (WorksheetPart)_workbookPart.GetPartById(sheet.Id.Value); - var sheetData = wsPart.Worksheet.GetFirstChild(); - if (sheetData == null) return FormulaResult.Number(0); + return wsPart.Worksheet?.GetFirstChild(); + } + catch { return null; } + } - // ResolveCellResult will handle circular detection using qualified ref (sheetKey!cellRef) - var eval = new FormulaEvaluator(sheetData, _workbookPart, _visiting, _depth + 1, sheetName); - return eval.ResolveCellResult(cellRef); + /// + /// Scan a sheet's populated rows to find min/max row index. Returns (0,0) if empty. + /// Used to clamp entire-column references like "A:A" to the actual data area. + /// + private static (int minRow, int maxRow) GetPopulatedRowRange(SheetData sheetData) + { + int minRow = int.MaxValue, maxRow = 0; + foreach (var row in sheetData.Elements()) + { + if (row.RowIndex?.Value is uint idx) + { + var i = (int)idx; + if (i < minRow) minRow = i; + if (i > maxRow) maxRow = i; + } } - catch { return FormulaResult.Number(0); } + return maxRow == 0 ? (0, 0) : (minRow, maxRow); + } + + /// + /// Scan a sheet's populated cells to find min/max column index. Returns (0,0) if empty. + /// Used to clamp entire-row references like "1:1" to the actual data area. + /// + private static (int minCol, int maxCol) GetPopulatedColRange(SheetData sheetData) + { + int minCol = int.MaxValue, maxCol = 0; + foreach (var row in sheetData.Elements()) + foreach (var cell in row.Elements()) + { + if (cell.CellReference?.Value is string cref) + { + var m = Regex.Match(cref, @"^([A-Z]+)\d+$", RegexOptions.IgnoreCase); + if (m.Success) + { + var idx = ColToIndex(m.Groups[1].Value.ToUpperInvariant()); + if (idx < minCol) minCol = idx; + if (idx > maxCol) maxCol = idx; + } + } + } + return maxCol == 0 ? (0, 0) : (minCol, maxCol); } private Cell? FindCell(string cellRef) @@ -497,11 +627,49 @@ private RangeData Expand2DRange(string rangeExpr) var parts = expr.Split(':'); if (parts.Length != 2) return new RangeData(new FormulaResult?[0, 0]); - var (col1, row1) = ParseRef(StripDollar(parts[0])); - var (col2, row2) = ParseRef(StripDollar(parts[1])); - var c1 = ColToIndex(col1); var c2 = ColToIndex(col2); - var r1 = Math.Min(row1, row2); var r2 = Math.Max(row1, row2); - var cMin = Math.Min(c1, c2); var cMax = Math.Max(c1, c2); + + var left = StripDollar(parts[0]); + var right = StripDollar(parts[1]); + int r1, r2, cMin, cMax; + + // Entire-column reference like "A:A" or "A:C" — clamp to populated row range + // of the target sheet (Excel would otherwise scan all 1,048,576 rows). + var leftColOnly = Regex.IsMatch(left, @"^[A-Z]+$", RegexOptions.IgnoreCase); + var rightColOnly = Regex.IsMatch(right, @"^[A-Z]+$", RegexOptions.IgnoreCase); + // Entire-row reference like "1:1" or "2:5" + var leftRowOnly = Regex.IsMatch(left, @"^\d+$"); + var rightRowOnly = Regex.IsMatch(right, @"^\d+$"); + + if (leftColOnly && rightColOnly) + { + var c1 = ColToIndex(left.ToUpperInvariant()); + var c2 = ColToIndex(right.ToUpperInvariant()); + cMin = Math.Min(c1, c2); cMax = Math.Max(c1, c2); + var targetSheet = GetSheetDataFor(sheetPrefix); + if (targetSheet == null) return new RangeData(new FormulaResult?[0, 0]); + var (minRow, maxRow) = GetPopulatedRowRange(targetSheet); + if (maxRow == 0) return new RangeData(new FormulaResult?[0, 0]); + r1 = minRow; r2 = maxRow; + } + else if (leftRowOnly && rightRowOnly) + { + r1 = Math.Min(int.Parse(left), int.Parse(right)); + r2 = Math.Max(int.Parse(left), int.Parse(right)); + var targetSheet = GetSheetDataFor(sheetPrefix); + if (targetSheet == null) return new RangeData(new FormulaResult?[0, 0]); + var (minCol, maxCol) = GetPopulatedColRange(targetSheet); + if (maxCol == 0) return new RangeData(new FormulaResult?[0, 0]); + cMin = minCol; cMax = maxCol; + } + else + { + var (col1, row1) = ParseRef(left); + var (col2, row2) = ParseRef(right); + var c1 = ColToIndex(col1); var c2 = ColToIndex(col2); + r1 = Math.Min(row1, row2); r2 = Math.Max(row1, row2); + cMin = Math.Min(c1, c2); cMax = Math.Max(c1, c2); + } + var rows = r2 - r1 + 1; var cols = cMax - cMin + 1; var cells = new FormulaResult?[rows, cols]; for (int r = 0; r < rows; r++) diff --git a/src/officecli/Core/FormulaParser.cs b/src/officecli/Core/Formula/FormulaParser.cs similarity index 75% rename from src/officecli/Core/FormulaParser.cs rename to src/officecli/Core/Formula/FormulaParser.cs index 85c92f82c..4397ec5f4 100644 --- a/src/officecli/Core/FormulaParser.cs +++ b/src/officecli/Core/Formula/FormulaParser.cs @@ -33,7 +33,7 @@ namespace OfficeCli.Core; /// \alpha \beta \gamma \delta \pi \theta \sigma \omega \lambda \mu \epsilon /// Single-char shorthand: H_2 x^2 (braces optional for single char) /// -public static class FormulaParser +internal static class FormulaParser { // ==================== LaTeX → OMML ==================== @@ -43,6 +43,9 @@ public static OpenXmlElement Parse(string latex) { try { + // Preprocess: fix double-escaped backslashes (common AI/JSON over-escaping) + // \\frac → \frac, \\sqrt → \sqrt, etc. (only when \\ is directly followed by a letter) + latex = FixDoubleEscapedCommands(latex); // Preprocess: convert {a \over b} to \frac{a}{b} latex = RewriteOver(latex); var tokens = Tokenize(latex); @@ -57,6 +60,35 @@ public static OpenXmlElement Parse(string latex) } } + /// + /// Fix double-escaped backslashes from AI/JSON over-escaping. + /// Converts \\cmd → \cmd when \\ is directly followed by a letter sequence. + /// Safe because \\letter is not valid LaTeX (line break immediately followed by + /// a bare word has no mathematical meaning). Legitimate usage like \\ \frac always + /// has a space between the line break and the next command. + /// + private static string FixDoubleEscapedCommands(string latex) + { + // Replace \\ followed directly by a letter with \ (single pass, left to right) + var sb = new System.Text.StringBuilder(latex.Length); + int i = 0; + while (i < latex.Length) + { + if (i + 2 < latex.Length && latex[i] == '\\' && latex[i + 1] == '\\' && char.IsLetter(latex[i + 2])) + { + // Collapse \\ to \ before the command + sb.Append('\\'); + i += 2; // skip both backslashes, the letter will be consumed in the next iteration + } + else + { + sb.Append(latex[i]); + i++; + } + } + return sb.ToString(); + } + /// /// Rewrite LaTeX old-style {numerator \over denominator} to \frac{numerator}{denominator}. /// Handles nested braces correctly. @@ -135,21 +167,40 @@ 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); + // Hex-gate before interpolating into LaTeX: a crafted w:color + // val could close the \textcolor brace group and inject + // \href{…} / \url{…} that KaTeX may honor when trust=true. + if (colorHex != null && IsLaTeXHex(colorHex)) + result = $"\\textcolor{{#{colorHex}}}{{{result}}}"; + return result; } case "sSub": @@ -297,8 +348,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: @@ -802,9 +885,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 @@ -825,7 +942,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) @@ -841,14 +974,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) @@ -1118,10 +1267,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: @@ -1296,6 +1591,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) @@ -1303,6 +1615,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) @@ -1324,6 +1670,15 @@ private static string ArgToReadable(OpenXmlElement? arg) return string.Concat(arg.ChildElements.Select(ToReadableText)); } + private static bool IsLaTeXHex(string s) + { + if (string.IsNullOrEmpty(s)) return false; + if (s.Length is not (3 or 6 or 8)) return false; + foreach (var c in s) + if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) return false; + return true; + } + private static string EscapeLatex(string text) { // Reverse-map special Unicode symbols back to LaTeX commands @@ -1398,9 +1753,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" => "β", @@ -1484,7 +1862,7 @@ private static string ToUnicodeSuperscript(string text) /// /// Exception thrown when FormulaParser fails to parse a LaTeX formula. /// -public class FormulaParseException : Exception +internal class FormulaParseException : Exception { public FormulaParseException(string message, Exception innerException) : base(message, innerException) { } diff --git a/src/officecli/Core/Formula/ModernFunctionQualifier.cs b/src/officecli/Core/Formula/ModernFunctionQualifier.cs new file mode 100644 index 000000000..01779d87a --- /dev/null +++ b/src/officecli/Core/Formula/ModernFunctionQualifier.cs @@ -0,0 +1,149 @@ +// Copyright 2025 OfficeCli (officecli.ai) +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.RegularExpressions; + +namespace OfficeCli.Core; + +/// +/// Prefixes Excel 2016+ dynamic-array and "modern" function names with +/// _xlfn. when emitting OOXML. Excel refuses to resolve bare +/// post-2016 function names (e.g. SEQUENCE(5)#NAME?) +/// unless the XML formula uses the namespaced form (_xlfn.SEQUENCE(5)). +/// Excel strips the prefix back out when displaying the formula to the user, +/// so the round-trip is transparent. +/// +/// Also handles _xlfn._xlws. (worksheet-only namespace) for FILTER +/// and _xlfn.ANCHORARRAY for spilled-range references (A1# stays +/// user-facing; the XML serialization is a separate concern handled by Excel). +/// +public static class ModernFunctionQualifier +{ + // Functions that need just _xlfn. + // Source: MS-XLSX / Excel 2016+ dynamic-array + modern function catalogue. + private static readonly HashSet XlfnFunctions = new(StringComparer.OrdinalIgnoreCase) + { + "SEQUENCE", "SORT", "SORTBY", "UNIQUE", + "XLOOKUP", "XMATCH", + "LET", "LAMBDA", + "IFS", "SWITCH", + "MAXIFS", "MINIFS", + "CONCAT", "TEXTJOIN", + "STOCKHISTORY", + "TEXTBEFORE", "TEXTAFTER", "TEXTSPLIT", + "TAKE", "DROP", + "CHOOSECOLS", "CHOOSEROWS", + "ARRAYTOTEXT", "VALUETOTEXT", + "TOCOL", "TOROW", + "WRAPCOLS", "WRAPROWS", + "EXPAND", + "ANCHORARRAY", + }; + + // Functions that need _xlfn._xlws. (dynamic-array, worksheet-only) + private static readonly HashSet XlwsFunctions = new(StringComparer.OrdinalIgnoreCase) + { + "FILTER", + }; + + // Match a bare function name (identifier followed by '('), not preceded by + // a '.' or alphanumeric (so _xlfn.SEQUENCE and MYSEQUENCE are skipped), + // and not inside a quoted string literal. + private static readonly Regex FunctionCallRegex = new( + @"(? + /// Returns the formula with Excel 2016+ modern function names qualified + /// with _xlfn. / _xlfn._xlws. as required by OOXML. Leaves + /// already-qualified names, older functions, quoted string literals, and + /// non-function identifiers untouched. + /// + public static string Qualify(string formula) + { + if (string.IsNullOrEmpty(formula)) return formula; + + // Walk the string and only rewrite identifiers outside quoted strings. + // Excel formula strings are bounded by '"' with '""' as an escape. + var sb = new System.Text.StringBuilder(formula.Length + 32); + int i = 0; + while (i < formula.Length) + { + char c = formula[i]; + if (c == '"') + { + // Copy the entire string literal verbatim. + sb.Append(c); + i++; + while (i < formula.Length) + { + sb.Append(formula[i]); + if (formula[i] == '"') + { + // escaped "" → consume both, stay in string + if (i + 1 < formula.Length && formula[i + 1] == '"') + { + sb.Append('"'); + i += 2; + continue; + } + i++; + break; + } + i++; + } + continue; + } + + // Outside a string: scan for an identifier-call. + // Use regex-on-substring is awkward; instead detect manually. + if (IsIdentStart(c) && (i == 0 || !IsIdentPrev(formula[i - 1]))) + { + int start = i; + while (i < formula.Length && IsIdentCont(formula[i])) i++; + // Skip whitespace then check for '(' + int j = i; + while (j < formula.Length && formula[j] == ' ') j++; + if (j < formula.Length && formula[j] == '(') + { + var name = formula.Substring(start, i - start); + if (XlwsFunctions.Contains(name)) + sb.Append("_xlfn._xlws.").Append(name); + else if (XlfnFunctions.Contains(name)) + sb.Append("_xlfn.").Append(name); + else + sb.Append(name); + } + else + { + sb.Append(formula, start, i - start); + } + continue; + } + + sb.Append(c); + i++; + } + return sb.ToString(); + } + + /// + /// Inverse of for readback: strips the + /// _xlfn. / _xlfn._xlws. prefix so users see canonical + /// function names instead of the OOXML-internal namespaced form. + /// + public static string Unqualify(string formula) + { + if (string.IsNullOrEmpty(formula)) return formula; + // Longer prefix first so we don't leave _xlws. stragglers. + var s = formula.Replace("_xlfn._xlws.", "", StringComparison.Ordinal); + s = s.Replace("_xlfn.", "", StringComparison.Ordinal); + return s; + } + + private static bool IsIdentStart(char c) => char.IsLetter(c) || c == '_'; + private static bool IsIdentCont(char c) => char.IsLetterOrDigit(c) || c == '_' || c == '.'; + // Prev char that would mean we're in the middle of an existing identifier + // (incl. already-qualified `_xlfn.NAME`). + private static bool IsIdentPrev(char c) => char.IsLetterOrDigit(c) || c == '_' || c == '.'; +} diff --git a/src/officecli/Core/GenericXmlQuery.cs b/src/officecli/Core/GenericXmlQuery.cs index 810cd1033..d9110cfcc 100644 --- a/src/officecli/Core/GenericXmlQuery.cs +++ b/src/officecli/Core/GenericXmlQuery.cs @@ -11,7 +11,7 @@ namespace OfficeCli.Core; /// Traverses the OpenXML element tree matching by XML local name and attributes. /// Used as a fallback when the element type is not recognized by handler-specific (Scheme A) logic. /// -public static class GenericXmlQuery +internal static class GenericXmlQuery { /// /// Query an OpenXML element tree by XML local name and attribute filters. diff --git a/src/officecli/Core/HtmlPreviewHelper.cs b/src/officecli/Core/HtmlPreviewHelper.cs index 253248912..4b9392949 100644 --- a/src/officecli/Core/HtmlPreviewHelper.cs +++ b/src/officecli/Core/HtmlPreviewHelper.cs @@ -8,7 +8,7 @@ namespace OfficeCli.Core; /// /// Shared helpers for HTML preview rendering across PowerPoint, Word, and Excel handlers. /// -public static class HtmlPreviewHelper +internal static class HtmlPreviewHelper { /// /// Load an OpenXML part by its relationship ID and return the content as a base64 data URI. diff --git a/src/officecli/Core/IDocumentHandler.cs b/src/officecli/Core/IDocumentHandler.cs index 5ebef829a..1c5fa3a74 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); @@ -52,6 +88,16 @@ public interface IDocumentHandler : IDisposable /// Validate the document against OpenXML schema and return any errors. /// List Validate(); + + /// + /// Extract the binary payload backing a node (ole/picture/media/embedded) + /// to . Returns true if the node has a + /// backing part and the bytes were written, false if the node has + /// no binary payload (e.g. it is a text paragraph or table cell). + /// receives the part's MIME type on success; + /// receives the number of bytes written. + /// + bool TryExtractBinary(string path, string destPath, out string? contentType, out long byteCount); } public record ValidationError(string ErrorType, string Description, string? Path, string? Part); diff --git a/src/officecli/Core/ImageSource.cs b/src/officecli/Core/ImageSource.cs index 2bde177c0..1668206eb 100644 --- a/src/officecli/Core/ImageSource.cs +++ b/src/officecli/Core/ImageSource.cs @@ -15,7 +15,7 @@ namespace OfficeCli.Core; /// /// Returns a content type string compatible with OpenXmlPart.AddImagePart() (e.g. ImagePartType.Png). /// -public static class ImageSource +internal static class ImageSource { /// /// Resolve an image source string into a stream and content type string. @@ -168,4 +168,137 @@ private static bool TrySniffContentType(byte[] bytes, out PartTypeInfo contentTy return false; } + + /// + /// Try to read pixel (width, height) by parsing image file headers. + /// Cross-platform — pure byte parsing, no System.Drawing / GDI dependency. + /// Supports PNG, JPEG, GIF, BMP. Returns null for any unrecognized or + /// malformed header. The stream position is restored on return. + /// + public static (int Width, int Height)? TryGetDimensions(Stream stream) + { + if (stream is null || !stream.CanSeek || stream.Length < 24) return null; + + var startPos = stream.Position; + try + { + stream.Position = 0; + var header = new byte[30]; + var read = stream.Read(header, 0, header.Length); + if (read < 24) return null; + + // PNG: signature 89 50 4E 47 0D 0A 1A 0A, IHDR width/height at + // big-endian offsets 16..19 and 20..23. + if (header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47) + { + int w = ReadBE32(header, 16); + int h = ReadBE32(header, 20); + return (w > 0 && h > 0) ? (w, h) : null; + } + + // BMP: signature 42 4D, width little-endian at offset 18, height at 22. + // Height may be negative for top-down bitmaps; take the absolute value. + if (header[0] == 0x42 && header[1] == 0x4D && read >= 26) + { + int w = ReadLE32(header, 18); + int h = ReadLE32(header, 22); + if (h < 0) h = -h; + return (w > 0 && h > 0) ? (w, h) : null; + } + + // GIF: signature 47 49 46 38, logical screen width/height are + // little-endian uint16 at offsets 6 and 8. + if (header[0] == 0x47 && header[1] == 0x49 && header[2] == 0x46 && header[3] == 0x38) + { + int w = header[6] | (header[7] << 8); + int h = header[8] | (header[9] << 8); + return (w > 0 && h > 0) ? (w, h) : null; + } + + // JPEG: signature FF D8 — walk markers to find a Start-of-Frame. + if (header[0] == 0xFF && header[1] == 0xD8) + return TryGetJpegDimensions(stream); + + // SVG: XML text — sniff for = 3 && header[0] == 0xEF && header[1] == 0xBB && header[2] == 0xBF) i = 3; + while (i < read && (header[i] == ' ' || header[i] == '\t' + || header[i] == '\r' || header[i] == '\n')) i++; + if (i >= read || header[i] != (byte)'<') return false; + var text = System.Text.Encoding.UTF8.GetString(header, i, read - i).ToLowerInvariant(); + return text.StartsWith(" + (buf[offset] << 24) | (buf[offset + 1] << 16) | (buf[offset + 2] << 8) | buf[offset + 3]; + + private static int ReadLE32(byte[] buf, int offset) => + buf[offset] | (buf[offset + 1] << 8) | (buf[offset + 2] << 16) | (buf[offset + 3] << 24); + + private static (int Width, int Height)? TryGetJpegDimensions(Stream stream) + { + // Skip the SOI marker (FF D8) and walk segment markers looking for + // a Start-of-Frame (SOFn) marker, which holds the true pixel size. + stream.Position = 2; + var buf = new byte[7]; + + while (stream.Position < stream.Length - 2) + { + int b1 = stream.ReadByte(); + if (b1 != 0xFF) return null; + + int b2; + do + { + b2 = stream.ReadByte(); + } while (b2 == 0xFF && stream.Position < stream.Length); + if (b2 < 0) return null; + + // SOFn markers: C0..C3, C5..C7, C9..CB, CD..CF. These all carry + // the frame header (height then width, each big-endian uint16). + bool isSof = (b2 >= 0xC0 && b2 <= 0xC3) + || (b2 >= 0xC5 && b2 <= 0xC7) + || (b2 >= 0xC9 && b2 <= 0xCB) + || (b2 >= 0xCD && b2 <= 0xCF); + if (isSof) + { + if (stream.Read(buf, 0, 7) < 7) return null; + int h = (buf[3] << 8) | buf[4]; + int w = (buf[5] << 8) | buf[6]; + return (w > 0 && h > 0) ? (w, h) : null; + } + + // Start-of-Scan: image data begins, no more metadata. + if (b2 == 0xDA) return null; + + // Any other segment: skip over its declared length. + if (stream.Read(buf, 0, 2) < 2) return null; + int len = (buf[0] << 8) | buf[1]; + if (len < 2) return null; + stream.Position += len - 2; + } + return null; + } } diff --git a/src/officecli/Core/Installer.cs b/src/officecli/Core/Installer.cs index 84a0b9ae3..49a0be05b 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; /// @@ -8,13 +10,14 @@ namespace OfficeCli.Core; /// Usage: /// officecli install [target] — install binary + skills + fallback MCP /// -public static class Installer +internal static class Installer { - private static readonly string BinDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".local", "bin"); + private static readonly string BinDir = OperatingSystem.IsWindows() + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OfficeCli") + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin"); - private static readonly string TargetPath = Path.Combine(BinDir, "officecli"); + private static readonly string TargetPath = Path.Combine(BinDir, + OperatingSystem.IsWindows() ? "officecli.exe" : "officecli"); /// /// MCP targets and the skill aliases that overlap with them. @@ -62,24 +65,40 @@ 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 - if (string.Equals(Path.GetFullPath(src), Path.GetFullPath(TargetPath), StringComparison.Ordinal)) - return; + // Already at target location — record version and skip the copy + var pathComparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (string.Equals(Path.GetFullPath(src), Path.GetFullPath(TargetPath), pathComparison)) + { + RecordInstalledVersion(); + return false; + } + + // Skip binary copy when managed by a package manager (Homebrew, etc.) + if (src.Contains("/Caskroom/") || src.Contains("/Cellar/")) + { + if (!quiet) + Console.WriteLine("Skipping binary install: managed by Homebrew."); + 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 +117,126 @@ 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; + + // 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`) + var pathComparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (string.Equals(Path.GetFullPath(src), Path.GetFullPath(TargetPath), pathComparison)) + 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; - EnsurePath(); + // 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 +249,7 @@ private static bool IsInPath() }); } - private static void EnsurePath() + private static void EnsurePath(bool quiet = false) { if (IsInPath()) return; @@ -125,8 +261,18 @@ private static void EnsurePath() string profilePath; if (OperatingSystem.IsWindows()) { - // Windows: just advise, don't auto-modify registry - Console.WriteLine($" Add {BinDir} to your system PATH."); + // Windows: add to user PATH via registry (same as install.ps1) + var currentPath = Environment.GetEnvironmentVariable("Path", EnvironmentVariableTarget.User) ?? ""; + if (!currentPath.Split(Path.PathSeparator).Contains(BinDir, StringComparer.OrdinalIgnoreCase)) + { + var newPath = string.IsNullOrEmpty(currentPath) ? BinDir : $"{currentPath}{Path.PathSeparator}{BinDir}"; + Environment.SetEnvironmentVariable("Path", newPath, EnvironmentVariableTarget.User); + if (!quiet) + { + Console.WriteLine($" Added {BinDir} to PATH."); + Console.WriteLine($" Restart your terminal to apply changes."); + } + } return; } diff --git a/src/officecli/Core/OleHelper.cs b/src/officecli/Core/OleHelper.cs new file mode 100644 index 000000000..9eb5acdbd --- /dev/null +++ b/src/officecli/Core/OleHelper.cs @@ -0,0 +1,633 @@ +// Copyright 2025 OfficeCli (officecli.ai) +// SPDX-License-Identifier: Apache-2.0 + +using DocumentFormat.OpenXml.Packaging; + +namespace OfficeCli.Core; + +/// +/// Shared helpers for OLE (Object Linking and Embedding) support across +/// Word/Excel/PowerPoint handlers. Covers: +/// - ProgID auto-detection from file extension +/// - Mapping src file extensions to the right embedded PartTypeInfo +/// - A tiny placeholder PNG used as the visual icon for new OLE objects +/// - Populating canonical DocumentNode.Format fields from an embedded part +/// +/// Design: all three handlers consume the same helper so that a single call +/// site governs progId defaults, content-type decisions, and node shape. +/// This keeps the "ole" node schema consistent across .docx/.xlsx/.pptx. +/// +internal static class OleHelper +{ + /// + /// Detect the OLE ProgID to use when the caller did not supply one. + /// Returns identifiers that match what Word/Excel/PowerPoint register + /// at install time on Windows; all three are version-12 ProgIDs that + /// real Office uses for embedded round-tripping. Unknown extensions + /// fall back to "Package", the generic "wrapper for an opaque file" + /// ProgID that any Office host will open via its registered handler. + /// + public static string DetectProgId(string srcPath) + { + var ext = Path.GetExtension(srcPath).TrimStart('.').ToLowerInvariant(); + return ext switch + { + "docx" or "docm" or "dotx" or "dotm" => "Word.Document.12", + "doc" => "Word.Document.8", + "xlsx" or "xlsm" or "xlsb" or "xltx" or "xltm" => "Excel.Sheet.12", + "xls" => "Excel.Sheet.8", + "pptx" or "pptm" or "ppsx" or "ppsm" or "potx" or "potm" => "PowerPoint.Show.12", + "ppt" => "PowerPoint.Show.8", + "pdf" => "AcroExch.Document", + "vsdx" or "vsdm" or "vsd" => "Visio.Drawing", + _ => "Package", + }; + } + + /// + /// Classifier for the content-type axis: Office files get an + /// with the matching OOXML MIME, + /// everything else gets a generic . + /// This mirrors how real Office writes OLE objects — OOXML documents + /// embed as x/vnd.openxmlformats-* package parts, binary or legacy + /// content lands in the generic "oleObject" bucket. + /// + public enum EmbeddingKind + { + /// Use EmbeddedPackagePart (for .docx/.xlsx/.pptx and their macro/template siblings). + Package, + /// Use EmbeddedObjectPart (for arbitrary binaries — PDF, Visio, .bin, etc.). + Object, + } + + /// + /// Decide whether a source file should be embedded as a Package part + /// (strongly-typed OOXML container) or a generic Object part. + /// + public static EmbeddingKind ClassifyKind(string srcPath) + { + var ext = Path.GetExtension(srcPath).TrimStart('.').ToLowerInvariant(); + return ext switch + { + "docx" or "docm" or "dotx" or "dotm" + or "xlsx" or "xlsm" or "xlsb" or "xltx" or "xltm" + or "pptx" or "pptm" or "ppsx" or "ppsm" or "potx" or "potm" + or "sldx" or "sldm" or "xlam" or "ppam" or "thmx" + => EmbeddingKind.Package, + _ => EmbeddingKind.Object, + }; + } + + /// + /// Map an OOXML-family extension to its EmbeddedPackagePartType entry. + /// Returns null if the extension is not a recognized Office format, + /// in which case the caller should use + /// with a generic content type. + /// + public static PartTypeInfo? GetPackagePartTypeInfo(string srcPath) + { + var ext = Path.GetExtension(srcPath).TrimStart('.').ToLowerInvariant(); + return ext switch + { + "docx" => EmbeddedPackagePartType.Docx, + "docm" => EmbeddedPackagePartType.Docm, + "dotx" => EmbeddedPackagePartType.Dotx, + "dotm" => EmbeddedPackagePartType.Dotm, + "xlsx" => EmbeddedPackagePartType.Xlsx, + "xlsm" => EmbeddedPackagePartType.Xlsm, + "xlsb" => EmbeddedPackagePartType.Xlsb, + "xltx" => EmbeddedPackagePartType.Xltx, + "xltm" => EmbeddedPackagePartType.Xltm, + "xlam" => EmbeddedPackagePartType.Xlam, + "pptx" => EmbeddedPackagePartType.Pptx, + "pptm" => EmbeddedPackagePartType.Pptm, + "ppsx" => EmbeddedPackagePartType.Ppsx, + "ppsm" => EmbeddedPackagePartType.Ppsm, + "potx" => EmbeddedPackagePartType.Potx, + "potm" => EmbeddedPackagePartType.Potm, + "ppam" => EmbeddedPackagePartType.Ppam, + "sldx" => EmbeddedPackagePartType.Sldx, + "sldm" => EmbeddedPackagePartType.Sldm, + "thmx" => EmbeddedPackagePartType.Thmx, + _ => null, + }; + } + + /// + /// Add an embedded part (package or generic object) to the given host + /// part, feed it the source file bytes, and return the rel id. + /// Works for any parent that supports embedded parts: MainDocumentPart, + /// WorksheetPart, SlidePart. + /// + public static (string RelId, OpenXmlPart Part) AddEmbeddedPart(OpenXmlPart host, string srcPath, string? hostDocumentPath = null) + { + if (!File.Exists(srcPath)) + throw new FileNotFoundException($"OLE source file not found: {srcPath}"); + + // Warn (don't throw) when the source file is zero bytes and it is NOT + // a self-embed. Self-embed intentionally writes a zero-byte placeholder + // (see CONSISTENCY(ole-self-embed) block below) and should stay silent. + // Non-self-embed 0-byte files usually indicate a truncated or missing + // payload — the user deserves a visible warning so they know the + // embedded bytes are empty. We still proceed with the embed to match + // the existing "silently ignored → visibly ignored" contract. + var isSelfEmbed = hostDocumentPath != null && IsSameFile(srcPath, hostDocumentPath); + if (!isSelfEmbed && new FileInfo(srcPath).Length == 0) + { + Console.Error.WriteLine( + $"Warning: OLE source file is empty (0 bytes): {srcPath}. Document will embed an empty payload."); + } + + var kind = ClassifyKind(srcPath); + OpenXmlPart part; + if (kind == EmbeddingKind.Package) + { + var pt = GetPackagePartTypeInfo(srcPath) + ?? EmbeddedPackagePartType.Xlsx; // should never hit, classified as Package + part = host switch + { + MainDocumentPart mdp => mdp.AddEmbeddedPackagePart(pt), + WorksheetPart wp => wp.AddEmbeddedPackagePart(pt), + SlidePart sp => sp.AddEmbeddedPackagePart(pt), + HeaderPart hp => hp.AddEmbeddedPackagePart(pt), + FooterPart fp => fp.AddEmbeddedPackagePart(pt), + _ => throw new InvalidOperationException( + $"Host part type {host.GetType().Name} does not support embedded packages"), + }; + } + else + { + // Generic: use content-type that Office writes for "Package" OLE. + // The literal OOXML content type for an oleObject is documented as + // "application/vnd.openxmlformats-officedocument.oleObject". + var ct = "application/vnd.openxmlformats-officedocument.oleObject"; + part = host switch + { + MainDocumentPart mdp => mdp.AddEmbeddedObjectPart(ct), + WorksheetPart wp => wp.AddEmbeddedObjectPart(ct), + SlidePart sp => sp.AddEmbeddedObjectPart(ct), + HeaderPart hp => hp.AddEmbeddedObjectPart(ct), + FooterPart fp => fp.AddEmbeddedObjectPart(ct), + _ => throw new InvalidOperationException( + $"Host part type {host.GetType().Name} does not support embedded objects"), + }; + } + + // CONSISTENCY(ole-self-embed): when srcPath refers to the host + // document itself, the SDK holds an exclusive package lock and any + // FileStream.Open() against srcPath fails with IOException. In that + // case feed a zero-byte placeholder payload so the OLE element and + // relationship are still created — callers can Get() the resulting + // node and reopen the document without corruption. The user-facing + // contract is: "self-embed is allowed and does not crash, but the + // embedded bytes are a placeholder rather than the host's literal + // snapshot" (which would require cloning the in-memory package). + if (hostDocumentPath != null && IsSameFile(srcPath, hostDocumentPath)) + { + using var emptyMs = new MemoryStream(Array.Empty()); + part.FeedData(emptyMs); + var selfRelId = host.GetIdOfPart(part); + return (selfRelId, part); + } + + // First try FileShare.ReadWrite so concurrent writers do not crash; + // if that still fails (exclusive package lock / non-self-embed race), + // surface the exception to the caller with an actionable hint — + // commonly it is an officecli resident/watch process holding the + // source file open, in which case `officecli close ` unblocks + // the embed. We keep the detection-free approach (just add the hint + // to every IOException) so the helper stays dependency-free and the + // message is useful even for non-officecli holders. + // + // CONSISTENCY(ole-orphan-cleanup): if FileStream.Open() or FeedData() + // fails after the host part has been created, delete the dangling + // part so we don't leave an orphan EmbeddedPackagePart/EmbeddedObjectPart + // on the host (which would inflate part counts and survive into + // the saved file). The part was just added by AddEmbeddedPackagePart/ + // AddEmbeddedObjectPart above — at this point nothing else references + // it, so DeletePart is safe. + try + { + byte[] srcBytes; + try + { + srcBytes = File.ReadAllBytes(srcPath); + } + catch (IOException ioEx) + { + throw new IOException( + $"Cannot read OLE source file '{srcPath}': the file is locked by another process. " + + $"If an officecli resident or watch process has this file open, run " + + $"'officecli close {srcPath}' first, then retry.", ioEx); + } + + // CONSISTENCY(ole-cfb-wrap): non-Office payloads (.pdf/.txt/binary) + // must be wrapped in a CFB container with a \x01Ole10Native stream. + // Excel rejects the file (0x800A03EC) otherwise. Office OOXML + // payloads are embedded raw via EmbeddedPackagePart — Excel reads + // them directly using the progId (Word.Document.12 / etc). + byte[] payload = kind == EmbeddingKind.Object + ? BuildOle10NativeCfb(srcBytes, Path.GetFileName(srcPath)) + : srcBytes; + + using var payloadStream = new MemoryStream(payload); + part.FeedData(payloadStream); + } + catch + { + try { host.DeletePart(part); } catch { /* best effort */ } + throw; + } + var relId = host.GetIdOfPart(part); + return (relId, part); + } + + /// + /// Returns true if resolves to the same + /// file as . Used by handlers to + /// detect self-embed Set(src=hostPath) so they can substitute a + /// zero-byte or placeholder payload instead of crashing when the SDK + /// holds an exclusive package lock on the host file. + /// + public static bool IsSameFile(string candidatePath, string hostDocumentPath) + { + if (string.IsNullOrEmpty(candidatePath) || string.IsNullOrEmpty(hostDocumentPath)) + return false; + try + { + var a = Path.GetFullPath(candidatePath); + var b = Path.GetFullPath(hostDocumentPath); + return string.Equals(a, b, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + + + /// + /// Populate canonical OLE fields on a DocumentNode from the backing + /// embedded part. Reads content type and byte length so consumers see + /// the same shape regardless of whether the part was EmbeddedObject or + /// EmbeddedPackage. + /// + public static void PopulateFromPart(DocumentNode node, OpenXmlPart part, string? progId = null) + { + node.Type = "ole"; + node.Format["objectType"] = "ole"; + if (!string.IsNullOrEmpty(progId)) + { + node.Format["progId"] = progId; + if (string.IsNullOrEmpty(node.Text)) + node.Text = progId; + } + node.Format["contentType"] = part.ContentType; + try + { + // CONSISTENCY(ole-cfb-wrap): fileSize reports the logical payload + // size (as fed via `add ole src=...`), not the on-disk CFB wrapper + // size. Read the stream fully and unwrap Ole10Native if CFB. + using var s = part.GetStream(); + using var ms = new MemoryStream(); + s.CopyTo(ms); + var raw = ms.ToArray(); + var payload = UnwrapOle10NativeIfCfb(raw); + node.Format["fileSize"] = (long)payload.Length; + } + catch + { + // part stream may be transient during write; ignore + } + } + + /// + /// Minimal valid 1x1 transparent PNG used as the icon preview for + /// newly-inserted OLE objects. Office requires a visual placeholder; + /// the size is irrelevant because the host shape's explicit extents + /// govern display dimensions. This is the same byte sequence used by + /// PowerPointHandler.AddMedia for its poster fallback, known + /// to decode cleanly in every consumer we test against. + /// + public static byte[] PlaceholderIconPng => _placeholderPng; + + // 1x1 transparent PNG, precomputed. Verified valid by the existing + // PowerPointHandler media poster path. + private static readonly byte[] _placeholderPng = + { + 0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A, + 0x00,0x00,0x00,0x0D,0x49,0x48,0x44,0x52, + 0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01,0x08,0x06,0x00,0x00,0x00,0x1F,0x15,0xC4,0x89, + 0x00,0x00,0x00,0x0D,0x49,0x44,0x41,0x54, + 0x08,0xD7,0x63,0x60,0x60,0x60,0x60,0x00,0x00,0x00,0x05,0x00,0x01,0x87,0xA1,0x4E,0xD4, + 0x00,0x00,0x00,0x00,0x49,0x45,0x4E,0x44,0xAE,0x42,0x60,0x82, + }; + + /// + /// Compute default icon dimensions in EMU when the caller didn't supply + /// width/height. 2 inches × 0.75 inches matches what Office uses for a + /// default "show as icon" OLE frame, sized to fit the file-type label. + /// + public const long DefaultOleWidthEmu = 1828800; // 2 inches + public const long DefaultOleHeightEmu = 685800; // 0.75 inches + + /// + /// Validate a COM ProgID string against the well-known Windows COM + /// constraints: the identifier must be 1..39 characters long and must + /// not start with a digit. OLE spec (MSDN "ProgID") is explicit on both + /// rules. Handlers previously accepted arbitrary strings silently; this + /// method gives users an early, actionable error instead of writing an + /// invalid OLE element that Office refuses to open. + /// + public static void ValidateProgId(string progId) + { + if (progId == null) return; + if (progId.Length > 39) + throw new ArgumentException( + $"progId '{progId}' exceeds 39 characters (limit: 39, actual: {progId.Length})."); + if (progId.Length > 0 && char.IsDigit(progId[0])) + throw new ArgumentException( + $"progId '{progId}' cannot start with a digit."); + // COM ProgID character set: letters, digits, '.', '_', '-'. Anything + // else (notably XML-unsafe characters like '<', '>', '&', '"') would + // either corrupt the OOXML progId attribute or be rejected by Office + // on reopen. Reject early with an actionable error instead of letting + // bad bytes land in the package. + foreach (var ch in progId) + { + if (!(char.IsLetterOrDigit(ch) || ch == '.' || ch == '_' || ch == '-')) + throw new ArgumentException( + $"progId '{progId}' contains invalid characters. Only letters, digits, '.', '_', '-' are allowed."); + } + } + + /// + /// Normalize and validate the caller-supplied display property + /// for an OLE object. Canonical values are "icon" (show the file + /// as a clickable icon preview) and "content" (show the embedded + /// file's first page as a live picture). Any other value — including + /// ambiguous synonyms like "embed", "invisible", numbers, + /// or boolean strings — is rejected with + /// so the user is told their input was wrong instead of silently + /// falling back to "icon". Used by Word/PPT Add and Set. + /// + public static string NormalizeOleDisplay(string value) + { + if (value == null) + throw new ArgumentException( + "Invalid display value ''. Expected 'icon' or 'content'."); + var v = value.Trim().ToLowerInvariant(); + if (v == "icon") return "icon"; + if (v == "content") return "content"; + throw new ArgumentException( + $"Invalid display value '{value}'. Expected 'icon' or 'content'."); + } + + /// + /// Known OLE Add/Set property keys shared across Word/PPT/Excel. Used by + /// to surface silently-ignored + /// properties via stderr. Kept as a single union so the three handlers + /// stay consistent — per-handler differences (e.g. Excel's "anchor" + /// range string) are all represented here. + /// + private static readonly HashSet KnownOleProps = new(StringComparer.OrdinalIgnoreCase) + { + "src", "path", "progId", "progid", + "width", "height", "x", "y", + "icon", "display", "name", + "anchor", + }; + + /// + /// Emit a single-line stderr warning for every property key in + /// that is not in . + /// The Add handler signature returns a string and cannot carry a + /// structured warning list back to the caller, so we surface unknown + /// keys via Console.Error to match the "silently ignored → visibly + /// ignored" expectation. No-op when is + /// null or empty. + /// + public static void WarnOnUnknownOleProps(Dictionary? properties) + { + if (properties == null || properties.Count == 0) return; + foreach (var key in properties.Keys) + { + if (!KnownOleProps.Contains(key)) + Console.Error.WriteLine($"warning: unknown ole property '{key}' — ignored"); + } + } + + // ==================== Shared Add helpers ==================== + // + // The following methods extract duplicated boilerplate that previously + // appeared verbatim in Word/Excel/PowerPoint AddOle handlers. + + /// + /// Validate and extract the required src (or path) property + /// from the caller-supplied dictionary. Throws + /// when neither key is present or the + /// value is blank. + /// + public static string RequireSource(Dictionary? properties) + { + properties ??= new Dictionary(); + if (!properties.TryGetValue("src", out var srcPath) + && !properties.TryGetValue("path", out srcPath)) + throw new ArgumentException("'src' property is required for ole type"); + if (string.IsNullOrWhiteSpace(srcPath)) + throw new ArgumentException("'src' property for ole type cannot be empty"); + return srcPath; + } + + /// + /// Resolve the ProgID from explicit property → auto-detected from + /// extension, then validate. Replaces the 4-line fallback chain that + /// was duplicated in every handler. + /// + public static string ResolveProgId(Dictionary properties, string srcPath) + { + var progId = properties.GetValueOrDefault("progId") + ?? properties.GetValueOrDefault("progid") + ?? DetectProgId(srcPath); + ValidateProgId(progId); + return progId; + } + + /// + /// Create the icon preview on the given host + /// part — either from the user-supplied icon property or the + /// default 1×1 placeholder PNG. Returns the relationship id. + /// + public static (ImagePart Part, string RelId) CreateIconPart(OpenXmlPart host, Dictionary properties) + { + ImagePart iconPart; + if (properties.TryGetValue("icon", out var iconPath) && !string.IsNullOrWhiteSpace(iconPath)) + { + var (iconStream, iconType) = ImageSource.Resolve(iconPath); + using var _ = iconStream; + iconPart = AddImagePartTo(host, iconType); + iconPart.FeedData(iconStream); + } + else + { + iconPart = AddImagePartTo(host, ImagePartType.Png); + using var ms = new MemoryStream(PlaceholderIconPng); + iconPart.FeedData(ms); + } + return (iconPart, host.GetIdOfPart(iconPart)); + } + + /// + /// Dispatch to the correct + /// concrete host type. Covers all part types that can own OLE objects. + /// + private static ImagePart AddImagePartTo(OpenXmlPart host, PartTypeInfo type) + => host switch + { + MainDocumentPart mdp => mdp.AddImagePart(type), + HeaderPart hp => hp.AddImagePart(type), + FooterPart fp => fp.AddImagePart(type), + WorksheetPart wp => wp.AddImagePart(type), + SlidePart sp => sp.AddImagePart(type), + DrawingsPart dp => dp.AddImagePart(type), + _ => throw new InvalidOperationException( + $"Host part type {host.GetType().Name} does not support image parts"), + }; + + /// + /// Wrap an arbitrary payload (pdf/txt/binary) in an OLE1.0 Ole10Native + /// stream inside a CFB (Compound File Binary) container. This is the + /// shape Excel expects for generic "Package" OLE embeddings — without + /// it, Excel rejects the host .xlsx at open with 0x800A03EC. + /// + /// Ole10Native stream layout (little-endian): + /// uint32 total size of remaining bytes + /// uint16 version (0x0002) + /// cstring display name (ANSI, null-terminated) + /// cstring original file path (ANSI, null-terminated — may be bogus) + /// uint32 reserved (0) + /// uint32 reserved (0) + /// cstring temp path (ANSI, null-terminated) + /// uint32 payload size + /// byte[] payload + /// + public static byte[] BuildOle10NativeCfb(byte[] payload, string displayName) + { + if (payload == null) throw new ArgumentNullException(nameof(payload)); + if (string.IsNullOrEmpty(displayName)) displayName = "embedded.bin"; + + // Build the \x01Ole10Native stream body. + byte[] streamBody; + using (var ms = new MemoryStream()) + using (var w = new BinaryWriter(ms)) + { + // Use ASCII-safe rendering of the display name. Non-ASCII chars + // get best-effort '?' substitution (ANSI constraint of the OLE1 + // wire format; Excel only displays this). + string ansiName = SanitizeAnsi(displayName); + string fakePath = "C:\\" + ansiName; + + // Reserve 4 bytes for total-size prefix; fill in at the end. + w.Write((uint)0); + w.Write((ushort)0x0002); + WriteCString(w, ansiName); + WriteCString(w, fakePath); + w.Write((uint)0); + w.Write((uint)0); + WriteCString(w, ansiName); + w.Write((uint)payload.Length); + w.Write(payload); + + // Backfill total size = entire body length minus the 4-byte prefix. + long end = ms.Position; + ms.Position = 0; + w.Write((uint)(end - 4)); + ms.Position = end; + streamBody = ms.ToArray(); + } + + // Wrap in a CFB container with a single stream named "\x01Ole10Native". + // Default (non-transacted) mode writes through on dispose; calling + // Commit() in that mode throws NotSupportedException. + using var cfbMs = new MemoryStream(); + using (var root = OpenMcdf.RootStorage.Create(cfbMs, OpenMcdf.Version.V3, OpenMcdf.StorageModeFlags.LeaveOpen)) + { + using var cfbStream = root.CreateStream("\u0001Ole10Native"); + cfbStream.Write(streamBody, 0, streamBody.Length); + } + return cfbMs.ToArray(); + } + + /// + /// If starts with CFB magic bytes and contains a + /// single \x01Ole10Native stream, return the unwrapped payload. + /// Otherwise return unchanged. This is the + /// counterpart to — after we wrap + /// non-Office payloads at embed time, TryExtractBinary has to + /// strip the wrapping so callers see the bytes they fed in. + /// + public static byte[] UnwrapOle10NativeIfCfb(byte[] raw) + { + if (raw == null || raw.Length < 8) return raw ?? Array.Empty(); + // CFB magic: D0 CF 11 E0 A1 B1 1A E1 + if (raw[0] != 0xD0 || raw[1] != 0xCF || raw[2] != 0x11 || raw[3] != 0xE0 || + raw[4] != 0xA1 || raw[5] != 0xB1 || raw[6] != 0x1A || raw[7] != 0xE1) + return raw; + + try + { + using var ms = new MemoryStream(raw, writable: false); + using var root = OpenMcdf.RootStorage.Open(ms, OpenMcdf.StorageModeFlags.LeaveOpen); + if (!root.TryOpenStream("\u0001Ole10Native", out var nativeStream) || nativeStream == null) + return raw; + using (nativeStream) + { + // Parse Ole10Native header: uint32 totalSize, uint16 version, + // cstring name, cstring path, 8 bytes reserved, cstring temp, + // uint32 payloadSize, bytes payload. + using var br = new BinaryReader(nativeStream); + br.ReadUInt32(); // totalSize + br.ReadUInt16(); // version + ReadCString(br); // displayName + ReadCString(br); // origPath + br.ReadUInt32(); // reserved1 + br.ReadUInt32(); // reserved2 + ReadCString(br); // tempPath + uint payloadSize = br.ReadUInt32(); + if (payloadSize > int.MaxValue) return raw; + return br.ReadBytes((int)payloadSize); + } + } + catch + { + return raw; + } + } + + private static string ReadCString(BinaryReader br) + { + var sb = new System.Text.StringBuilder(); + while (true) + { + byte b = br.ReadByte(); + if (b == 0) break; + sb.Append((char)b); + } + return sb.ToString(); + } + + private static void WriteCString(BinaryWriter w, string s) + { + foreach (char c in s) + w.Write(c < 0x80 ? (byte)c : (byte)'?'); + w.Write((byte)0); + } + + private static string SanitizeAnsi(string s) + { + var chars = new char[s.Length]; + for (int i = 0; i < s.Length; i++) + chars[i] = s[i] < 0x80 && s[i] >= 0x20 ? s[i] : '_'; + return new string(chars); + } +} diff --git a/src/officecli/Core/OutputFormatter.cs b/src/officecli/Core/OutputFormatter.cs index ee0b79ebf..70f0e8ba4 100644 --- a/src/officecli/Core/OutputFormatter.cs +++ b/src/officecli/Core/OutputFormatter.cs @@ -8,31 +8,33 @@ namespace OfficeCli.Core; -public enum OutputFormat +internal enum OutputFormat { Text, Json } -public class ViewResult +internal class ViewResult { public string View { get; set; } = ""; public string Content { get; set; } = ""; } -public class NodesResult +internal class NodesResult { + [JsonPropertyName("matches")] public int Matches { get; set; } + [JsonPropertyName("results")] public List Results { get; set; } = new(); } -public class IssuesResult +internal class IssuesResult { public int Count { get; set; } public List Issues { get; set; } = new(); } -public class ErrorResult +internal class ErrorResult { public string Error { get; set; } = ""; public string? Code { get; set; } @@ -41,7 +43,7 @@ public class ErrorResult public string[]? ValidValues { get; set; } } -public class CliWarning +internal class CliWarning { public string Message { get; set; } = ""; public string? Code { get; set; } @@ -51,7 +53,7 @@ public class CliWarning /// /// Thread-static context for capturing warnings during command execution in JSON mode. /// -public static class WarningContext +internal static class WarningContext { [ThreadStatic] private static List? _warnings; @@ -96,7 +98,7 @@ public static void Add(string message, string? code = null, string? suggestion = [JsonSerializable(typeof(string))] internal partial class AppJsonContext : JsonSerializerContext; -public static class OutputFormatter +internal static class OutputFormatter { public static readonly JsonSerializerOptions PublicJsonOptions = new() { @@ -134,7 +136,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 +144,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); @@ -286,7 +309,7 @@ public static string FormatNode(DocumentNode node, OutputFormat format) if (format == OutputFormat.Json) return JsonSerializer.Serialize(node, AppJsonContext.Default.DocumentNode); - return FormatNodeAsText(node, 0); + return FormatNodeAsText(node); } public static string FormatNodes(List nodes, OutputFormat format) @@ -295,13 +318,8 @@ public static string FormatNodes(List nodes, OutputFormat format) return JsonSerializer.Serialize(new NodesResult { Matches = nodes.Count, Results = nodes }, AppJsonContext.Default.NodesResult); var sb = new StringBuilder(); - sb.AppendLine($"Matches: {nodes.Count}"); foreach (var node in nodes) - { - sb.AppendLine($" {node.Path}: {node.Text ?? node.Preview ?? node.Type}"); - foreach (var (key, val) in node.Format) - sb.AppendLine($" {key}: {val}"); - } + sb.AppendLine(FormatNodeOneline(node)); return sb.ToString().TrimEnd(); } @@ -346,28 +364,39 @@ public static string FormatIssues(List issues, OutputFormat forma return sb.ToString().TrimEnd(); } - private static string FormatNodeAsText(DocumentNode node, int indent) + private static string FormatNodeAsText(DocumentNode node) { var sb = new StringBuilder(); - var prefix = new string(' ', indent * 2); - - sb.Append($"{prefix}{node.Path} ({node.Type})"); - if (node.Text != null) sb.Append($" \"{Truncate(node.Text, 60)}\""); - if (node.Style != null) sb.Append($" [{node.Style}]"); - if (node.ChildCount > 0 && node.Children.Count == 0) sb.Append($" ({node.ChildCount} children)"); - sb.AppendLine(); - foreach (var (key, val) in node.Format) - sb.AppendLine($"{prefix} {key}: {val}"); + sb.AppendLine(FormatNodeOneline(node)); foreach (var child in node.Children) - sb.Append(FormatNodeAsText(child, indent + 1)); + sb.Append(FormatNodeAsText(child)); return sb.ToString(); } - private static string Truncate(string s, int maxLen) + /// + /// Single-line format: path (type) "text" children=N style=X key=val key=val ... + /// Grep-friendly: every line is a complete, self-contained record. + /// + private static string FormatNodeOneline(DocumentNode node) { - return s.Length > maxLen ? s[..maxLen] + "..." : s; + var sb = new StringBuilder(); + + sb.Append($"{node.Path} ({node.Type})"); + if (node.Text != null) sb.Append($" \"{node.Text.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", "").Replace("\n", "\\n")}\""); + if (node.ChildCount > 0 && node.Children.Count == 0) sb.Append($" children={node.ChildCount}"); + if (node.Style != null) sb.Append($" style={node.Style}"); + + foreach (var (key, val) in node.Format) + { + // style is already shown via node.Style; skip duplicate + if (key == "style" && node.Style != null) continue; + sb.Append($" {key}={val}"); + } + + return sb.ToString(); } + } diff --git a/src/officecli/Core/ParseHelpers.cs b/src/officecli/Core/ParseHelpers.cs index 4106ea915..69f21a3c9 100644 --- a/src/officecli/Core/ParseHelpers.cs +++ b/src/officecli/Core/ParseHelpers.cs @@ -11,7 +11,7 @@ namespace OfficeCli.Core; /// Accepts flexible user input (e.g. "true", "yes", "1", "on" for booleans; /// "24pt" or "24" for font sizes). /// -public static class ParseHelpers +internal static class ParseHelpers { /// /// Map of common CSS/HTML named colors to 6-digit uppercase hex RGB. @@ -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/PathAliases.cs b/src/officecli/Core/PathAliases.cs index 3eb5c0cd0..6bbd6cae9 100644 --- a/src/officecli/Core/PathAliases.cs +++ b/src/officecli/Core/PathAliases.cs @@ -7,7 +7,7 @@ namespace OfficeCli.Core; /// Maps human-friendly path segment names to their OpenXML local names. /// Allows paths like /body/paragraph[1] in addition to /body/p[1]. /// -public static class PathAliases +internal static class PathAliases { private static readonly Dictionary Aliases = new(StringComparer.OrdinalIgnoreCase) { diff --git a/src/officecli/Core/PivotTableHelper.Cache.cs b/src/officecli/Core/PivotTableHelper.Cache.cs new file mode 100644 index 000000000..85bdbdc28 --- /dev/null +++ b/src/officecli/Core/PivotTableHelper.Cache.cs @@ -0,0 +1,882 @@ +// 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; + +namespace OfficeCli.Core; + +internal static partial class PivotTableHelper +{ + // ==================== 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; + // CONSISTENCY(timezone): Use AssumeUniversal+AdjustToUniversal so the parsed + // DateTime has Kind=Utc and no timezone shift occurs when OpenXML SDK serializes + // it. AssumeLocal would produce Kind=Local which the SDK converts to UTC on + // write, shifting dates by the local UTC offset (e.g. UTC+8 shifts Jan 15 → Jan 14). + if (DateTime.TryParse(raw, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, 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; + // CONSISTENCY(timezone): match TryParseSourceDate — use AssumeUniversal to + // avoid Kind=Local which shifts dates by local UTC offset during serialization. + if (!DateTime.TryParse(raw, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, 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) + { + // Error cells (DataType=Error, e.g. #DIV/0!) must not be treated as string values. + // Return the sentinel so BuildCacheField can emit ErrorItem instead of StringItem. + if (cell.DataType?.Value == CellValues.Error) + return ErrorCellSentinel; + + // 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, + uint?[]? columnNumFmtIds = null) + { + var recordCount = columnData.Count > 0 ? columnData[0].Length : 0; + + // RenderPivotIntoSheet now materializes all pivot cells into sheetData + // (including the N≥3 general renderer), so Excel can display the pre- + // rendered values directly without a cache refresh. Do NOT set + // RefreshOnLoad — it causes Excel to clear the pre-rendered cells and + // attempt a live rebuild from the cache definition. If the rebuild + // fails (e.g. complex N≥3 rowItems structure, security policy blocking + // refresh, or WPS Office's limited pivot support), the user sees an + // empty pivot skeleton instead of the correct data. Real Excel/ + // LibreOffice files likewise ship rendered cells without refreshOnLoad. + var cacheDef = new PivotCacheDefinition + { + CreatedVersion = 3, + MinRefreshableVersion = 3, + RefreshedVersion = 3, + RecordCount = (uint)recordCount + }; + + // 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(); + + // R19-1: per-column source numFmtId (date/currency/etc.) to stamp + // on the cacheField so the pivot renders values with the same + // formatting as the source column. Null means "General" and we + // leave the default in place. + uint? srcNumFmtId = (columnNumFmtIds != null && i < columnNumFmtIds.Length) + ? columnNumFmtIds[i] : null; + + if (derivedByIdx.TryGetValue(i, out var spec)) + { + // Derived date group field — synthesized, no records entries. + var derived = BuildDateGroupDerivedCacheField(fieldName, spec, + out fieldValueIndex[i]); + if (srcNumFmtId.HasValue) derived.NumberFormatId = srcNumFmtId.Value; + cacheFields.AppendChild(derived); + 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); + var baseField = BuildDateGroupBaseCacheField(fieldName, values, parIdx, + out fieldValueIndex[i]); + // Prefer the source column's numFmtId when present; else keep + // the builder's 164u default (yyyy-mm-dd). + if (srcNumFmtId.HasValue) baseField.NumberFormatId = srcNumFmtId.Value; + cacheFields.AppendChild(baseField); + 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; + var plainField = BuildCacheField( + fieldName, values, out fieldNumeric[i], out fieldValueIndex[i], forceStringIndexed); + if (srcNumFmtId.HasValue) plainField.NumberFormatId = srcNumFmtId.Value; + cacheFields.AppendChild(plainField); + } + 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 }; + // Exclude error-cell sentinels from the numeric check — they are neither + // numeric nor regular strings; they will be emitted as ErrorItem elements. + bool valuesAreNumeric = values.Length > 0 && values.All(v => + string.IsNullOrEmpty(v) || v == ErrorCellSentinel + || 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). + bool hasErrorCells = values.Any(v => v == ErrorCellSentinel); + if (isNumeric && values.Any(v => !string.IsNullOrEmpty(v) && v != ErrorCellSentinel)) + { + var nums = values.Where(v => !string.IsNullOrEmpty(v) && v != ErrorCellSentinel) + .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 string items enumerated — records emit or index ref for errors. + } + else + { + var uniqueValues = values + .Where(v => !string.IsNullOrEmpty(v) && v != ErrorCellSentinel) + .Distinct() + .OrderByAxis(v => v) + .ToList(); + // Error cells occupy their own ErrorItem slots after the string items. + var uniqueErrors = values + .Where(v => v == ErrorCellSentinel) + .Distinct() + .ToList(); + int totalCount = uniqueValues.Count + uniqueErrors.Count; + sharedItems.Count = (uint)totalCount; + if (hasErrorCells) + { + sharedItems.ContainsSemiMixedTypes = false; + } + 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; + } + // Emit ErrorItem elements for error-cell sentinels. + for (int i = 0; i < uniqueErrors.Count; i++) + { + sharedItems.AppendChild(new ErrorItem { Val = "#VALUE!" }); + valueIndex[ErrorCellSentinel] = uniqueValues.Count + i; + } + // OOXML requires longText="1" when any string exceeds 255 chars. + // Without it, Excel reports "problem with some content" and repairs. + if (uniqueValues.Any(v => v.Length > 255)) + sharedItems.LongText = true; + } + + 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 (v == ErrorCellSentinel) + { + // Error cell — reference the ErrorItem in sharedItems if indexed, or + // emit MissingItem for numeric fields that have no sharedItems index. + if (fieldValueIndex[f].TryGetValue(v, out var errIdx)) + record.AppendChild(new FieldItem { Val = (uint)errIdx }); + else + 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; + } + +} diff --git a/src/officecli/Core/PivotTableHelper.Definition.cs b/src/officecli/Core/PivotTableHelper.Definition.cs new file mode 100644 index 000000000..26204628d --- /dev/null +++ b/src/officecli/Core/PivotTableHelper.Definition.cs @@ -0,0 +1,1430 @@ +// Copyright 2025 OfficeCli (officecli.ai) +// SPDX-License-Identifier: Apache-2.0 + +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; + +namespace OfficeCli.Core; + +internal static partial class PivotTableHelper +{ + // ==================== 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=4 marks this pivot as "last saved by Excel 2010" + // — the minimum required for Excel to attach slicers. With =3 + // (Excel 2007), Excel silently refuses to bind slicers to the + // pivot table and the slicer drawing renders blank. See + // slicer repro: only the + // needed to change for the slicer to appear. + UpdatedVersion = 4, + ApplyNumberFormats = false, + ApplyBorderFormats = false, + ApplyFontFormats = false, + ApplyPatternFormats = false, + ApplyAlignmentFormats = false, + ApplyWidthHeightFormats = true, + UseAutoFormatting = true, + ItemPrintTitles = true, + MultipleFieldFilters = false, + Indent = 0u, + // 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 = ActiveGrandTotalCaption + }; + + // Layout-dependent attributes on PivotTableDefinition. + // Compact: compact=default(true), outline=true, outlineData=true + // Outline: compact=false, compactData=false, outline=true, outlineData=true + // Tabular: compact=false, compactData=false, outline=default, outlineData=default + var layoutMode = ActiveLayoutMode; + if (layoutMode == "outline" || layoutMode == "tabular") + { + pivotDef.Compact = false; + pivotDef.CompactData = false; + } + if (layoutMode != "tabular") + { + pivotDef.Outline = true; + pivotDef.OutlineData = true; + } + + // Grand totals toggles. Both attributes default to true in ECMA-376 — + // only emit when the user opted out, matching real Excel + LibreOffice + // serialization behavior. + // OOXML attribute mapping (ECMA-376, empirically verified): + // RowGrandTotals = BOTTOM grand total ROW (→ internal _colGrandTotals) + // ColumnGrandTotals = RIGHT grand total COLUMN (→ internal _rowGrandTotals) + if (!ActiveRowGrandTotals) pivotDef.ColumnGrandTotals = false; + if (!ActiveColGrandTotals) pivotDef.RowGrandTotals = 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 = BuildLocation(geom, rowFieldIndices, colFieldIndices, valueFields, filterFieldIndices.Count); + + // 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 }; + // Layout-dependent per-field attributes. + // Compact: compact=default(true), outline=default(true) + // Outline: compact=false, outline=default(true) + // Tabular: compact=false, outline=false + if (layoutMode == "outline" || layoutMode == "tabular") + pf.Compact = false; + if (layoutMode == "tabular") + pf.Outline = 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; + // PV4: persist axis sort as OOXML sortType="ascending|descending" + // on each row pivotField. Previously only affected rendering + // order at write-time; Excel reopens reset to source order. + if (_axisSortMode is string pvSort) + { + if (pvSort.Equals("desc", StringComparison.OrdinalIgnoreCase) + || pvSort.Equals("locale-desc", StringComparison.OrdinalIgnoreCase)) + pf.SortType = FieldSortValues.Descending; + else if (pvSort.Equals("asc", StringComparison.OrdinalIgnoreCase) + || pvSort.Equals("locale", StringComparison.OrdinalIgnoreCase)) + pf.SortType = FieldSortValues.Ascending; + } + // PV5: repeatItemLabels ("Repeat All Item Labels") lands on + // every outer row pivotField (all row fields except the + // innermost — repeating the leaf would be redundant). This + // is the per-field knob; the prior workbook-wide + // fillDownLabelsDefault ext was a default-for-future-pivots, + // not a knob affecting the current pivot. + if (ActiveRepeatItemLabels) + { + int rowFieldPos = rowFieldIndices.IndexOf(i); + bool isInnermost = rowFieldPos == rowFieldIndices.Count - 1; + if (!isInnermost) + { + // x14 extension on pivotField: with + // repeatItemLabels="1" wrapped in . + // The attribute is a 2009 extension, not part of the + // base schema (Open XML SDK 3.4 PivotField has no + // property for it), so we synthesize the ext element. + const string x14Ns = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"; + var pfExt = new PivotFieldExtension + { + Uri = "{2946ED86-A175-432a-8AC1-64E0C546D7DE}" + }; + var x14Pf = new OpenXmlUnknownElement("x14", "pivotField", x14Ns); + x14Pf.SetAttribute(new OpenXmlAttribute("repeatItemLabels", "", "1")); + x14Pf.AddNamespaceDeclaration("x14", x14Ns); + pfExt.AppendChild(x14Pf); + var pfExtLst = pf.GetFirstChild() + ?? pf.AppendChild(new PivotFieldExtensionList()); + pfExtLst.AppendChild(pfExt); + } + } + } + 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); + // CONSISTENCY(subtotals-opts): defaultSubtotal=false on the + // pivotField tells Excel this axis field does not contribute + // an outer-level subtotal. Only emit the attribute when the + // user opted out (default true matches ECMA-376). + if (!ActiveDefaultSubtotal) + pf.DefaultSubtotal = false; + } + if (valueFields.Any(vf => vf.idx == i)) + { + pf.DataField = true; + } + // insertBlankRow: Excel sets this on ALL pivotFields (not just + // axis fields) when "Insert Blank Line After Each Item" is enabled. + if (ActiveInsertBlankRow) + pf.InsertBlankRow = 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; + + // PV5: "Repeat All Item Labels" is set per-pivotField in the loop + // above (pf.RepeatItemLabels = true on outer row fields), replacing + // the previous workbook-wide x14 fillDownLabelsDefault ext which was + // a default-for-future-pivots, not a knob for the current pivot. + + 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(); + + // CONSISTENCY(grand-totals): emit the t="grand" sentinel entries only + // when the corresponding axis toggle is on. rowItems' grand = bottom row + // = _colGrandTotals; colItems' grand = right column = _rowGrandTotals. + bool emitGrand = isRow ? ActiveColGrandTotals : ActiveRowGrandTotals; + + // 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); + } + } + + int extra = 0; + if (emitGrand) + { + // 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); + } + extra = dataFieldCount; + } + + SetAxisCount(container, uniqueCount * dataFieldCount + extra); + 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); + } + + if (emitGrand) + { + // Grand total entry — omitted when the corresponding axis toggle is off. + var grandTotal = new RowItem { ItemType = ItemValues.Grand }; + grandTotal.AppendChild(new MemberPropertyIndex()); + container.AppendChild(grandTotal); + SetAxisCount(container, uniqueCount + 1); + } + else + { + SetAxisCount(container, uniqueCount); + } + 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)); + } + + // 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(); + } + + /// + /// 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 container = new RowItems(); + 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); + + // 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); + + // CONSISTENCY(subtotals-opts): subtotal position depends on layout: + // compact/outline: subtotal BEFORE leaves (subtotalTop) + // tabular: subtotal AFTER leaves (matches Excel-authored tabular pivots) + // + // When subtotals are on: + // compact/outline: outer subtotal row first, then leaves with r=1 + // tabular: first leaf has full (outer,inner) path, rest r=1, + // then subtotal with t="default" after all leaves + // When subtotals are off: first leaf has full path, rest r=1 + bool emitSubtotals = ActiveDefaultSubtotal; + bool tabularMode = ActiveLayoutMode == "tabular"; + int count = 0; + foreach (var (outer, inners) in groups) + { + var outerPivIdx = outerOrder[outer]; + + if (emitSubtotals && !tabularMode) + { + // Compact/outline: outer subtotal row BEFORE leaves + var outerEntry = new RowItem(); + if (outerPivIdx == 0) + outerEntry.AppendChild(new MemberPropertyIndex()); + else + outerEntry.AppendChild(new MemberPropertyIndex { Val = outerPivIdx }); + container.AppendChild(outerEntry); + count++; + } + + // Leaf rows for each inner of this outer. + // In tabular mode (or when subtotals are off), the FIRST leaf of + // each outer group spells the full (outer, inner) path; subsequent + // leaves use r=1. In compact/outline with subtotals, every leaf + // uses r=1 to inherit from the subtotal row above. + for (int li = 0; li < inners.Count; li++) + { + var inner = inners[li]; + var innerPivIdx = innerOrder[inner]; + bool needsFullPath = (tabularMode || !emitSubtotals) && li == 0; + var leafEntry = needsFullPath + ? new RowItem() + : new RowItem { RepeatedItemCount = 1u }; + if (needsFullPath) + { + // Full (outer, inner) path. + if (outerPivIdx == 0) + leafEntry.AppendChild(new MemberPropertyIndex()); + else + leafEntry.AppendChild(new MemberPropertyIndex { Val = outerPivIdx }); + } + if (innerPivIdx == 0) + leafEntry.AppendChild(new MemberPropertyIndex()); + else + leafEntry.AppendChild(new MemberPropertyIndex { Val = innerPivIdx }); + container.AppendChild(leafEntry); + count++; + } + + if (emitSubtotals && tabularMode) + { + // Tabular: outer subtotal row AFTER leaves, with t="default" + var subtotalEntry = new RowItem { ItemType = ItemValues.Default }; + if (outerPivIdx == 0) + subtotalEntry.AppendChild(new MemberPropertyIndex()); + else + subtotalEntry.AppendChild(new MemberPropertyIndex { Val = outerPivIdx }); + container.AppendChild(subtotalEntry); + count++; + } + + // insertBlankRow: emit after each group + if (ActiveInsertBlankRow) + { + var blankEntry = new RowItem { ItemType = ItemValues.Blank }; + if (outerPivIdx == 0) + blankEntry.AppendChild(new MemberPropertyIndex()); + else + blankEntry.AppendChild(new MemberPropertyIndex { Val = outerPivIdx }); + container.AppendChild(blankEntry); + count++; + } + } + + // CONSISTENCY(grand-totals): rowItems' grand entry = bottom grand total + // row, gated on _colGrandTotals. Omit entirely when the user opted out. + if (ActiveColGrandTotals) + { + 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++) + { + 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++; + } + } + + // CONSISTENCY(subtotals-opts): skip the per-outer subtotal column + // block entirely when subtotals are off. Col-axis subtotals use + // t="default" (not the bare row pattern). + if (ActiveDefaultSubtotal) + { + // Outer subtotal columns: K entries with t="default", x v=outer, i=d for d>0. + for (int d = 0; d < K; d++) + { + 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++; + } + } + } + + // CONSISTENCY(grand-totals): colItems' grand entries = right grand total + // column(s), gated on _rowGrandTotals. Omit entirely when the user opted out. + if (ActiveRowGrandTotals) + { + // Grand total columns: K entries with t="grand", x=0, i=d for d>0. + for (int d = 0; d < K; d++) + { + var grand = new RowItem { ItemType = ItemValues.Grand }; + if (d > 0) grand.Index = (uint)d; + grand.AppendChild(new MemberPropertyIndex()); + container.AppendChild(grand); + count++; + } + } + + 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 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); + } + + // 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" + // CONSISTENCY(subtotals-opts): when subtotals are off, skip emitting + // the "subtotal" entries for every internal node. Leaf entries still + // go in as normal, and the grand sentinel is handled below based on + // ActiveRow/ColGrandTotals. + bool emitSubtotals = ActiveDefaultSubtotal; + void Walk(AxisNode node) + { + 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); + if (emitSubtotals) + entries.Add((node.Path, "subtotal")); + } + else if (isRow && node.Depth > 0) + { + // Row axis: subtotal before children. + if (emitSubtotals) + 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); + // CONSISTENCY(grand-totals): row-axis tree grand = bottom row (→ _colGrandTotals); + // col-axis tree grand = right column (→ _rowGrandTotals). Skip the grand + // sentinel entirely when the corresponding toggle is off. + bool emitGrand = isRow ? ActiveColGrandTotals : ActiveRowGrandTotals; + if (emitGrand) + entries.Add((Array.Empty(), "grand")); + + // 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) + { + if (kind == "grand") + { + // 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++) + { + 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; + } + + 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 }); + } + // 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; + } + + SetAxisCount(container, emittedCount); + return container; + } + + /// 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().OrderByAxis(v => v).ToList(); + // CONSISTENCY(subtotals-opts): trailing is the + // field-level subtotal sentinel. Must be omitted when defaultSubtotal=0 + // or Excel rejects with "problem with some content" validation error. + bool emitSub = ActiveDefaultSubtotal; + var items = new Items { Count = (uint)(unique.Count + (emitSub ? 1 : 0)) }; + for (int i = 0; i < unique.Count; i++) + items.AppendChild(new Item { Index = (uint)i }); + if (emitSub) + items.AppendChild(new Item { ItemType = ItemValues.Default }); + 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); + } + + // ==================== Calculated Fields ==================== + // + // PV7: user-declared calculated fields are parsed from properties as + // calculatedField="Name:=Formula" + // calculatedField1="Name1:=Formula1" + // calculatedField2="Name2:=Formula2" + // + // Each one becomes: + // - a + // on the pivotCacheDefinition (formula stored WITHOUT leading '=') + // - a on the pivotTableDefinition + // - a + // - a marker block on the pivotTableDefinition + // (ECMA-376 §18.10.1.13; OpenXml SDK does not model it, so we emit + // it as an unknown element). + // + // No records are written for calculated fields (databaseField="0"), + // matching the date-group-derived pattern — Excel computes the column + // live from the formula when the workbook opens. + internal static void ApplyCalculatedFields( + PivotCacheDefinition cacheDef, + PivotTableDefinition pivotDef, + Dictionary properties) + { + var specs = ParseCalculatedFieldSpecs(properties); + if (specs.Count == 0) return; + + var cacheFields = cacheDef.GetFirstChild() + ?? throw new InvalidOperationException("pivotCacheDefinition is missing "); + var pivotFields = pivotDef.PivotFields + ?? throw new InvalidOperationException("pivotTableDefinition is missing "); + + // Collect existing names (in both cacheFields and calculated specs) + // so we can reject duplicates cleanly. + var existingNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var cf in cacheFields.Elements()) + if (!string.IsNullOrEmpty(cf.Name?.Value)) + existingNames.Add(cf.Name!.Value!); + + // Ensure exists so we can append to it. + var dataFields = pivotDef.DataFields; + if (dataFields == null) + { + dataFields = new DataFields { Count = 0u }; + pivotDef.DataFields = dataFields; + } + + // Accumulate a single block — OOXML requires one + // container, not one per field. + const string xNs = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + var calcFieldsEl = new OpenXmlUnknownElement("x", "calculatedFields", xNs); + + foreach (var (name, formula) in specs) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("calculatedField requires a non-empty name"); + if (string.IsNullOrWhiteSpace(formula)) + throw new ArgumentException($"calculatedField '{name}' requires a non-empty formula"); + if (existingNames.Contains(name)) + throw new ArgumentException( + $"calculatedField '{name}' collides with an existing field name"); + existingNames.Add(name); + + // 1. cacheField + var cleanFormula = formula.TrimStart('=').Trim(); + var cacheField = new CacheField + { + Name = name, + Formula = cleanFormula, + DatabaseField = false, + NumberFormatId = 0u + }; + cacheFields.AppendChild(cacheField); + + // New field index = position of the freshly-appended cacheField. + var newFieldIdx = (uint)(cacheFields.Elements().Count() - 1); + cacheFields.Count = (uint)cacheFields.Elements().Count(); + + // 2. pivotField — empty, DataField=true. + var pf = new PivotField + { + DataField = true, + ShowAll = false + }; + pivotFields.AppendChild(pf); + pivotFields.Count = (uint)pivotFields.Elements().Count(); + + // 3. dataField + var df = new DataField + { + Name = name, + Field = newFieldIdx, + BaseField = 0, + BaseItem = 0u + }; + dataFields.AppendChild(df); + dataFields.Count = (uint)dataFields.Elements().Count(); + + // 4. calculatedFields entry + var calcField = new OpenXmlUnknownElement("x", "calculatedField", xNs); + calcField.SetAttribute(new OpenXmlAttribute("name", "", name)); + calcField.SetAttribute(new OpenXmlAttribute("formula", "", cleanFormula)); + calcFieldsEl.AppendChild(calcField); + } + + // Place after (ECMA-376 schema + // order: ...dataFields, formats, conditionalFormats, chartFormats, + // pivotHierarchies, pivotTableStyleInfo, filters, rowHierarchiesUsage, + // colHierarchiesUsage, extLst). We insert before pivotTableStyle info + // if present so the element lands in a schema-legal slot. + var insertBefore = (OpenXmlElement?)pivotDef.GetFirstChild(); + if (insertBefore != null) + pivotDef.InsertBefore(calcFieldsEl, insertBefore); + else + pivotDef.AppendChild(calcFieldsEl); + } + + /// + /// Parse all calculatedField props from the property bag. Accepts: + /// calculatedField=Name:=Formula + /// calculatedField=Name:Formula (leading '=' optional) + /// calculatedField1=..., calculatedField2=... + /// calculatedFields=[{"name":"X","formula":"..."}, ...] (JSON) + /// + private static List<(string name, string formula)> ParseCalculatedFieldSpecs( + Dictionary properties) + { + var result = new List<(string, string)>(); + + // JSON form first — higher fidelity when user wants multiple specs. + if (properties.TryGetValue("calculatedFields", out var jsonRaw) + && !string.IsNullOrWhiteSpace(jsonRaw)) + { + try + { + using var doc = System.Text.Json.JsonDocument.Parse(jsonRaw); + if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Array) + throw new ArgumentException("'calculatedFields' must be a JSON array"); + foreach (var el in doc.RootElement.EnumerateArray()) + { + if (el.ValueKind != System.Text.Json.JsonValueKind.Object) + throw new ArgumentException("each calculatedFields entry must be a JSON object"); + string? name = null, formula = null; + foreach (var p in el.EnumerateObject()) + { + if (p.NameEquals("name")) name = p.Value.GetString(); + else if (p.NameEquals("formula")) formula = p.Value.GetString(); + } + if (name != null && formula != null) + result.Add((name, formula)); + } + } + catch (System.Text.Json.JsonException ex) + { + throw new ArgumentException($"invalid JSON for calculatedFields: {ex.Message}"); + } + } + + // Numbered + bare calculatedField props (ordinal sort so calculatedField1 + // appears before calculatedField2 regardless of insertion order). + var cfKeys = properties.Keys + .Where(k => System.Text.RegularExpressions.Regex.IsMatch( + k, @"^calculatedField\d*$", System.Text.RegularExpressions.RegexOptions.IgnoreCase)) + .OrderBy(k => k, StringComparer.OrdinalIgnoreCase) + .ToList(); + foreach (var key in cfKeys) + { + var raw = properties[key]; + if (string.IsNullOrWhiteSpace(raw)) continue; + var colonIdx = raw.IndexOf(':'); + if (colonIdx < 0) + throw new ArgumentException( + $"calculatedField '{raw}' must be 'Name:=Formula' (colon-separated)"); + var name = raw[..colonIdx].Trim(); + var formula = raw[(colonIdx + 1)..].Trim(); + result.Add((name, formula)); + } + + return result; + } + +} diff --git a/src/officecli/Core/PivotTableHelper.Parse.cs b/src/officecli/Core/PivotTableHelper.Parse.cs new file mode 100644 index 000000000..43ba86f9b --- /dev/null +++ b/src/officecli/Core/PivotTableHelper.Parse.cs @@ -0,0 +1,720 @@ +// 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; + +namespace OfficeCli.Core; + +internal static partial class PivotTableHelper +{ + + // ==================== Parse Helpers ==================== + + private static List ParseFieldListWithWarning(Dictionary props, string key, string[] headers) + { + var result = ParseFieldList(props, key, headers); + if (result.Count == 0 && props.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value)) + { + var available = string.Join(", ", headers.Where(h => !string.IsNullOrEmpty(h))); + Console.Error.WriteLine($"WARNING: No matching fields for {key}={value}. Available: {available}"); + } + return result; + } + + private static List<(int idx, string func, string showAs, string name)> ParseValueFieldsWithWarning( + Dictionary props, string key, string[] headers) + { + var result = ParseValueFields(props, key, headers); + if (result.Count == 0 && props.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value)) + { + var available = string.Join(", ", headers.Where(h => !string.IsNullOrEmpty(h))); + Console.Error.WriteLine($"WARNING: No matching fields for {key}={value}. Available: {available}"); + } + 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(); + + var result = new List(); + // CONSISTENCY(field-area-dedup): dedup within the same axis (rows/cols/filters). + // A field index must appear at most once per axis; repeated tokens keep the first + // occurrence and skip subsequent ones, matching cross-axis dedup semantics. + var seen = new HashSet(); + foreach (var f in value.Split(',')) + { + var name = f.Trim(); + 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 && seen.Add(idx)) result.Add(idx); + continue; + } + int found = -1; + for (int i = 0; i < headers.Length; i++) + 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}"); + } + if (seen.Add(found)) result.Add(found); + } + return result; + } + + 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, 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 showAs, string name)>(); + var specs = value.Split(','); + for (int specIndex = 0; specIndex < specs.Length; specIndex++) + { + 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(':'); + 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; + // 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 (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, 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. + /// + // CONSISTENCY(enum-innertext): switch over EnumValue.InnerText (the + // OOXML attribute literal), not over C# enum-value equality. OpenXML SDK + // v3 exposes ShowDataAsValues.Percent AND ShowDataAsValues.PercentOfTotal + // as distinct values; XML "percent" deserializes to .Percent, and + // EnumValue.ToString() yields garbage like "showdataasvalues { }" + // (same class of bug as LineSpacingRuleValues.Auto.ToString() documented + // in CLAUDE.md "Known API Quirks"). Reading InnerText sidesteps both + // traps — no silent enum-fall-through, no SDK ToString() footguns. + private static string ShowDataAsToCanonicalToken(EnumValue? showDataAs) + { + var raw = showDataAs?.InnerText ?? ""; + return raw switch + { + "" or "normal" => "normal", + // OOXML has two distinct ShowDataAs enum values ("percent" and + // "percentOfTotal") that share the same canonical snake_case + // output — matching ParseShowDataAs which already accepts both + // input aliases for .PercentOfTotal. Keep the longer-form + // canonical so pre-existing round-trip assertions (which expect + // "percent_of_total") stay green. + "percent" or "percentOfTotal" => "percent_of_total", + "percentOfRow" => "percent_of_row", + "percentOfCol" => "percent_of_col", + "runTotal" => "running_total", + "difference" => "difference", + "percentDiff" => "percent_diff", + "index" => "index", + _ => raw, + }; + } + + /// + /// 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" 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; + while (i < cellRef.Length && char.IsLetter(cellRef[i])) i++; + var col = cellRef[..i].ToUpperInvariant(); + var row = int.TryParse(cellRef[i..], out var r) ? r : 1; + return (col, row); + } + + private static int ColToIndex(string col) + { + int result = 0; + foreach (var c in col.ToUpperInvariant()) + 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/PivotTableHelper.Readback.cs b/src/officecli/Core/PivotTableHelper.Readback.cs new file mode 100644 index 000000000..cc24f9a51 --- /dev/null +++ b/src/officecli/Core/PivotTableHelper.Readback.cs @@ -0,0 +1,537 @@ +// 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; + +namespace OfficeCli.Core; + +internal static partial class PivotTableHelper +{ + // ==================== Readback ==================== + + 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; + + // 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.InnerText != "normal" && !string.IsNullOrEmpty(df.ShowDataAs.InnerText)) + { + node.Format[$"dataField{i + 1}.showAs"] = ShowDataAsToCanonicalToken(df.ShowDataAs); + } + } + } + // CONSISTENCY(pivot-sort-readonly): the 'sortByField' Format key + // (emitted below after the subtotals block) surfaces per-pivotField + // SortType from real-world files (e.g. Excel-authored pivots). The + // writer still applies 'sort=' globally and does not persist per-field + // AutoSort — so Set can't round-trip 'sortByField'. See + // CONSISTENCY(pivot-sort-store) v2 candidate for full AutoSort support. + + // Layout form readback. Detect from definition-level compact attribute + // and per-pivotField outline attribute. + // Compact = compact=true or absent (default), outline fields = default + // Outline = compact=false, pivotField outline = default (true) + // Tabular = compact=false, pivotField outline = false + { + bool defCompact = pivotDef.Compact?.Value ?? true; + string layout = "compact"; + if (!defCompact) + { + var firstAxisPf = pivotFields?.Elements() + .FirstOrDefault(pf => pf.Axis != null); + bool fieldOutline = firstAxisPf?.Outline?.Value ?? true; + layout = fieldOutline ? "outline" : "tabular"; + } + node.Format["layout"] = layout; + } + + // grandTotalCaption readback + { + var caption = pivotDef.GrandTotalCaption?.Value; + if (!string.IsNullOrEmpty(caption) && caption != "Grand Total") + node.Format["grandTotalCaption"] = caption; + } + + // insertBlankRow readback — check outermost row axis field + if (pivotFields != null) + { + var rowAxisFields = pivotFields.Elements() + .Where(pf => pf.Axis?.Value == PivotTableAxisValues.AxisRow) + .ToList(); + if (rowAxisFields.Count > 0 && rowAxisFields[0].InsertBlankRow?.Value == true) + node.Format["blankRows"] = "true"; + } + + // repeatItemLabels (fillDownLabelsDefault in x14:pivotTableDefinition) + { + bool repeatLabels = false; + var extLst = pivotDef.GetFirstChild(); + if (extLst != null) + { + foreach (var ext in extLst.Elements()) + { + foreach (var child in ext.ChildElements) + { + if (child.LocalName != "pivotTableDefinition") continue; + // Open XML SDK v3's GetAttribute(local, ns) throws + // KeyNotFoundException when the attribute is absent — + // which is the common case here since Excel only + // emits fillDownLabelsDefault when the user enables + // "Repeat Item Labels". Enumerate attributes and + // tolerate absence instead. + var attr = child.GetAttributes() + .FirstOrDefault(a => a.LocalName == "fillDownLabelsDefault"); + if (attr.Value == "1") + { + repeatLabels = true; + break; + } + } + if (repeatLabels) break; + } + } + if (repeatLabels) + node.Format["repeatLabels"] = "true"; + } + + // 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"; + + // R20-1: subtotals readback. Inspect axis pivotFields (those with + // Axis != null) and aggregate their DefaultSubtotal flags. + // - All false → "off" (user set subtotals=off) + // - All true / missing → "on" (default OOXML behaviour) + // - Mixed → omit key (per-field subtotals is a v2 feature) + // Canonical key "subtotals" matches Add/Set input form. + if (pivotFields != null) + { + var axisFields = pivotFields.Elements() + .Where(pf => pf.Axis != null) + .ToList(); + if (axisFields.Count > 0) + { + // DefaultSubtotal attribute defaults to true when absent (ECMA-376 § 18.10.1.69). + var defaultSubtotalValues = axisFields + .Select(pf => pf.DefaultSubtotal?.Value ?? true) + .ToList(); + bool allOff = defaultSubtotalValues.All(v => !v); + bool allOn = defaultSubtotalValues.All(v => v); + if (allOff) + node.Format["subtotals"] = "off"; + else if (allOn) + node.Format["subtotals"] = "on"; + // mixed: omit key (v2 per-field subtotals feature) + } + + // R27-1: three per-pivotField readback surfaces, each emitted as + // a csv of field-name or field-name:value pairs. All three keys + // are read-only — officecli's writer doesn't yet round-trip any + // of them, and Add/Set inputs remain untouched (see + // CONSISTENCY(pivot-sort-readonly), CONSISTENCY(collapsed-items-readonly), + // CONSISTENCY(axis-datafield-readonly) below). The purpose is to + // surface real-world OOXML pivot features during query/get so + // users inspecting files authored in Excel (or ClosedXML) don't + // see silent information loss. + // + // Key names intentionally distinct from the Add/Set input form + // ('sort=asc' is a global writer flag; 'sortByField: Name:asc' + // is the per-field readback). Mirrors how 'rows'/'cols'/'filters' + // emit name csvs while Add/Set takes 'rows=' etc. + var pivotFieldList = pivotFields.Elements().ToList(); + var sortParts = new List(); + var collapsedFieldNames = new List(); + var axisAsDataFieldNames = new List(); + for (int pfIdx = 0; pfIdx < pivotFieldList.Count; pfIdx++) + { + var pf = pivotFieldList[pfIdx]; + // CONSISTENCY(enum-innertext): SortType uses InnerText, not + // enum equality, for the same reason as ShowDataAsToCanonicalToken. + var sortRaw = pf.SortType?.InnerText ?? ""; + if (sortRaw == "ascending" || sortRaw == "descending") + { + var name = ResolveFieldName((uint)pfIdx); + sortParts.Add($"{name}:{(sortRaw == "ascending" ? "asc" : "desc")}"); + } + + // CONSISTENCY(collapsed-items-readonly): item-level sd="0" + // (showDetail=false) is the OOXML encoding for a collapsed + // pivot row. Add/Set does not yet write these, so readback + // is purely informational. Emitted as a csv of field names + // that have at least one collapsed item. NOTE: the OpenXML + // SDK exposes this attribute as Item.HideDetails (named after + // the "hide" semantic while the XML attribute is 'sd' which + // is "showDetail") — so we read the raw attribute value via + // GetAttribute to avoid depending on the SDK's potentially + // surprising property-name translation. + var items = pf.Items; + if (items != null) + { + bool hasCollapsed = false; + foreach (var it in items.Elements()) + { + string sdVal; + try { sdVal = it.GetAttribute("sd", "").Value ?? ""; } + catch (KeyNotFoundException) { sdVal = ""; } + if (sdVal == "0" || sdVal.Equals("false", StringComparison.OrdinalIgnoreCase)) + { + hasCollapsed = true; + break; + } + } + if (hasCollapsed) + collapsedFieldNames.Add(ResolveFieldName((uint)pfIdx)); + } + + // CONSISTENCY(axis-datafield-readonly): pivotField's + // dataField="1" attribute by itself is the standard marker + // for any field referenced in , so it alone is + // NOT interesting. The dual-role case — the one worth + // surfacing — is when the same pivotField is ALSO on an + // axis (rows/cols), meaning it's used both as a row/col + // label AND as a data aggregate. ECMA-376 § 18.10.1.69. + // Pure readback; writer does not currently set this flag. + if (pf.Axis != null && pf.DataField?.Value == true) + axisAsDataFieldNames.Add(ResolveFieldName((uint)pfIdx)); + } + if (sortParts.Count > 0) + node.Format["sortByField"] = string.Join(",", sortParts); + if (collapsedFieldNames.Count > 0) + node.Format["collapsedFields"] = string.Join(",", collapsedFieldNames); + if (axisAsDataFieldNames.Count > 0) + node.Format["axisAsDataField"] = string.Join(",", axisAsDataFieldNames); + } + } + + /// + /// 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; + + // 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); + + // 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) + { + pivotFields = new PivotFields(); + pivotDef.PivotFields = pivotFields; + } + var existingPfList = pivotFields.Elements().ToList(); + // Drop trailing PivotFields beyond the new column count. + while (existingPfList.Count > headers.Length) + { + existingPfList[existingPfList.Count - 1].Remove(); + existingPfList.RemoveAt(existingPfList.Count - 1); + } + // Append fresh PivotFields for any newly-added columns. + while (existingPfList.Count < headers.Length) + { + var pf = new PivotField { ShowAll = false }; + pivotFields.AppendChild(pf); + existingPfList.Add(pf); + } + // 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) + { + pf.RemoveAllChildren(); + } + pivotFields.Count = (uint)headers.Length; + + // 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(); + } + +} diff --git a/src/officecli/Core/PivotTableHelper.Render.cs b/src/officecli/Core/PivotTableHelper.Render.cs new file mode 100644 index 000000000..87566985d --- /dev/null +++ b/src/officecli/Core/PivotTableHelper.Render.cs @@ -0,0 +1,2521 @@ +// Copyright 2025 OfficeCli (officecli.ai) +// SPDX-License-Identifier: Apache-2.0 + +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; + +namespace OfficeCli.Core; + +internal static partial class PivotTableHelper +{ + // ==================== 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). + // + // Non-compact layouts (outline/tabular) always route through the general + // renderer because specialized renderers hardcode compact-mode column + // placement (all row labels in one column). The general renderer handles + // multi-column row labels for outline/tabular. + if (ActiveLayoutMode != "compact" && valueFields.Count >= 1) + { + RenderGeneralPivot(targetSheet, position, headers, columnData, + rowFieldIndices, colFieldIndices, valueFields, filterFieldIndices, valueStyleIds); + return; + } + + // Compact + multi-row + subtotals OFF also routes through the general + // renderer. The N=2 specialized RenderMultiRowPivot lacks the + // compactLabelRows path (label-only parent rows + indented children) and + // falls back to an "outer / inner" string-concat hack on the first + // leaf, which doesn't match Excel. The general renderer treats N≥2 + // compact+nosubtotals uniformly via its compactLabelRows branch. + if (ActiveLayoutMode == "compact" && !ActiveDefaultSubtotal + && rowFieldIndices.Count >= 2 && valueFields.Count >= 1) + { + RenderGeneralPivot(targetSheet, position, headers, columnData, + rowFieldIndices, colFieldIndices, valueFields, filterFieldIndices, valueStyleIds); + return; + } + + // Catch-all for field combinations not handled by the specialized N≤2 + // renderers below: 0×0, 0×1, 0×2, 2×0. RenderGeneralPivot handles + // empty row/col axes naturally via empty AxisTrees. + if (valueFields.Count >= 1 + && (rowFieldIndices.Count == 0 || (rowFieldIndices.Count == 2 && colFieldIndices.Count == 0))) + { + RenderGeneralPivot(targetSheet, position, headers, columnData, + rowFieldIndices, colFieldIndices, valueFields, filterFieldIndices, valueStyleIds); + return; + } + + 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 = ActiveGrandTotalCaption; + + 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 if (rowsOnly) + { + // R4-2: rows-only multi-data pivot has a synthetic "__total__" + // col bucket and its K data cells ARE the grand totals, so we + // skip the col-label row entirely (no sentinel, no "Total Sum"). + // Data field names are emitted on a dedicated row below. + } + 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])); + // Round-trip preservation: if the user has manually set a + // locale-specific label (e.g. "(全部)" / "(Tous)") on this + // filter cell in a previous edit, keep it. Fall back to the + // English default only when the cell is missing or empty. + var filterAllLabel = ReadExistingStringAtOrDefault( + targetSheet, sheetData, anchorColIdx + 1, rowIdx, "(All)"); + filterRow.AppendChild(MakeStringCell(anchorColIdx + 1, rowIdx, filterAllLabel)); + // 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 = ActiveGrandTotalCaption; + + 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; + + // CONSISTENCY(grand-totals): mirror the 1×1×K renderer's gating. Right + // grand-total column = ActiveRowGrandTotals; bottom grand-total row = + // ActiveColGrandTotals. Cached once per render call. + bool emitRowGrand = ActiveRowGrandTotals; + bool emitColGrand = ActiveColGrandTotals; + + // ----- 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])); + if (emitRowGrand) + colLabelRow.AppendChild(MakeStringCell(anchorColIdx + 1 + uniqueCols.Count, colLabelRowIdx, totalLabel)); + } + else + { + for (int c = 0; c < uniqueCols.Count; c++) + colLabelRow.AppendChild(MakeStringCell(LeafColIdx(c, 0), colLabelRowIdx, uniqueCols[c])); + if (emitRowGrand) + { + 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; + } + + // CONSISTENCY(subtotals-opts): cache the subtotals toggle once per + // render call. When off, skip the outer subtotal row emit AND change + // the leaf row label from "inner only" to "outer > inner" so each + // group is still visually identifiable in compact mode. + bool emitSubtotals = ActiveDefaultSubtotal; + + // ----- Data rows ----- + int currentRow = firstDataRow; + foreach (var (outer, inners) in groups) + { + if (emitSubtotals) + { + // 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])); + } + } + if (emitRowGrand) + { + 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. + bool firstLeafOfGroup = true; + foreach (var inner in inners) + { + var leafRow = new Row { RowIndex = (uint)currentRow }; + // When subtotals are off, prefix the FIRST leaf of each group + // with the outer label so users can still tell which group + // they're in. Subsequent leaves just carry the inner label + // (Excel's compact mode already indents them under the outer). + var label = (!emitSubtotals && firstLeafOfGroup) + ? $"{outer} / {inner}" + : inner; + leafRow.AppendChild(MakeStringCell(anchorColIdx, currentRow, label)); + firstLeafOfGroup = false; + 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])); + } + } + if (emitRowGrand) + { + 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. + if (emitColGrand) + { + 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])); + if (emitRowGrand) + { + 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])); + // Round-trip preservation: if the user has manually set a + // locale-specific label (e.g. "(全部)" / "(Tous)") on this + // filter cell in a previous edit, keep it. Fall back to the + // English default only when the cell is missing or empty. + var filterAllLabel = ReadExistingStringAtOrDefault( + targetSheet, sheetData, anchorColIdx + 1, rowIdx, "(All)"); + filterRow.AppendChild(MakeStringCell(anchorColIdx + 1, rowIdx, filterAllLabel)); + 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 = ActiveGrandTotalCaption; + + 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); + } + + // CONSISTENCY(grand-totals): cache the grand totals toggles once per + // render call. emitRowGrand controls the right grand-total column + // block; emitColGrand controls the bottom grand-total row. + bool emitRowGrand = ActiveRowGrandTotals; + bool emitColGrand = ActiveColGrandTotals; + + // 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) + // The grand total column block is skipped entirely when emitRowGrand=false. + // CONSISTENCY(subtotals-opts): cached once per render call. + bool emitSubtotals = ActiveDefaultSubtotal; + + 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++; + } + } + if (emitSubtotals) + { + for (int d = 0; d < K; d++) + { + subtotalColPositions[(outer, d)] = currentCol; + currentCol++; + } + } + } + if (emitRowGrand) + { + 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)); + if (emitSubtotals) + innerHeaderRow.AppendChild(MakeStringCell(subtotalColPositions[(outer, 0)], innerHeaderRowIdx, outer + " Total")); + } + if (emitRowGrand) + 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)); + if (emitSubtotals) + { + for (int d = 0; d < K; d++) + outerHeaderRow.AppendChild(MakeStringCell(subtotalColPositions[(outer, d)], + outerHeaderRowIdx, $"{outer} {valueFields[d].name}")); + } + } + if (emitRowGrand) + { + 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])); + } + } + if (emitSubtotals) + { + // 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])); + } + } + } + + if (emitRowGrand) + { + 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. + if (emitColGrand) + { + 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])); + if (emitSubtotals) + { + for (int d = 0; d < K; d++) + grandRow.AppendChild(MakeNumericCell(subtotalColPositions[(outer, d)], grandRowIdx, OuterColTotal(outer, d), valueStyleIds[d])); + } + } + if (emitRowGrand) + { + 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])); + // Round-trip preservation: if the user has manually set a + // locale-specific label (e.g. "(全部)" / "(Tous)") on this + // filter cell in a previous edit, keep it. Fall back to the + // English default only when the cell is missing or empty. + var filterAllLabel = ReadExistingStringAtOrDefault( + targetSheet, sheetData, anchorColIdx + 1, rowIdx, "(All)"); + filterRow.AppendChild(MakeStringCell(anchorColIdx + 1, rowIdx, filterAllLabel)); + 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 = ActiveGrandTotalCaption; + + 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); + } + + // CONSISTENCY(grand-totals): cache the grand totals toggles once per + // render call. emitRowGrand = right column block; emitColGrand = bottom row. + bool emitRowGrand = ActiveRowGrandTotals; + bool emitColGrand = ActiveColGrandTotals; + + // CONSISTENCY(subtotals-opts): cached once per render call. When off, + // skip per-group outer subtotal row and column position allocation, + // header labels, and cell writes in all 9 intersections below. + bool emitSubtotals = ActiveDefaultSubtotal; + + // Pre-compute K-aware col positions: each (outer, inner) leaf gets K + // cells, each outer subtotal gets K cells, K final grand total cells. + // Grand total column block is skipped entirely when emitRowGrand=false. + 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++; + } + } + if (emitSubtotals) + { + for (int d = 0; d < K; d++) + { + subtotalColPositions[(outer, d)] = currentCol; + currentCol++; + } + } + } + if (emitRowGrand) + { + 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)); + if (emitSubtotals) + innerHdrRow.AppendChild(MakeStringCell(subtotalColPositions[(outer, 0)], innerHdrRowIdx, outer + " Total")); + } + if (emitRowGrand) + 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)); + if (emitSubtotals) + { + for (int d = 0; d < K; d++) + outerHdrRow.AppendChild(MakeStringCell(subtotalColPositions[(outer, d)], + outerHdrRowIdx, $"{outer} {valueFields[d].name}")); + } + } + if (emitRowGrand) + { + 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) + { + if (emitSubtotals) + { + // 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])); + } + } + if (emitRowGrand) + { + 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. + // When subtotals are off, prefix the first leaf with the outer label + // so users can still identify which group the row belongs to. + bool firstLeafOfGroup = true; + foreach (var rowInner in rowInners) + { + var leafRow = new Row { RowIndex = (uint)currentRowIdx }; + var label = (!emitSubtotals && firstLeafOfGroup) + ? $"{rowOuter} / {rowInner}" + : rowInner; + leafRow.AppendChild(MakeStringCell(anchorColIdx, currentRowIdx, label)); + firstLeafOfGroup = false; + 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])); + } + } + if (emitSubtotals) + { + 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])); + } + } + } + if (emitRowGrand) + { + 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. + if (emitColGrand) + { + 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])); + if (emitSubtotals) + { + for (int d = 0; d < K; d++) + grandRow.AppendChild(MakeNumericCell(subtotalColPositions[(colOuter, d)], currentRowIdx, GrandRowColSub(colOuter, d), valueStyleIds[d])); + } + } + if (emitRowGrand) + { + 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])); + // Round-trip preservation: if the user has manually set a + // locale-specific label (e.g. "(全部)" / "(Tous)") on this + // filter cell in a previous edit, keep it. Fall back to the + // English default only when the cell is missing or empty. + var filterAllLabel = ReadExistingStringAtOrDefault( + targetSheet, sheetData, anchorColIdx + 1, rowIdx, "(All)"); + filterRow.AppendChild(MakeStringCell(anchorColIdx + 1, rowIdx, filterAllLabel)); + 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. + // CONSISTENCY(subtotals-opts): when off, drop all subtotal positions + // (internal tree nodes) from both axes. Leaf positions keep their + // relative ordering, and the grand total column block is still + // controlled separately by ActiveRow/ColGrandTotals below. + // + // Exception: compact mode keeps row-axis internal nodes as label-only + // rows even when subtotals are off. Excel's compact layout displays + // parent group headers (e.g. product name) as separate indented rows + // without aggregated values, so users can see the hierarchy. + bool emitSubtotals = ActiveDefaultSubtotal; + bool compactLabelRows = !emitSubtotals && ActiveLayoutMode == "compact" + && rowFieldIndices.Count >= 2; + var rowPositions = WalkAxisTree(rowTree, isCol: false) + .Where(p => emitSubtotals || !p.isSubtotal || compactLabelRows).ToList(); + var colPositions = WalkAxisTree(colTree, isCol: true) + .Where(p => emitSubtotals || !p.isSubtotal).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 = ActiveGrandTotalCaption; + + 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); + } + + // CONSISTENCY(grand-totals): cache the grand totals toggles once per + // render call. emitRowGrand → right grand total column block; + // emitColGrand → bottom grand total row. + bool emitRowGrand = ActiveRowGrandTotals; + bool emitColGrand = ActiveColGrandTotals; + + // Compact-form row-label indentation: for pivots with 2+ row fields, + // Excel's canonical compact layout puts every row field into col A with + // progressively deeper cell alignment indents (level 1 = indent 0, + // level 2 = indent 1, ...). The indent is a cell style, not a rowItem + // attribute — verified against Excel-authored test_encrypted.xlsx. + // Build a cached indent→styleIndex map so the renderer resolves each + // distinct depth to a single cellXfs entry. Lazy: only initialized + // when rowFieldIndices.Count >= 2. + var workbookPart = targetSheet.GetParentParts().OfType().FirstOrDefault(); + var indentStyleByLevel = new Dictionary(); + ExcelStyleManager? styleManager = null; + if (rowFieldIndices.Count >= 2 && workbookPart != null) + styleManager = new ExcelStyleManager(workbookPart); + + uint GetIndentStyleIndex(int indentLevel) + { + if (indentLevel <= 0 || styleManager == null) return 0u; + if (indentStyleByLevel.TryGetValue(indentLevel, out var cached)) return cached; + // ApplyStyle mutates a temp cell but returns the xfIndex we need. + var probe = new Cell(); + var styleIdx = styleManager.ApplyStyle(probe, new Dictionary + { + ["alignment.horizontal"] = "left", + ["alignment.indent"] = indentLevel.ToString(System.Globalization.CultureInfo.InvariantCulture) + }); + indentStyleByLevel[indentLevel] = styleIdx; + return styleIdx; + } + + // 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; + // Compact: all row fields share one column → firstDataCol = anchor + 1 + // Outline/Tabular: one column per row field → firstDataCol = anchor + N + int rowLabelCols = ActiveLayoutMode == "compact" + ? 1 + : Math.Max(1, rowFieldIndices.Count); + int firstDataCol = anchorColIdx + rowLabelCols; + 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; // unused when !emitRowGrand + + // Header rows. Layout depends on (N_col, K): + // - colN == 0 && K == 1: single header row with row-label caption + // + data field name. + // - colN == 0 && K > 1: two header rows — R0 carries the "Values" + // axis caption at col B, R1 carries the + // row-label caption at col A plus K data + // field names across cols B..B+K-1. Excel + // injects a synthetic col field (x=-2) for + // multi-data no-col pivots; the rendered + // sheetData must match that axis shape. + // - colN >= 1: 1 caption row + N_col field-label rows + optional + // dfRow when K>1. + // Must stay in sync with ComputePivotGeometry and BuildLocation. + int headerRows; + if (colFieldIndices.Count == 0) + headerRows = K > 1 ? 2 : 1; + else + headerRows = 1 + colFieldIndices.Count + (K > 1 ? 1 : 0); + + // Helper: write row field header labels into the label columns. + // Compact: single caption at anchorColIdx (first row field name). + // Outline/Tabular: one header per row field, each in its own column. + void WriteRowFieldHeaders(Row row, int rowIndex) + { + if (ActiveLayoutMode == "compact") + { + var caption = rowFieldIndices.Count > 0 + ? headers[rowFieldIndices[0]] + : "Row Labels"; + row.AppendChild(MakeStringCell(anchorColIdx, rowIndex, caption)); + } + else + { + for (int f = 0; f < rowFieldIndices.Count; f++) + row.AppendChild(MakeStringCell(anchorColIdx + f, rowIndex, headers[rowFieldIndices[f]])); + } + } + + if (colFieldIndices.Count == 0) + { + if (K > 1) + { + // R0: "Values" axis caption at first data col. + var valuesCaptionRow = new Row { RowIndex = (uint)anchorRow }; + valuesCaptionRow.AppendChild(MakeStringCell(firstDataCol, anchorRow, "Values")); + sheetData.AppendChild(valuesCaptionRow); + + // R1: row-label caption(s), K data field names. + int dfHeaderRowIdx = anchorRow + 1; + var dfHeaderRow = new Row { RowIndex = (uint)dfHeaderRowIdx }; + WriteRowFieldHeaders(dfHeaderRow, dfHeaderRowIdx); + for (int d = 0; d < K; d++) + dfHeaderRow.AppendChild(MakeStringCell(firstDataCol + d, dfHeaderRowIdx, + valueFields[d].name)); + sheetData.AppendChild(dfHeaderRow); + } + else + { + // Single header row: row-label caption(s), single data field name. + var headerRow = new Row { RowIndex = (uint)anchorRow }; + WriteRowFieldHeaders(headerRow, anchorRow); + headerRow.AppendChild(MakeStringCell(firstDataCol, anchorRow, valueFields[0].name)); + sheetData.AppendChild(headerRow); + } + } + else + { + // 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)); + 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 + // row field name(s) (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) + WriteRowFieldHeaders(headerRow, headerRowIdx); + + 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 + // for K=1. For K>1 the label belongs on the data-field-name row + // below (alongside "Sum of Sales"/"Sum of Qty"), not on the col + // header row — see the K>1 block right after this loop. + if (level == colFieldIndices.Count && emitRowGrand && K == 1) + { + headerRow.AppendChild(MakeStringCell(grandTotalColStart, headerRowIdx, totalLabel)); + } + sheetData.AppendChild(headerRow); + } + + // Optional data field name row (K>1). Only emitted when colN >= 1; + // the colN == 0 path above already wrote a single combined header row + // carrying the row-label caption + data field names, so running this + // block would write duplicate cells at anchorRow. + if (K > 1 && colFieldIndices.Count > 0) + { + int dfRowIdx = anchorRow + headerRows - 1; + var dfRow = new Row { RowIndex = (uint)dfRowIdx }; + if (rowFieldIndices.Count > 0) + WriteRowFieldHeaders(dfRow, dfRowIdx); + 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)); + } + // K>1 grand total column captions ("Total Sum of Sales" / + // "Total Sum of Qty") sit on the data-field-name row, NOT on the + // col-header row above — that row carries the col-axis labels + // (Q1/Q2/...) and would visually misalign the grand total caption + // with its values otherwise. + if (emitRowGrand) + { + for (int d = 0; d < K; d++) + dfRow.AppendChild(MakeStringCell(grandTotalColStart + d, dfRowIdx, + $"Total {valueFields[d].name}")); + } + sheetData.AppendChild(dfRow); + } + + // Data + grand total rows. + int firstDataRowIdx = anchorRow + headerRows; + int blankRowOffset = 0; // extra rows inserted for insertBlankRow + for (int rp = 0; rp < rowPositions.Count; rp++) + { + var (rowNode, rIsLeaf, rIsSubtotal) = rowPositions[rp]; + int rowIdx = firstDataRowIdx + rp + blankRowOffset; + var row = new Row { RowIndex = (uint)rowIdx }; + if (ActiveLayoutMode == "compact") + { + // Compact-mode: all labels in one column with indentation. + // level 1 (outermost row field) gets no indent (style 0), + // level 2 gets indent 1, level 3 gets indent 2, etc. + var rowLabelCell = MakeStringCell(anchorColIdx, rowIdx, rowNode.Label); + var indentStyle = GetIndentStyleIndex(rowNode.Depth - 1); + if (indentStyle != 0) rowLabelCell.StyleIndex = indentStyle; + row.AppendChild(rowLabelCell); + } + else + { + // Outline/Tabular: each row field level writes to its own column. + // rowNode.Depth is 1-based; the label goes at column (anchor + depth - 1). + // Tabular subtotal rows append " Total" to match Excel — the + // subtotal row sits AFTER its leaves so the suffix disambiguates + // it from a leaf row of the same name. Outline subtotals sit + // BEFORE leaves and act as group headers, so they keep the + // bare label (matches Excel's outline mode). + // CONSISTENCY(subtotal-total-suffix): mirrors col-axis subtotal + // labels at PivotTableHelper.Render.cs:1981. + int labelCol = anchorColIdx + rowNode.Depth - 1; + string labelText = rowNode.Label; + if (rIsSubtotal && ActiveLayoutMode == "tabular") + labelText = rowNode.Label + " Total"; + row.AppendChild(MakeStringCell(labelCol, rowIdx, labelText)); + // Ancestor labels for non-compact leaf rows. Two modes: + // - repeatLabels=true: write every ancestor on every leaf, + // unconditionally (Excel's "Repeat All Item Labels" toggle). + // - default: per-level diff against the previous row's path. + // A given ancestor level is written only if its value + // changed from the previous row. The previous row may be + // a subtotal (path shorter than leaf) or another leaf — + // either way the diff gives the correct answer: + // * outline+subtotals=on: prev subtotal already carries + // the outer label, so its path matches → diff skips it + // * outline+subtotals=off: parent labels appear on first + // leaf of each group; intermediate transitions stay + // visible + // * tabular+subtotals=on: after an inner subtotal at + // depth L, the next leaf only re-writes ancestors that + // actually changed (NOT the still-same outer ones) + // * tabular+subtotals=off: same as outline+subtotals=off + // CONSISTENCY(first-of-group-ancestors): one rule for every + // non-compact leaf — per-level diff is what Excel does. + if (rowNode.Depth >= 2) + { + bool repeatAll = ActiveRepeatItemLabels; + if (repeatAll) + { + for (int anc = 0; anc < rowNode.Depth - 1; anc++) + row.InsertBefore( + MakeStringCell(anchorColIdx + anc, rowIdx, rowNode.Path[anc]), + row.FirstChild); + } + else if (rIsLeaf) + { + string[]? prevPath = null; + if (rp > 0) + { + var (prevNode, _, _) = rowPositions[rp - 1]; + prevPath = prevNode.Path; + } + for (int anc = 0; anc < rowNode.Depth - 1; anc++) + { + bool changed = prevPath == null + || anc >= prevPath.Length + || prevPath[anc] != rowNode.Path[anc]; + if (changed) + row.InsertBefore( + MakeStringCell(anchorColIdx + anc, rowIdx, rowNode.Path[anc]), + row.FirstChild); + } + } + } + } + + // Label-only rows: compact internal nodes with subtotals off + // get the label but no aggregated values (mirrors Excel's compact + // layout where parent group headers have no data). + bool isLabelOnly = compactLabelRows && rIsSubtotal && !emitSubtotals; + + if (!isLabelOnly) + { + if (colPositions.Count > 0) + { + 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])); + } + } + } + else + { + // No col fields: K value cells written directly. The empty + // colNode matches all source rows so ComputeCell aggregates + // across the entire dataset for the given row path. + var emptyColNode = new AxisNode(string.Empty, 0, Array.Empty()); + for (int d = 0; d < K; d++) + { + var v = ComputeCell(rowNode, emptyColNode, d); + row.AppendChild(MakeNumericCell(firstDataCol + d, rowIdx, v, valueStyleIds[d])); + } + } + } + + // Grand total cells (per data field) — the row's value across all cols. + // Only applies when there ARE col fields; without col fields the value + // cells already aggregate across all rows (no per-row grand total needed). + if (emitRowGrand && !isLabelOnly && colPositions.Count > 0) + { + 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); + + // insertBlankRow: insert an empty row after each outer group's + // last entry. With subtotals ON, that's the depth-1 subtotal row; + // with subtotals OFF those positions are filtered out, so we + // detect end-of-group as "the next row's outermost path element + // differs from this row's, OR there is no next row before the + // grand total". This works for tabular/outline/compact alike. + bool insertBlank = false; + if (ActiveInsertBlankRow) + { + if (rIsSubtotal && rowNode.Depth == 1) + insertBlank = true; + else if (!emitSubtotals && rIsLeaf && rowNode.Path.Length >= 1) + { + bool isLastInGroup; + if (rp == rowPositions.Count - 1) + isLastInGroup = true; + else + { + var (nextNode, _, _) = rowPositions[rp + 1]; + isLastInGroup = nextNode.Path.Length == 0 + || nextNode.Path[0] != rowNode.Path[0]; + } + insertBlank = isLastInGroup; + } + } + if (insertBlank) + { + blankRowOffset++; + var blankRow = new Row { RowIndex = (uint)(rowIdx + 1) }; + sheetData.AppendChild(blankRow); + } + } + + // Final grand total row. + if (emitColGrand) + { + int grandRowIdx = firstDataRowIdx + rowPositions.Count + blankRowOffset; + var grandRow = new Row { RowIndex = (uint)grandRowIdx }; + grandRow.AppendChild(MakeStringCell(anchorColIdx, grandRowIdx, totalLabel)); + var grandRowNodeFinal = new AxisNode(string.Empty, 0, Array.Empty()); + if (colPositions.Count > 0) + { + 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])); + } + } + } + else + { + // No col fields: write K value cells directly at firstDataCol. + var emptyColNode = new AxisNode(string.Empty, 0, Array.Empty()); + for (int d = 0; d < K; d++) + { + var v = ComputeCell(grandRowNodeFinal, emptyColNode, d); + grandRow.AppendChild(MakeNumericCell(firstDataCol + d, grandRowIdx, v, valueStyleIds[d])); + } + } + if (emitRowGrand && colPositions.Count > 0) + { + 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])); + // Round-trip preservation: if the user has manually set a + // locale-specific label (e.g. "(全部)" / "(Tous)") on this + // filter cell in a previous edit, keep it. Fall back to the + // English default only when the cell is missing or empty. + var filterAllLabel = ReadExistingStringAtOrDefault( + targetSheet, sheetData, anchorColIdx + 1, rowIdx, "(All)"); + filterRow.AppendChild(MakeStringCell(anchorColIdx + 1, rowIdx, filterAllLabel)); + 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)) + }; + } + + /// + /// Read the string value of an existing cell at (colIdx, rowIdx) and + /// return it if non-empty, otherwise return . + /// Used by the page filter renderers to preserve a user-localized filter + /// label (e.g. "(全部)") on round-trip through RebuildFieldAreas, + /// instead of overwriting it with our English default "(All)". + /// + /// Resolves both InlineString cells and SharedString cells; falls back to + /// the raw CellValue text if neither matches. Missing row / missing cell / + /// empty text all return the default. + /// + private static string ReadExistingStringAtOrDefault( + WorksheetPart targetSheet, SheetData sheetData, + int colIdx, int rowIdx, string defaultValue) + { + var cellRef = $"{IndexToCol(colIdx)}{rowIdx}"; + var row = sheetData.Elements() + .FirstOrDefault(r => r.RowIndex?.Value == (uint)rowIdx); + if (row == null) return defaultValue; + var cell = row.Elements() + .FirstOrDefault(c => c.CellReference?.Value == cellRef); + if (cell == null) return defaultValue; + + // InlineString: text is embedded in the cell. + if (cell.DataType?.Value == CellValues.InlineString) + { + var inline = cell.InlineString?.Text?.Text ?? cell.InlineString?.InnerText; + if (!string.IsNullOrEmpty(inline)) return inline; + return defaultValue; + } + + // SharedString: CellValue holds the SST index; resolve via workbook. + if (cell.DataType?.Value == CellValues.SharedString + && cell.CellValue?.Text is { } sstIdxStr + && int.TryParse(sstIdxStr, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var sstIdx)) + { + var wbPart = targetSheet.GetParentParts().OfType().FirstOrDefault(); + var sst = wbPart?.SharedStringTablePart?.SharedStringTable; + if (sst != null) + { + var items = sst.Elements().ToList(); + if (sstIdx >= 0 && sstIdx < items.Count) + { + var txt = items[sstIdx].Text?.Text ?? items[sstIdx].InnerText; + if (!string.IsNullOrEmpty(txt)) return txt; + } + } + return defaultValue; + } + + // String-typed (legacy) or untyped: fall back to raw CellValue. + if (cell.CellValue?.Text is { Length: > 0 } cv) return cv; + + return defaultValue; + } + + /// + /// 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; + } + +} diff --git a/src/officecli/Core/PivotTableHelper.Set.cs b/src/officecli/Core/PivotTableHelper.Set.cs new file mode 100644 index 000000000..0ff1ad0f8 --- /dev/null +++ b/src/officecli/Core/PivotTableHelper.Set.cs @@ -0,0 +1,812 @@ +// 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; + +namespace OfficeCli.Core; + +internal static partial class PivotTableHelper +{ + 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); + // CONSISTENCY(thread-static-pivot-opts): same pattern for subtotals. + using var _subScope = PushSubtotalsOptions(properties); + // CONSISTENCY(thread-static-pivot-opts): same pattern for layout mode. + using var _layoutScope = PushLayoutMode(properties); + // CONSISTENCY(thread-static-pivot-opts): same pattern for repeatItemLabels. + using var _repeatScope = PushRepeatItemLabels(properties); + // CONSISTENCY(thread-static-pivot-opts): same pattern for insertBlankRow. + using var _blankRowScope = PushInsertBlankRow(properties); + // CONSISTENCY(thread-static-pivot-opts): same pattern for grandTotalCaption. + using var _captionScope = PushGrandTotalCaption(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). + // OOXML attribute → internal flag mapping: + // RowGrandTotals (bottom row) → _colGrandTotals + // ColumnGrandTotals (right col) → _rowGrandTotals + if (!_rowGrandTotals.HasValue && pivotDef.ColumnGrandTotals?.Value == false) + _rowGrandTotals = false; + if (!_colGrandTotals.HasValue && pivotDef.RowGrandTotals?.Value == false) + _colGrandTotals = false; + + // Seed layout sticky state: detect current layout from definition + // attributes when the caller did not explicitly pass layout=. This keeps + // the layout stable across unrelated Set operations (e.g. `set rows=...` + // must not silently revert an outline pivot to compact). + if (_layoutMode == null) + { + if (pivotDef.Compact?.Value == false) + { + var firstAxisField = pivotDef.PivotFields?.Elements() + .FirstOrDefault(pf => pf.Axis != null); + if (firstAxisField?.Outline?.Value == false) + _layoutMode = "tabular"; + else + _layoutMode = "outline"; + } + // else: compact (default) — _layoutMode stays null → ActiveLayoutMode returns "compact" + } + + // Seed subtotals sticky state: if any existing row/col pivotField has + // DefaultSubtotal=false, assume the user previously turned subtotals off + // and the current Set (which didn't re-specify it) should preserve that. + if (!_defaultSubtotal.HasValue && pivotDef.PivotFields != null) + { + foreach (var pf in pivotDef.PivotFields.Elements()) + { + if (pf.DefaultSubtotal?.Value == false) + { + _defaultSubtotal = false; + break; + } + } + } + + // 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": + // 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": + { + // 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": + case "cols" or "columns": + case "values": + 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; + case "subtotals": + case "defaultsubtotal": + // Already consumed by PushSubtotalsOptions at the top of + // this method. Trigger a re-render (mirrors grandtotals). + if (!fieldAreaProps.ContainsKey("rows") && !fieldAreaProps.ContainsKey("cols") + && !fieldAreaProps.ContainsKey("values") && !fieldAreaProps.ContainsKey("filters") + && !fieldAreaProps.ContainsKey("__sort_only__")) + { + fieldAreaProps["__sort_only__"] = value; + } + break; + case "layout": + { + // Already consumed by PushLayoutMode at the top of this + // method. Apply definition-level + per-field attributes + // immediately, then trigger a re-render for geometry change + // (rowLabelCols depends on layout mode). + var lower = (value ?? "").Trim().ToLowerInvariant(); + // Definition-level attributes + if (lower == "compact") + { + pivotDef.Compact = null; // revert to default true + pivotDef.CompactData = null; + pivotDef.Outline = true; + pivotDef.OutlineData = true; + } + else if (lower == "outline") + { + pivotDef.Compact = false; + pivotDef.CompactData = false; + pivotDef.Outline = true; + pivotDef.OutlineData = true; + } + else // tabular + { + pivotDef.Compact = false; + pivotDef.CompactData = false; + pivotDef.Outline = null; + pivotDef.OutlineData = null; + } + // Per-field attributes + if (pivotDef.PivotFields != null) + { + foreach (var pf in pivotDef.PivotFields.Elements()) + { + pf.Compact = (lower == "compact") ? null : (BooleanValue)false; + pf.Outline = (lower == "tabular") ? (BooleanValue)false : null; + } + } + // Trigger re-render for geometry change + if (!fieldAreaProps.ContainsKey("rows") && !fieldAreaProps.ContainsKey("cols") + && !fieldAreaProps.ContainsKey("values") && !fieldAreaProps.ContainsKey("filters") + && !fieldAreaProps.ContainsKey("__sort_only__")) + { + fieldAreaProps["__sort_only__"] = ""; + } + break; + } + case "repeatlabels": + { + // Write or remove the x14:pivotTableDefinition fillDownLabelsDefault + // extension element. Also trigger re-render so materialized cells + // reflect the label repetition. + bool enable = ParseHelpers.IsTruthy(value); + const string x14Ns = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"; + var extLst = pivotDef.GetFirstChild(); + // Remove any existing fillDownLabels extension + if (extLst != null) + { + var toRemove = extLst.Elements() + .Where(e => e.Uri == "{962EF5D1-5CA2-4c93-8EF4-DBF5C05439D2}") + .ToList(); + foreach (var e in toRemove) e.Remove(); + if (!extLst.HasChildren) extLst.Remove(); + } + if (enable) + { + var ext = new PivotTableDefinitionExtension + { + Uri = "{962EF5D1-5CA2-4c93-8EF4-DBF5C05439D2}" + }; + var x14PivotDef = new OpenXmlUnknownElement("x14", "pivotTableDefinition", x14Ns); + x14PivotDef.SetAttribute(new OpenXmlAttribute("fillDownLabelsDefault", "", "1")); + x14PivotDef.AddNamespaceDeclaration("x14", x14Ns); + ext.AppendChild(x14PivotDef); + extLst = pivotDef.GetFirstChild() + ?? pivotDef.AppendChild(new PivotTableDefinitionExtensionList()); + extLst.AppendChild(ext); + } + // Trigger re-render + if (!fieldAreaProps.ContainsKey("rows") && !fieldAreaProps.ContainsKey("cols") + && !fieldAreaProps.ContainsKey("values") && !fieldAreaProps.ContainsKey("filters") + && !fieldAreaProps.ContainsKey("__sort_only__")) + { + fieldAreaProps["__sort_only__"] = ""; + } + break; + } + case "grandtotalcaption": + { + pivotDef.GrandTotalCaption = value?.Trim() ?? "Grand Total"; + // Trigger re-render so materialized cells reflect the new caption + if (!fieldAreaProps.ContainsKey("rows") && !fieldAreaProps.ContainsKey("cols") + && !fieldAreaProps.ContainsKey("values") && !fieldAreaProps.ContainsKey("filters") + && !fieldAreaProps.ContainsKey("__sort_only__")) + { + fieldAreaProps["__sort_only__"] = ""; + } + break; + } + case "blankrows": + { + bool enable = ParseHelpers.IsTruthy(value); + // Set insertBlankRow on the outermost row field + if (pivotDef.PivotFields != null && pivotDef.RowFields != null) + { + var rowFields = pivotDef.RowFields.Elements().ToList(); + if (rowFields.Count >= 2) + { + var firstIdx = (int)(rowFields[0].Index?.Value ?? 0); + var pf = pivotDef.PivotFields.Elements() + .ElementAtOrDefault(firstIdx); + if (pf != null) + pf.InsertBlankRow = enable ? true : null; + } + } + // Trigger re-render + if (!fieldAreaProps.ContainsKey("rows") && !fieldAreaProps.ContainsKey("cols") + && !fieldAreaProps.ContainsKey("values") && !fieldAreaProps.ContainsKey("filters") + && !fieldAreaProps.ContainsKey("__sort_only__")) + { + fieldAreaProps["__sort_only__"] = ""; + } + 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; + } + } + } + + // If any field areas were specified, rebuild them + if (fieldAreaProps.Count > 0) + RebuildFieldAreas(pivotPart, pivotDef, fieldAreaProps); + + pivotDef.Save(); + return unsupported; + } + + /// + /// Rebuild pivot table field areas (rows, cols, values, filters). + /// For areas not specified in changes, preserves the current assignment. + /// Two-layer update: (1) PivotField.Axis/DataField, (2) RowFields/ColumnFields/PageFields/DataFields. + /// + private static void RebuildFieldAreas(PivotTablePart pivotPart, PivotTableDefinition pivotDef, + Dictionary changes) + { + // Get headers from cache definition + var cachePart = pivotPart.GetPartsOfType().FirstOrDefault(); + if (cachePart?.PivotCacheDefinition == null) return; + + var cacheFields = cachePart.PivotCacheDefinition.GetFirstChild(); + if (cacheFields == null) return; + + var headers = cacheFields.Elements().Select(cf => cf.Name?.Value ?? "").ToArray(); + if (headers.Length == 0) return; + + // Read current assignments for areas NOT being changed + var currentRows = ReadCurrentFieldIndices(pivotDef.RowFields?.Elements(), f => f.Index?.Value ?? -1); + var currentCols = ReadCurrentFieldIndices(pivotDef.ColumnFields?.Elements(), f => f.Index?.Value ?? -1); + var currentFilters = ReadCurrentFieldIndices(pivotDef.PageFields?.Elements(), f => f.Field?.Value ?? -1); + var currentValues = ReadCurrentDataFields(pivotDef.DataFields); + + // Parse new assignments (or keep current) + // If user specified a non-empty value but nothing resolved, warn via stderr + var rowFieldIndices = changes.ContainsKey("rows") + ? ParseFieldListWithWarning(changes, "rows", headers) + : currentRows; + var colFieldIndices = changes.ContainsKey("cols") + ? ParseFieldListWithWarning(changes, "cols", headers) + : currentCols; + 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; + + var pfList = pivotFields.Elements().ToList(); + for (int i = 0; i < pfList.Count; i++) + { + var pf = pfList[i]; + // Clear axis and dataField + pf.Axis = null; + pf.DataField = null; + pf.DefaultSubtotal = null; + pf.RemoveAllChildren(); + // CONSISTENCY(thread-static-pivot-opts): layout-dependent per-field + // attributes. Mirrors BuildPivotTableDefinition per-field logic. + var layoutMode = ActiveLayoutMode; + pf.Compact = (layoutMode == "compact") ? null : (BooleanValue)false; + pf.Outline = (layoutMode == "tabular") ? (BooleanValue)false : null; + + // Determine if this field's cache data is numeric (for Items generation) + var isNumeric = IsFieldNumeric(cacheFields, i); + + bool onAxis = false; + if (rowFieldIndices.Contains(i)) + { + pf.Axis = PivotTableAxisValues.AxisRow; + if (!isNumeric) AppendFieldItemsFromCache(pf, cacheFields, i); + onAxis = true; + } + else if (colFieldIndices.Contains(i)) + { + pf.Axis = PivotTableAxisValues.AxisColumn; + if (!isNumeric) AppendFieldItemsFromCache(pf, cacheFields, i); + onAxis = true; + } + else if (filterFieldIndices.Contains(i)) + { + pf.Axis = PivotTableAxisValues.AxisPage; + if (!isNumeric) AppendFieldItemsFromCache(pf, cacheFields, i); + onAxis = true; + } + else if (valueFields.Any(vf => vf.idx == i)) + { + pf.DataField = true; + } + + // CONSISTENCY(subtotals-opts): mirror BuildPivotTableDefinition — the + // defaultSubtotal attribute lives on every axis field, gated on the + // Set-time scope (seeded from existing state earlier if not passed). + if (onAxis && !ActiveDefaultSubtotal) + pf.DefaultSubtotal = false; + } + + // Layer 2: Rebuild area reference lists + // 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 }); + pivotDef.RowFields = rf; + } + else + { + pivotDef.RowFields = null; + } + + // ColumnFields + if (colFieldIndices.Count > 0 || valueFields.Count > 1) + { + var cf = new ColumnFields(); + foreach (var idx in colFieldIndices) + cf.AppendChild(new Field { Index = idx }); + // -2 sentinel for multiple value fields in columns + if (valueFields.Count > 1) + cf.AppendChild(new Field { Index = -2 }); + cf.Count = (uint)cf.Elements().Count(); + pivotDef.ColumnFields = cf; + } + else + { + pivotDef.ColumnFields = null; + } + + // 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; + } + else + { + 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, 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; + 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; + } + else + { + pivotDef.DataFields = null; + } + + // 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 = BuildLocation(newGeom, rowFieldIndices, colFieldIndices, valueFields, filterFieldIndices.Count); + + // 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. OOXML mapping: internal _rowGrandTotals controls + // the right column → OOXML ColumnGrandTotals; _colGrandTotals controls + // the bottom row → OOXML RowGrandTotals. + if (_rowGrandTotals.HasValue) + pivotDef.ColumnGrandTotals = _rowGrandTotals.Value ? null : (BooleanValue)false; + if (_colGrandTotals.HasValue) + pivotDef.RowGrandTotals = _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); + + // Collapse any duplicate elements produced by the + // re-render interacting with other pivots in the same sheet. + // See DedupeSheetDataRows docstring. + DedupeSheetDataRows(sheetData); + } + } + } + + private static List ReadCurrentFieldIndices(IEnumerable? elements, Func getIndex) + { + if (elements == null) return new List(); + return elements.Select(getIndex).Where(i => i >= 0).ToList(); + } + + private static List<(int idx, string func, string showAs, string name)> ReadCurrentDataFields(DataFields? dataFields) + { + 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(); + } + + private static bool IsFieldNumeric(CacheFields cacheFields, int index) + { + var cf = cacheFields.Elements().ElementAtOrDefault(index); + var sharedItems = cf?.GetFirstChild(); + if (sharedItems == null) return false; + return sharedItems.ContainsNumber?.Value == true && sharedItems.ContainsString?.Value != true; + } + + private static void AppendFieldItemsFromCache(PivotField pf, CacheFields cacheFields, int index) + { + var cf = cacheFields.Elements().ElementAtOrDefault(index); + var sharedItems = cf?.GetFirstChild(); + var count = sharedItems?.Elements().Count() ?? 0; + if (count == 0) return; + + // CONSISTENCY(subtotals-opts): mirror AppendFieldItems — the trailing + // is the field-level subtotal sentinel, gated on + // ActiveDefaultSubtotal. + bool emitSub = ActiveDefaultSubtotal; + var items = new Items { Count = (uint)(count + (emitSub ? 1 : 0)) }; + for (int i = 0; i < count; i++) + items.AppendChild(new Item { Index = (uint)i }); + if (emitSub) + items.AppendChild(new Item { ItemType = ItemValues.Default }); + pf.AppendChild(items); + } +} diff --git a/src/officecli/Core/PivotTableHelper.cs b/src/officecli/Core/PivotTableHelper.cs index 5dd7db10b..fb9c8e06d 100644 --- a/src/officecli/Core/PivotTableHelper.cs +++ b/src/officecli/Core/PivotTableHelper.cs @@ -11,8 +11,848 @@ namespace OfficeCli.Core; /// Helper for building and reading pivot tables. /// Manages PivotTableCacheDefinitionPart (workbook-level) and PivotTablePart (worksheet-level). /// -internal static class PivotTableHelper +internal static partial class PivotTableHelper { + // Sentinel used to represent Excel error cells (DataType=Error, e.g. #DIV/0!) + // in the string[] columnData arrays passed between ReadSourceData and BuildCacheField. + // This value never appears in normal cell text (U+0001 prefix makes it XML-illegal + // for ordinary strings, so SanitizeXmlText would have stripped it). BuildCacheField + // emits ErrorItem instead of StringItem when it sees this sentinel. + internal const string ErrorCellSentinel = "\x01#ERROR"; + + // ==================== 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", + // PV7: bandedRows/bandedCols are Excel Ribbon labels for the + // same knobs that OOXML calls showRowStripes/showColStripes. + // Accept the user-facing spelling too. + ["bandedrows"] = "showrowstripes", + ["bandedcols"] = "showcolstripes", + ["bandedcolumns"] = "showcolstripes", + // repeatItemLabels aliases + ["repeatitemlabels"] = "repeatlabels", + ["repeatalllabels"] = "repeatlabels", + ["filldownlabels"] = "repeatlabels", + // blankRows aliases + ["insertblankrow"] = "blankrows", + ["insertblankrows"] = "blankrows", + ["blankrow"] = "blankrows", + ["blankline"] = "blankrows", + ["blanklines"] = "blankrows", + }; + + /// + /// 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`. + /// + private 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. + /// + private 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", "layout", "repeatlabels", "blankrows", "grandtotalcaption", + "grandtotals", "rowgrandtotals", "colgrandtotals", + "subtotals", "defaultsubtotal", + // 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", + // PV7: showDrill toggles the expand/collapse (+/-) buttons on + // every pivotField. mergeLabels emits which tells Excel to merge+center repeated + // outer axis item cells. + "showdrill", "mergelabels", + // PV7: labelFilter=field:type:value — row-level pre-cache filter + // (see ApplyLabelFilter). + "labelfilter", + // R4-3: calculatedField[N]=Name:=Formula — numbered variants are + // also accepted; CollectUnknownPivotKeys normalizes trailing + // digits before the known-set check. + "calculatedfield", "calculatedfields", + }; + + /// + /// 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. + /// + private 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); + // R4-3: strip trailing digits before lookup so `calculatedField1`, + // `calculatedField2`, etc. match the canonical `calculatedfield`. + var stripped = System.Text.RegularExpressions.Regex.Replace(canonical, @"\d+$", ""); + if (!_knownPivotKeys.Contains(canonical) + && !_knownPivotKeys.Contains(stripped)) + unknown.Add(key); + } + return unknown; + } + + /// + /// Public wrapper around + alias/digit + /// normalization for tests and external callers. + /// + public static bool IsKnownPivotProperty(string key) + { + if (string.IsNullOrEmpty(key)) return false; + var canonical = NormalizePivotPropKey(key); + var stripped = System.Text.RegularExpressions.Regex.Replace(canonical, @"\d+$", ""); + return _knownPivotKeys.Contains(canonical) || _knownPivotKeys.Contains(stripped); + } + + /// + /// 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. + /// + private 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), EMPIRICALLY + // VERIFIED against an Excel-authored pivot the user created via + // "Grand Totals → On for Rows Only" in the UI (test-samples/grand_totals_demo_Fix.xlsx): + // rowGrandTotals — BOTTOM grand total ROW (one row at the bottom of the + // pivot containing the per-col grand totals). Excel UI's + // "On for Rows Only" enables this and writes colGrandTotals=0. + // colGrandTotals — RIGHTMOST grand total COLUMN (one column at the right + // of the pivot containing the per-row grand totals). Excel UI's + // "On for Columns Only" enables this and writes rowGrandTotals=0. + // + // ⚠️ WARNING — HISTORICAL BUG: the initial implementation of this feature had + // the mapping BACKWARDS (assumed rowGrandTotals = right column). The ThreadStatic + // names below are kept stable to minimize churn, but their meaning was REDEFINED + // during bug fix commit: `_rowGrandTotals` is the CLI-level flag whose true/false + // maps to "render right column yes/no" (= OOXML colGrandTotals), and + // `_colGrandTotals` maps to "render bottom row yes/no" (= OOXML rowGrandTotals). + // The renderer / geometry / item builders use `ActiveRowGrandTotals` / + // `ActiveColGrandTotals` to mean "right col visible" / "bottom row visible" + // respectively. The attribute writer / reader / parser swap the names when + // talking to OOXML so the final XML and visual match Excel UI. + // + // 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; + + // ActiveRowGrandTotals: "render the right grand-total column" (= OOXML colGrandTotals) + // ActiveColGrandTotals: "render the bottom grand-total row" (= OOXML rowGrandTotals) + 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), matching Excel UI semantics: + // 'rows' = Excel's "On for Rows Only" = BOTTOM row visible, right col hidden + // 'cols' = Excel's "On for Columns Only" = RIGHT col visible, bottom row hidden + // Internally: _rowGrandTotals = "render right col", _colGrandTotals = "render bottom row" + // (see comment at the ThreadStatic declaration above). + 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": + // "On for Rows Only" = only bottom row, no right col. + _rowGrandTotals = false; _colGrandTotals = true; break; + case "cols": case "col": case "columns": + // "On for Columns Only" = only right col, no bottom row. + _rowGrandTotals = true; _colGrandTotals = false; break; + } + } + + // Fine-grained bool keys mirror OOXML attribute names (ECMA-376): + // rowGrandTotals=... → bottom row toggle (internal: _colGrandTotals) + // colGrandTotals=... → right col toggle (internal: _rowGrandTotals) + // Parsed AFTER the master key so they override it when both are supplied. + if (TryParseBoolProp(properties, "rowGrandTotals", out var rgt)) + _colGrandTotals = rgt; + if (TryParseBoolProp(properties, "colGrandTotals", out var cgt) + || TryParseBoolProp(properties, "columnGrandTotals", out cgt)) + _rowGrandTotals = 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; } + } + + // ==================== Subtotals options ==================== + // + // CONSISTENCY(thread-static-pivot-opts): same ThreadStatic precedent as + // sort + grand totals. Subtotals (the outer-level group subtotal rows + // and columns that appear between groups in 2+ row/col-field pivots) + // need to reach item builders, geometry, and every multi-dim renderer. + // + // OOXML semantics (ECMA-376 § 18.10.1.69 on pivotField): + // defaultSubtotal (default true) — whether this pivot field's axis + // emits an outer-level subtotal sentinel + // ( in pivotField.items). + // + // v1b scope: only on/off. subtotalTop (position = top vs bottom of + // group) is deferred — our renderers always emit subtotals at the top + // of each group, and switching position would require reordering the + // sheetData write loop. Tracked as v1c. + [ThreadStatic] private static bool? _defaultSubtotal; + + private static bool ActiveDefaultSubtotal => _defaultSubtotal ?? true; + + /// + /// Parse subtotals properties into the thread-static scope. Supports: + /// subtotals=on|off|true|false|show|hide|yes|no|1|0 + /// defaultSubtotal=true|false (OOXML-level alias) + /// Returns a scope that restores the previous value on Dispose. + /// + private static IDisposable PushSubtotalsOptions(Dictionary properties) + { + var prev = _defaultSubtotal; + + if (properties.TryGetValue("subtotals", out var s) + || properties.TryGetValue("Subtotals", out s)) + { + switch ((s ?? "").Trim().ToLowerInvariant()) + { + case "on": case "true": case "1": case "yes": case "show": + _defaultSubtotal = true; break; + case "off": case "false": case "0": case "no": case "hide": case "none": + _defaultSubtotal = false; break; + } + } + + if (TryParseBoolProp(properties, "defaultSubtotal", out var ds)) + _defaultSubtotal = ds; + + return new SubtotalsScope(prev); + } + + private sealed class SubtotalsScope : IDisposable + { + private readonly bool? _prev; + public SubtotalsScope(bool? prev) { _prev = prev; } + public void Dispose() { _defaultSubtotal = _prev; } + } + + // ==================== Layout mode options ==================== + // + // CONSISTENCY(thread-static-pivot-opts): same ThreadStatic precedent as + // sort + grand totals + subtotals. Layout mode (compact/outline/tabular) + // affects geometry (rowLabelCols), definition attributes, PivotField + // attributes, and renderer column placement. Threading a parameter + // through all 15+ call sites would be excessively invasive. + // + // Supported modes: + // "compact" — (DEFAULT) all row fields share one column with indentation + // "outline" — each row field gets its own column, labels on same row as data + // "tabular" — each row field gets its own column, labels on separate row from data + [ThreadStatic] private static string? _layoutMode; + + private static string ActiveLayoutMode => _layoutMode ?? "compact"; + + /// + /// Parse layout property into the thread-static scope. Supports: + /// layout=compact|outline|tabular + /// Returns a scope that restores the previous value on Dispose. + /// + private static readonly HashSet _validLayoutModes = new(StringComparer.OrdinalIgnoreCase) + { + "compact", "outline", "tabular" + }; + + private static IDisposable PushLayoutMode(Dictionary properties) + { + var prev = _layoutMode; + if (properties.TryGetValue("layout", out var mode) && !string.IsNullOrWhiteSpace(mode)) + { + var normalized = mode.Trim().ToLowerInvariant(); + if (!_validLayoutModes.Contains(normalized)) + throw new ArgumentException( + $"invalid layout: '{mode}'. Valid: compact, outline, tabular"); + _layoutMode = normalized; + } + return new LayoutModeScope(prev); + } + + private sealed class LayoutModeScope : IDisposable + { + private readonly string? _prev; + public LayoutModeScope(string? prev) { _prev = prev; } + public void Dispose() { _layoutMode = _prev; } + } + + // CONSISTENCY(thread-static-pivot-opts): repeatItemLabels — "Repeat All + // Item Labels" in Excel's Report Layout menu. When true, outer row axis + // labels are repeated on every leaf row instead of appearing only once + // at the top of each group. OOXML: fillDownLabelsDefault on x14:pivotTableDefinition. + [ThreadStatic] private static bool? _repeatItemLabels; + + private static bool ActiveRepeatItemLabels => _repeatItemLabels ?? false; + + private static IDisposable PushRepeatItemLabels(Dictionary properties) + { + var prev = _repeatItemLabels; + if (properties.TryGetValue("repeatlabels", out var val) && !string.IsNullOrWhiteSpace(val)) + _repeatItemLabels = ParseHelpers.IsTruthy(val); + return new RepeatItemLabelsScope(prev); + } + + private sealed class RepeatItemLabelsScope : IDisposable + { + private readonly bool? _prev; + public RepeatItemLabelsScope(bool? prev) { _prev = prev; } + public void Dispose() { _repeatItemLabels = _prev; } + } + + // CONSISTENCY(thread-static-pivot-opts): insertBlankRow — "Insert Blank + // Line After Each Item" in Excel's Report Layout menu. When true, an + // empty row is inserted after each outer group (after subtotal in tabular, + // after last leaf in compact/outline). OOXML: insertBlankRow on pivotField. + [ThreadStatic] private static bool? _insertBlankRow; + + private static bool ActiveInsertBlankRow => _insertBlankRow ?? false; + + private static IDisposable PushInsertBlankRow(Dictionary properties) + { + var prev = _insertBlankRow; + if (properties.TryGetValue("blankrows", out var val) && !string.IsNullOrWhiteSpace(val)) + _insertBlankRow = ParseHelpers.IsTruthy(val); + return new InsertBlankRowScope(prev); + } + + private sealed class InsertBlankRowScope : IDisposable + { + private readonly bool? _prev; + public InsertBlankRowScope(bool? prev) { _prev = prev; } + public void Dispose() { _insertBlankRow = _prev; } + } + + // CONSISTENCY(thread-static-pivot-opts): grandTotalCaption — user-specified + // label for the grand total row/column. Defaults to "Grand Total". + [ThreadStatic] private static string? _grandTotalCaption; + + private static string ActiveGrandTotalCaption => _grandTotalCaption ?? "Grand Total"; + + private static IDisposable PushGrandTotalCaption(Dictionary properties) + { + var prev = _grandTotalCaption; + if (properties.TryGetValue("grandtotalcaption", out var val) && !string.IsNullOrWhiteSpace(val)) + _grandTotalCaption = val.Trim(); + return new GrandTotalCaptionScope(prev); + } + + private sealed class GrandTotalCaptionScope : IDisposable + { + private readonly string? _prev; + public GrandTotalCaptionScope(string? prev) { _prev = prev; } + public void Dispose() { _grandTotalCaption = _prev; } + } + + /// + /// 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; + } + } + + // PV7 / DEFERRED(xlsx/pivot-advanced-props): row-level pre-cache label + // filter. Colon-separated scalar form: `labelFilter=field:type:value` + // where `type` is one of contains, beginsWith, endsWith, equals, + // notEquals, doesNotContain. Filtering happens BEFORE the cache is + // built so the cache, rendered cells, and totals all stay consistent + // (same trick the topN filter uses — the alternative, emitting + // in the pivotField, would require the cache and the + // filter predicate to agree at runtime and Excel is strict about it). + // Known limitation vs native Excel: only row-axis labels are filterable + // (column-axis labels are not yet addressable). + private static void ApplyLabelFilter( + string[] headers, + List columnData, + Dictionary properties) + { + if (!properties.TryGetValue("labelFilter", out var spec) || string.IsNullOrEmpty(spec)) + return; + var parts = spec.Split(':', 3); + if (parts.Length != 3) + throw new ArgumentException( + $"labelFilter must be 'field:type:value', got: '{spec}'"); + var fieldName = parts[0].Trim(); + var opType = parts[1].Trim().ToLowerInvariant(); + var needle = parts[2]; + + int fieldIdx = Array.FindIndex(headers, h => string.Equals(h, fieldName, StringComparison.Ordinal)); + if (fieldIdx < 0) + throw new ArgumentException($"labelFilter field '{fieldName}' not found in source headers"); + if (columnData.Count == 0 || fieldIdx >= columnData.Count) return; + var col = columnData[fieldIdx]; + var rowCount = col.Length; + if (rowCount == 0) return; + + Func match = opType switch + { + "contains" => v => v != null && v.IndexOf(needle, StringComparison.Ordinal) >= 0, + "doesnotcontain" => v => v == null || v.IndexOf(needle, StringComparison.Ordinal) < 0, + "beginswith" => v => v != null && v.StartsWith(needle, StringComparison.Ordinal), + "endswith" => v => v != null && v.EndsWith(needle, StringComparison.Ordinal), + "equals" => v => string.Equals(v, needle, StringComparison.Ordinal), + "notequals" => v => !string.Equals(v, needle, StringComparison.Ordinal), + _ => throw new ArgumentException( + $"labelFilter type must be one of contains/doesNotContain/beginsWith/endsWith/equals/notEquals, got: '{opType}'"), + }; + + var keep = new bool[rowCount]; + int keepCount = 0; + for (int r = 0; r < rowCount; r++) + { + if (match(col[r])) { keep[r] = true; keepCount++; } + } + if (keepCount == rowCount) return; + 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 +873,71 @@ 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); + // CONSISTENCY(thread-static-pivot-opts): same pattern for subtotals. + using var _subScope = PushSubtotalsOptions(properties); + // CONSISTENCY(thread-static-pivot-opts): same pattern for layout mode. + using var _layoutScope = PushLayoutMode(properties); + // CONSISTENCY(thread-static-pivot-opts): same pattern for repeatItemLabels. + using var _repeatScope = PushRepeatItemLabels(properties); + // CONSISTENCY(thread-static-pivot-opts): same pattern for insertBlankRow. + using var _blankRowScope = PushInsertBlankRow(properties); + // CONSISTENCY(thread-static-pivot-opts): same pattern for grandTotalCaption. + using var _captionScope = PushGrandTotalCaption(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 +945,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 +984,28 @@ 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; } } } + // 2a. Apply label filter (row-level, pre-cache). Mirrors topN's + // filter-before-cache approach so definition/cache stay consistent. + ApplyLabelFilter(headers, columnData, properties); + + // 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,20 +1014,88 @@ 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); + // R19-1: resolve numFmtIds BEFORE building the cache so date/number + // formats on the source column propagate onto the cacheField's + // numFmtId attribute. Without this, a column styled as "yyyy-mm-dd" + // renders in the pivot as the raw OADate serial (45306, ...). + var columnNumFmtIds = ResolveColumnNumFmtIds(workbookPart, columnStyleIds); + var (cacheDef, fieldNumeric, fieldValueIndex) = + BuildCacheDefinition(sourceSheetName, sourceRef, headers, columnData, axisFieldSet, dateGroups, columnNumFmtIds); 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) { pivotCaches = new PivotCaches(); - workbook.AppendChild(pivotCaches); + // OOXML schema requires pivotCaches AFTER calcPr/oleSize/ + // customWorkbookViews and BEFORE smartTagPr/fileRecoveryPr/extLst. + // AppendChild puts it after fileRecoveryPr, violating schema order + // and causing Excel to report "problem with some content". + var insertBefore = workbook.GetFirstChild() + ?? workbook.GetFirstChild() + ?? (OpenXmlElement?)workbook.GetFirstChild(); + if (insertBefore != null) + workbook.InsertBefore(pivotCaches, insertBefore); + else + workbook.AppendChild(pivotCaches); } pivotCaches.AppendChild(new PivotCache { CacheId = cacheId, Id = cacheRelId }); workbook.Save(); @@ -89,720 +1105,890 @@ 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"); + // columnNumFmtIds was resolved above (R19-1) and reused here to 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. + + // Page filters occupy rows ABOVE the pivot body. Ensure position leaves + // enough headroom for filterCount filter rows + 1 blank separator row. + if (filterFields.Count > 0) + { + var (posCol, posRow) = ParseCellRef(position); + int minBodyRow = filterFields.Count + 2; // 1-based + if (posRow < minBodyRow) + position = $"{posCol}{minBodyRow}"; + } + 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); + // PV7: mergeLabels → . This + // tells Excel to merge+center repeated outer axis item cells. + if (properties.TryGetValue("mergelabels", out var mergeLabelsVal) + && ParseHelpers.IsTruthy(mergeLabelsVal)) + pivotDef.MergeItem = true; + // PV7: showDrill (inverted sense) → every pivotField's + // showDropDowns attribute. Excel's "Show expand/collapse buttons" + // toggle. showDropDowns defaults to true; we only write false + // when user sets showDrill=false. + if (properties.TryGetValue("showdrill", out var showDrillVal)) + { + bool showDrill = ParseHelpers.IsTruthy(showDrillVal); + if (!showDrill && pivotDef.PivotFields != null) + { + foreach (var pf in pivotDef.PivotFields.Elements()) + pf.ShowDropDowns = false; + } + } + // PV7: calculatedField — parses `calculatedField="Name:=Formula"` (or + // numbered variants `calculatedField1=...`, `calculatedField2=...`) + // and appends the matching cacheField / pivotField / dataField trio + // plus an marker on the pivotTableDefinition. + // The underlying column is NOT rendered into sheetData; Excel + // computes calculated fields live at display time from the formula + // stored on the cacheField. + ApplyCalculatedFields(cachePart.PivotCacheDefinition, pivotDef, properties); + pivotPart.PivotTableDefinition = pivotDef; pivotPart.PivotTableDefinition.Save(); + cachePart.PivotCacheDefinition.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); + + // After rendering, collapse any duplicate elements the + // renderer may have appended if this sheet already had pivot-rendered + // rows (second pivot in same sheet → shared row indices). OOXML + // requires unique row elements per index; Excel rejects the file with + // "problem with some content" otherwise. + var targetSheetData = targetSheet.Worksheet?.GetFirstChild(); + if (targetSheetData != null) + DedupeSheetDataRows(targetSheetData); // 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}"); - - var (startCol, startRow) = ParseCellRef(parts[0]); - var (endCol, endRow) = ParseCellRef(parts[1]); + /// 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(); + + 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; - - values[ci] = GetCellText(cell, sst); + 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; + + // 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 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; + 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); } - // ==================== Cache Definition Builder ==================== - - private static PivotCacheDefinition BuildCacheDefinition( - string sourceSheetName, string sourceRef, - string[] headers, List columnData) + /// + /// 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) { - var recordCount = columnData.Count > 0 ? columnData[0].Length : 0; + // 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; + } - var cacheDef = new PivotCacheDefinition + private static IEnumerable<(AxisNode node, bool isLeaf, bool isSubtotal)> WalkAxisTreeRecursive( + AxisNode node, bool isCol) + { + if (node.IsLeaf) { - CreatedVersion = 3, - MinRefreshableVersion = 3, - RefreshedVersion = 3, - RecordCount = (uint)recordCount - }; + yield return (node, true, false); + yield break; + } - // CacheSource -> WorksheetSource - var cacheSource = new CacheSource { Type = SourceValues.Worksheet }; - cacheSource.AppendChild(new WorksheetSource - { - Reference = sourceRef, - Sheet = sourceSheetName - }); - cacheDef.AppendChild(cacheSource); + // Row axis subtotal position depends on layout: + // compact/outline: subtotal BEFORE children (subtotalTop, default) + // tabular: subtotal AFTER children (matches Excel-authored tabular pivots) + // Col axis convention: subtotal col always AFTER children + // (matches multi_col_authored.xlsx ground truth). + bool subtotalAfter = isCol || ActiveLayoutMode == "tabular"; + if (!subtotalAfter) + yield return (node, false, true); + + foreach (var child in node.Children) + foreach (var entry in WalkAxisTreeRecursive(child, isCol)) + yield return entry; + + if (subtotalAfter) + yield return (node, false, true); + } - // CacheFields - var cacheFields = new CacheFields { Count = (uint)headers.Length }; - for (int i = 0; i < headers.Length; i++) + /// Count all internal nodes (subtotal positions) in a tree. + private static int CountSubtotalNodes(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++; + 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) + /// Count all leaf nodes in a tree. + private static int CountLeafNodes(AxisNode root) { - 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 _)); - - var sharedItems = new SharedItems { Count = (uint)uniqueValues.Count }; - - if (allNumeric && values.Any(v => !string.IsNullOrEmpty(v))) + int count = 0; + void Recurse(AxisNode n) { - // 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; + if (n.IsLeaf && n.Depth > 0) count++; + else foreach (var c in n.Children) Recurse(c); } - else + Recurse(root); + return count; + } + + // ==================== Geometry & Cache Readback Helpers ==================== + + /// Computed pivot table extent — anchor + bounding range + key offsets. + private readonly struct PivotGeometry + { + public PivotGeometry(int anchorCol, int anchorRow, int width, int height, int rowLabelCols, string rangeRef) { - // String field — enumerate shared items - foreach (var v in uniqueValues) - sharedItems.AppendChild(new StringItem { Val = v }); + AnchorCol = anchorCol; + AnchorRow = anchorRow; + Width = width; + Height = height; + RowLabelCols = rowLabelCols; + RangeRef = rangeRef; } - - field.AppendChild(sharedItems); - return field; + public int AnchorCol { get; } + public int AnchorRow { get; } + public int Width { get; } + public int Height { get; } + public int RowLabelCols { get; } + public string RangeRef { get; } } - // ==================== Pivot Table Definition Builder ==================== - - private static PivotTableDefinition BuildPivotTableDefinition( - string name, uint cacheId, string position, - string[] headers, List columnData, + /// + /// 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 filterFieldIndices, List<(int idx, string func, string name)> valueFields, - string styleName) - { - 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 - }; - - // Use typed property setters to ensure correct schema order - - // Location - pivotDef.Location = new Location + List<(int idx, string func, string showAs, string name)> valueFields) + { + int dataFieldCount = Math.Max(1, valueFields.Count); + // Compact: all row fields share one column. Outline/Tabular: one column per row field. + int rowLabelCols = ActiveLayoutMode == "compact" + ? 1 + : Math.Max(1, rowFieldIndices.Count); + + // CONSISTENCY(subtotals-opts): when subtotals=off, the per-group outer + // subtotal row (2+ row fields) and outer subtotal column (2+ col fields) + // are not rendered — shrink the geometry accordingly so location and + // sheetData stay consistent. + bool emitSubtotals = ActiveDefaultSubtotal; + + int valueCols, totalCols, dataRowCount, headerRows; + + // N≥3 on either axis, OR any axis is empty (0×*, 2×0): use AxisTree + // for both width and height counts. The tree handles empty axes + // naturally (zero leaves, zero subtotals). + // N≤2 with both axes non-empty: keep the existing specialized formulas + // (regression-tested via pivot_baselines). + if (rowFieldIndices.Count >= 3 || colFieldIndices.Count >= 3 + || rowFieldIndices.Count == 0 + || (rowFieldIndices.Count == 2 && colFieldIndices.Count == 0)) { - Reference = position, - FirstHeaderRow = 1u, - FirstDataRow = 1u, - FirstDataColumn = (uint)rowFieldIndices.Count - }; - - // PivotFields — one per source column - var pivotFields = new PivotFields { Count = (uint)headers.Length }; - for (int i = 0; i < headers.Length; i++) + 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). When subtotals + // are off, only leaf rows contribute — unless compact mode where + // parent group headers still appear as label-only rows. + bool compactLabelRows = !emitSubtotals && ActiveLayoutMode == "compact" + && rowFieldIndices.Count >= 2; + int rowSubtotals = (emitSubtotals || compactLabelRows) + ? CountSubtotalNodes(rowTree) : 0; + int rowLeaves = CountLeafNodes(rowTree); + dataRowCount = rowSubtotals + rowLeaves; + + int colSubtotals = emitSubtotals ? CountSubtotalNodes(colTree) : 0; + int colLeaves = CountLeafNodes(colTree); + // Per col position: K cells. Plus K grand totals. + // When there are no col fields, colLeaves=0 but we still need K + // value columns (one per data field). + int colPositionCount = colSubtotals + colLeaves; + valueCols = Math.Max(1, colPositionCount) * dataFieldCount; + totalCols = colFieldIndices.Count > 0 ? dataFieldCount : 0; + + // Header rows: + // colN == 0 && K == 1: single header row with row label caption + // + data field name. + // colN == 0 && K > 1: TWO header rows — R0 carries the "Values" + // axis caption at col B (Excel injects a synthetic + // col field for multi-data pivots, and dataCaption + // appears at this row), R1 carries the row-label + // caption at col A plus the K data field names + // across cols B..B+K-1. Verified against Excel- + // authored pivot files (ref="A3:F36", + // firstHeaderRow=1, firstDataRow=2). + // colN >= 1: 1 caption + N_col field-label rows + optional dfRow + // when K>1. + if (colFieldIndices.Count == 0) + headerRows = dataFieldCount > 1 ? 2 : 1; + else + headerRows = 1 + colFieldIndices.Count + (dataFieldCount > 1 ? 1 : 0); + } + else if (colFieldIndices.Count >= 2) { - 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 _)); - - if (rowFieldIndices.Contains(i)) + var groups = BuildOuterInnerGroups( + colFieldIndices[0], colFieldIndices[1], columnData); + // Each outer group contributes inners.Count leaf cols + 1 subtotal col. + // When subtotals=off, drop the per-group subtotal col. + valueCols = groups.Sum(g => (g.inners.Count + (emitSubtotals ? 1 : 0)) * dataFieldCount); + totalCols = dataFieldCount; + + if (rowFieldIndices.Count >= 2) { - pf.Axis = PivotTableAxisValues.AxisRow; - if (!isNumeric) AppendFieldItems(pf, values); + var rowGroups = BuildOuterInnerGroups( + rowFieldIndices[0], rowFieldIndices[1], columnData); + // Each outer group contributes g.inners.Count leaf rows + 1 subtotal row. + dataRowCount = rowGroups.Sum(g => (emitSubtotals ? 1 : 0) + g.inners.Count); } - else if (colFieldIndices.Contains(i)) + else { - pf.Axis = PivotTableAxisValues.AxisColumn; - if (!isNumeric) AppendFieldItems(pf, values); + dataRowCount = Math.Max(1, ProductOfUniqueValues(rowFieldIndices, columnData)); } - else if (filterFieldIndices.Contains(i)) + 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) { - pf.Axis = PivotTableAxisValues.AxisPage; - if (!isNumeric) AppendFieldItems(pf, values); + var rowGroups = BuildOuterInnerGroups( + rowFieldIndices[0], rowFieldIndices[1], columnData); + dataRowCount = rowGroups.Sum(g => (emitSubtotals ? 1 : 0) + g.inners.Count); } - else if (valueFields.Any(vf => vf.idx == i)) + else { - pf.DataField = true; + dataRowCount = Math.Max(1, ProductOfUniqueValues(rowFieldIndices, columnData)); } - pivotFields.AppendChild(pf); - } - pivotDef.PivotFields = pivotFields; - - // RowFields - if (rowFieldIndices.Count > 0) - { - 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; - } - - // ColumnFields - if (colFieldIndices.Count > 0) - { - var cf = new ColumnFields { Count = (uint)colFieldIndices.Count }; - foreach (var idx in colFieldIndices) - cf.AppendChild(new Field { Index = idx }); - pivotDef.ColumnFields = cf; + 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; } - // 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; - } + // 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; - // DataFields - if (valueFields.Count > 0) + // insertBlankRow: one blank row after each outer group's subtotal/last leaf. + int blankRowCount = 0; + if (ActiveInsertBlankRow && rowFieldIndices.Count >= 2) { - var df = new DataFields { Count = (uint)valueFields.Count }; - foreach (var (idx, func, displayName) in valueFields) - { - df.AppendChild(new DataField - { - Name = displayName, - Field = (uint)idx, - Subtotal = ParseSubtotal(func), - BaseField = 0, - BaseItem = 0u - }); - } - pivotDef.DataFields = df; + int outerGroups = rowFieldIndices[0] < columnData.Count + ? columnData[rowFieldIndices[0]].Where(v => !string.IsNullOrEmpty(v)).Distinct().Count() + : 0; + blankRowCount = outerGroups; } - // Style - pivotDef.PivotTableStyle = new PivotTableStyle - { - Name = styleName, - ShowRowHeaders = true, - ShowColumnHeaders = true, - ShowRowStripes = false, - ShowColumnStripes = false, - ShowLastColumn = true - }; + int width = rowLabelCols + valueCols + totalCols; + int height = headerRows + dataRowCount + blankRowCount + grandRowHeight; - return pivotDef; - } + 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}"; - private static void AppendFieldItems(PivotField pf, string[] values) - { - var unique = values.Where(v => !string.IsNullOrEmpty(v)).Distinct().OrderBy(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 }); - items.AppendChild(new Item { ItemType = ItemValues.Default }); // grand total - pf.AppendChild(items); + return new PivotGeometry(anchorColIdx, anchorRow, width, height, rowLabelCols, rangeRef); } - // ==================== Readback ==================== - - internal static void ReadPivotTableProperties(PivotTableDefinition pivotDef, DocumentNode node) + /// + /// Build the <location> element with offsets that match what the + /// renderer will actually write to sheetData. Shared by BuildPivotTableDefinition + /// (initial creation) and RebuildFieldAreas (post-Set rebuild) so the two + /// paths stay in sync. + /// + /// For the (N row × 0 col × K data) shape, Excel's canonical layout is a + /// SINGLE header row at the top of the range, so firstHeaderRow=0 and + /// firstDataRow=1 (verified against Excel-authored pivot in test_encrypted.xlsx: + /// 4 row × 0 col × 5 data × 1 filter ⇒ ref="A3:F42", firstHeaderRow=0, + /// firstDataRow=1, firstDataCol=1). For pivots with col fields, keep the + /// previous convention (firstHeaderRow=1 = second row of the range, offset + /// by the existing baselines under tests/pivot_baselines/). + /// + private static Location BuildLocation( + PivotGeometry geom, + List rowFieldIndices, + List colFieldIndices, + List<(int idx, string func, string showAs, string name)> valueFields, + int filterCount) { - 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; - - // Count fields - var pivotFields = pivotDef.GetFirstChild(); - if (pivotFields != null) - node.Format["fieldCount"] = pivotFields.Elements().Count(); - - // Row fields - var rowFields = pivotDef.RowFields; - if (rowFields != null) + uint firstHeaderRow; + uint firstDataRow; + if (colFieldIndices.Count == 0) { - 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); + // colN==0 && K==1: single header row at the top. + // compact/outline: firstHeaderRow=0, firstDataRow=1 + // tabular: firstHeaderRow=1, firstDataRow=1 (header and first + // data row share the same row — verified against + // Excel-authored tabular pivot) + // colN==0 && K>1: two header rows — "Values" axis caption at R0 + // and row-field caption + data field names at R1 + // (firstHeaderRow=1, firstDataRow=2). + if (valueFields.Count > 1) + { + firstHeaderRow = 1u; + firstDataRow = 2u; + } + else if (ActiveLayoutMode == "tabular") + { + firstHeaderRow = 1u; + firstDataRow = 1u; + } + else + { + firstHeaderRow = 0u; + firstDataRow = 1u; + } } - - // Column fields - var colFields = pivotDef.ColumnFields; - if (colFields != null) + else { - 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); + firstHeaderRow = 1u; + firstDataRow = (colFieldIndices.Count >= 2 && valueFields.Count > 1) ? 4u + : ((valueFields.Count > 1 || colFieldIndices.Count >= 2) ? 3u : 2u); } - // Page/filter fields - var pageFields = pivotDef.PageFields; - if (pageFields != null) + var location = new Location { - 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); - } + Reference = geom.RangeRef, + FirstHeaderRow = firstHeaderRow, + FirstDataRow = firstDataRow, + FirstDataColumn = (uint)geom.RowLabelCols + }; - // Data fields (use typed property for reliable access) - var dataFields = pivotDef.DataFields; - if (dataFields != null) + // rowPageCount / colPageCount: number of rows / columns the page filter + // area occupies ABOVE the location range. Without these attributes, + // Excel guesses filter-dropdown placement and ends up drawing the + // dropdown one row below the actual filter cell (verified in the + // regenerated encrypted_replica.xlsx). Excel-authored files + // consistently emit both as 1 when the pivot has any page filter + // (all filters stacked vertically on the outer row axis). + // + // Open XML SDK 3.x does not model these in the typed Location class, + // so set them as raw unknown attributes. The serializer writes + // unknown attributes without schema validation. Empty namespace URI + // means unprefixed, inheriting the element's default namespace + // (spreadsheetml main). + if (filterCount > 0) { - 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}"; - } + location.SetAttribute(new OpenXmlAttribute("rowPageCount", "", filterCount.ToString())); + location.SetAttribute(new OpenXmlAttribute("colPageCount", "", "1")); } - // Style - var styleInfo = pivotDef.PivotTableStyle; - if (styleInfo?.Name?.HasValue == true) - node.Format["style"] = styleInfo.Name.Value; + return location; } - internal static List SetPivotTableProperties(PivotTablePart pivotPart, Dictionary properties) + /// + /// 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 unsupported = new List(); - var pivotDef = pivotPart.PivotTableDefinition; - if (pivotDef == null) { unsupported.AddRange(properties.Keys); return unsupported; } - - // Collect field-area properties separately — they require a coordinated rebuild - var fieldAreaProps = new Dictionary(); - - foreach (var (key, value) in properties) + 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++) { - switch (key.ToLowerInvariant()) + var items = fieldList[f].GetFirstChild(); + var list = new List(); + if (items != null) { - case "name": - pivotDef.Name = value; - break; - case "style": + foreach (var child in items.ChildElements) { - pivotDef.PivotTableStyle = new PivotTableStyle + list.Add(child switch { - Name = value, - ShowRowHeaders = true, - ShowColumnHeaders = true, - ShowRowStripes = false, - ShowColumnStripes = false, - ShowLastColumn = true - }; - break; + 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 + }); } - case "rows": - case "cols" or "columns": - case "values": - case "filters": - fieldAreaProps[key.ToLowerInvariant() == "columns" ? "cols" : key.ToLowerInvariant()] = value; - break; - default: - unsupported.Add(key); - break; } + perFieldStrings.Add(list); } - // If any field areas were specified, rebuild them - if (fieldAreaProps.Count > 0) - RebuildFieldAreas(pivotPart, pivotDef, fieldAreaProps); - - pivotDef.Save(); - return unsupported; - } - - /// - /// Rebuild pivot table field areas (rows, cols, values, filters). - /// For areas not specified in changes, preserves the current assignment. - /// Two-layer update: (1) PivotField.Axis/DataField, (2) RowFields/ColumnFields/PageFields/DataFields. - /// - private static void RebuildFieldAreas(PivotTablePart pivotPart, PivotTableDefinition pivotDef, - Dictionary changes) - { - // Get headers from cache definition - var cachePart = pivotPart.GetPartsOfType().FirstOrDefault(); - if (cachePart?.PivotCacheDefinition == null) return; - - var cacheFields = cachePart.PivotCacheDefinition.GetFirstChild(); - if (cacheFields == null) return; - - var headers = cacheFields.Elements().Select(cf => cf.Name?.Value ?? "").ToArray(); - if (headers.Length == 0) return; - - // Read current assignments for areas NOT being changed - var currentRows = ReadCurrentFieldIndices(pivotDef.RowFields?.Elements(), f => f.Index?.Value ?? -1); - var currentCols = ReadCurrentFieldIndices(pivotDef.ColumnFields?.Elements(), f => f.Index?.Value ?? -1); - var currentFilters = ReadCurrentFieldIndices(pivotDef.PageFields?.Elements(), f => f.Field?.Value ?? -1); - var currentValues = ReadCurrentDataFields(pivotDef.DataFields); - - // Parse new assignments (or keep current) - // If user specified a non-empty value but nothing resolved, warn via stderr - var rowFieldIndices = changes.ContainsKey("rows") - ? ParseFieldListWithWarning(changes, "rows", headers) - : currentRows; - var colFieldIndices = changes.ContainsKey("cols") - ? ParseFieldListWithWarning(changes, "cols", headers) - : currentCols; - var filterFieldIndices = changes.ContainsKey("filters") - ? ParseFieldListWithWarning(changes, "filters", headers) - : currentFilters; - var valueFields = changes.ContainsKey("values") - ? ParseValueFieldsWithWarning(changes, "values", headers) - : currentValues; - - // Layer 1: Reset all PivotField axis/dataField, then re-assign - var pivotFields = pivotDef.PivotFields; - if (pivotFields == null) return; - - var pfList = pivotFields.Elements().ToList(); - for (int i = 0; i < pfList.Count; i++) - { - var pf = pfList[i]; - // Clear axis and dataField - pf.Axis = null; - pf.DataField = null; - pf.RemoveAllChildren(); - - // Determine if this field's cache data is numeric (for Items generation) - var isNumeric = IsFieldNumeric(cacheFields, i); - - if (rowFieldIndices.Contains(i)) - { - pf.Axis = PivotTableAxisValues.AxisRow; - if (!isNumeric) AppendFieldItemsFromCache(pf, cacheFields, i); - } - else if (colFieldIndices.Contains(i)) - { - pf.Axis = PivotTableAxisValues.AxisColumn; - if (!isNumeric) AppendFieldItemsFromCache(pf, cacheFields, i); - } - else if (filterFieldIndices.Contains(i)) - { - pf.Axis = PivotTableAxisValues.AxisPage; - if (!isNumeric) AppendFieldItemsFromCache(pf, cacheFields, i); - } - else if (valueFields.Any(vf => vf.idx == i)) - { - pf.DataField = true; - } - } - - // Layer 2: Rebuild area reference lists - // RowFields - if (rowFieldIndices.Count > 0) - { - 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 - { - pivotDef.RowFields = null; - } - - // ColumnFields - if (colFieldIndices.Count > 0 || valueFields.Count > 1) - { - var cf = new ColumnFields(); - foreach (var idx in colFieldIndices) - cf.AppendChild(new Field { Index = idx }); - // -2 sentinel for multiple value fields in columns - if (valueFields.Count > 1) - cf.AppendChild(new Field { Index = -2 }); - cf.Count = (uint)cf.Elements().Count(); - pivotDef.ColumnFields = cf; - } - else - { - pivotDef.ColumnFields = null; - } - - // 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; - } - else - { - pivotDef.PageFields = null; - } + 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]); - // DataFields - if (valueFields.Count > 0) + for (int r = 0; r < recordList.Count; r++) { - var df = new DataFields { Count = (uint)valueFields.Count }; - foreach (var (idx, func, displayName) in valueFields) + var record = recordList[r]; + var children = record.ChildElements.ToList(); + for (int f = 0; f < fieldCount && f < children.Count; f++) { - df.AppendChild(new DataField + columnData[f][r] = children[f] switch { - Name = displayName, - Field = (uint)idx, - Subtotal = ParseSubtotal(func), - BaseField = 0, - BaseItem = 0u - }); + 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 + }; } - pivotDef.DataFields = df; - } - else - { - pivotDef.DataFields = null; } - // Update Location.FirstDataColumn - var location = pivotDef.Location; - if (location != null) - location.FirstDataColumn = (uint)rowFieldIndices.Count; + return (headers, columnData); } - private static List ReadCurrentFieldIndices(IEnumerable? elements, Func getIndex) - { - if (elements == null) return new List(); - return elements.Select(getIndex).Where(i => i >= 0).ToList(); - } - - private static List<(int idx, string func, string name)> ReadCurrentDataFields(DataFields? dataFields) - { - if (dataFields == null) return new List<(int, string, string)>(); - return dataFields.Elements().Select(df => ( - idx: (int)(df.Field?.Value ?? 0), - func: df.Subtotal?.InnerText ?? "sum", - name: df.Name?.Value ?? "" - )).ToList(); - } - - private static bool IsFieldNumeric(CacheFields cacheFields, int index) + /// + /// 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 cf = cacheFields.Elements().ElementAtOrDefault(index); - var sharedItems = cf?.GetFirstChild(); - if (sharedItems == null) return false; - return sharedItems.ContainsNumber?.Value == true && sharedItems.ContainsString?.Value != true; - } + 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); - private static void AppendFieldItemsFromCache(PivotField pf, CacheFields cacheFields, int index) - { - var cf = cacheFields.Elements().ElementAtOrDefault(index); - var sharedItems = cf?.GetFirstChild(); - var count = sharedItems?.Elements().Count() ?? 0; - if (count == 0) return; + var rowsToRemove = new List(); + foreach (var row in sheetData.Elements()) + { + var rIdx = (int)(row.RowIndex?.Value ?? 0); + if (rIdx < startRow || rIdx > endRow) continue; - var items = new Items { Count = (uint)(count + 1) }; - for (int i = 0; i < count; i++) - items.AppendChild(new Item { Index = (uint)i }); - items.AppendChild(new Item { ItemType = ItemValues.Default }); // grand total - pf.AppendChild(items); + 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(); } - // ==================== Parse Helpers ==================== - - private static List ParseFieldListWithWarning(Dictionary props, string key, string[] headers) + /// + /// Merge duplicate <row> elements in sheetData into one element per + /// RowIndex, consolidating all Cell children into the winner in column + /// order. Also sorts the resulting rows by RowIndex. + /// + /// Why: OOXML schema requires each <row r="N"> to be unique within + /// <sheetData>. When a second pivot is added to a sheet that already + /// has pivot-rendered rows (e.g. a second pivot at J1 alongside an E1 + /// pivot in the same sheet), the per-renderer "new Row { RowIndex=N }; + /// sheetData.AppendChild(row)" pattern creates duplicates for any row + /// index the two pivots share. Excel rejects the file with "We found a + /// problem with some content" at open. + /// + /// Call this at the tail of any render path that may have appended rows. + /// + private static void DedupeSheetDataRows(SheetData sheetData) { - var result = ParseFieldList(props, key, headers); - if (result.Count == 0 && props.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value)) + // Group by RowIndex. Rows without RowIndex are left alone. + var byIdx = new Dictionary>(); + foreach (var row in sheetData.Elements().ToList()) { - var available = string.Join(", ", headers.Where(h => !string.IsNullOrEmpty(h))); - Console.Error.WriteLine($"WARNING: No matching fields for {key}={value}. Available: {available}"); + var idx = row.RowIndex?.Value; + if (idx == null) continue; + if (!byIdx.TryGetValue(idx.Value, out var list)) + { + list = new List(); + byIdx[idx.Value] = list; + } + list.Add(row); } - return result; - } - private static List<(int idx, string func, string name)> ParseValueFieldsWithWarning( - Dictionary props, string key, string[] headers) - { - var result = ParseValueFields(props, key, headers); - if (result.Count == 0 && props.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value)) + foreach (var (idx, list) in byIdx) { - var available = string.Join(", ", headers.Where(h => !string.IsNullOrEmpty(h))); - Console.Error.WriteLine($"WARNING: No matching fields for {key}={value}. Available: {available}"); + if (list.Count <= 1) continue; + // Merge: keep the first row element, move all cells from the rest + // into it, then remove the empty duplicates. + var winner = list[0]; + for (int i = 1; i < list.Count; i++) + { + foreach (var cell in list[i].Elements().ToList()) + { + cell.Remove(); + winner.AppendChild(cell); + } + list[i].Remove(); + } + // Sort cells by column index for Excel-friendly ordering. + var sorted = winner.Elements() + .OrderBy(c => ColToIndex((c.CellReference?.Value ?? "A1") + .TrimEnd('0','1','2','3','4','5','6','7','8','9'))) + .ToList(); + foreach (var c in sorted) { c.Remove(); winner.AppendChild(c); } } - return result; - } - - 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 name = f.Trim(); - // Try as column index first - if (int.TryParse(name, out var idx)) return idx; - // Try as header name - 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(); + // Sort rows themselves by RowIndex to keep sheetData ordered. + var orderedRows = sheetData.Elements() + .OrderBy(r => r.RowIndex?.Value ?? 0) + .ToList(); + foreach (var r in orderedRows) { r.Remove(); sheetData.AppendChild(r); } } - private static List<(int idx, string func, string name)> ParseValueFields( - Dictionary props, string key, string[] headers) + /// + /// Re-materialize pivot table cells for all pivots in the given worksheet. + /// Called before HTML rendering so that existing Excel files whose sheetData + /// contains stale/minimal pivot cache get properly expanded with hierarchical + /// row labels and aggregated values. + /// + internal static void RefreshPivotCellsForView(WorksheetPart worksheetPart) { - if (!props.TryGetValue(key, out var value) || string.IsNullOrEmpty(value)) - return new List<(int, string, string)>(); + var pivotParts = worksheetPart.PivotTableParts.ToList(); + if (pivotParts.Count == 0) return; - var result = new List<(int idx, string func, string name)>(); - foreach (var spec in value.Split(',')) + foreach (var pivotPart in pivotParts) { - // Format: "FieldName:func" or "FieldName" (default sum) - var parts = spec.Trim().Split(':'); - var fieldName = parts[0].Trim(); - var func = parts.Length > 1 ? parts[1].Trim().ToLowerInvariant() : "sum"; - - int fieldIdx = -1; - if (int.TryParse(fieldName, out var idx)) fieldIdx = idx; - else + var pivotDef = pivotPart.PivotTableDefinition; + if (pivotDef == null) continue; + + var cachePart = pivotPart.GetPartsOfType().FirstOrDefault(); + if (cachePart?.PivotCacheDefinition == null) continue; + + var cacheFields = cachePart.PivotCacheDefinition.GetFirstChild(); + if (cacheFields == null) continue; + + // Read field assignments from the existing definition + var rowFieldIndices = ReadCurrentFieldIndices( + pivotDef.RowFields?.Elements(), f => f.Index?.Value ?? -1); + var colFieldIndices = ReadCurrentFieldIndices( + pivotDef.ColumnFields?.Elements(), f => f.Index?.Value ?? -1); + var filterFieldIndices = ReadCurrentFieldIndices( + pivotDef.PageFields?.Elements(), f => f.Field?.Value ?? -1); + var valueFields = ReadCurrentDataFields(pivotDef.DataFields); + + if (valueFields.Count == 0) continue; + + // Read cache data + var (cacheHeaders, cacheColumnData) = ReadColumnDataFromCache( + cachePart.PivotCacheDefinition, + cachePart.GetPartsOfType().FirstOrDefault()?.PivotCacheRecords); + if (cacheColumnData.Count == 0) continue; + + // Detect layout mode from existing definition + string? layoutMode = null; + if (pivotDef.Compact?.Value == false) { - for (int i = 0; i < headers.Length; i++) - if (headers[i] != null && headers[i].Equals(fieldName, StringComparison.OrdinalIgnoreCase)) { fieldIdx = i; break; } + var firstAxisField = pivotDef.PivotFields?.Elements() + .FirstOrDefault(pf => pf.Axis != null); + if (firstAxisField?.Outline?.Value == false) + layoutMode = "tabular"; + else + layoutMode = "outline"; } - if (fieldIdx >= 0 && fieldIdx < headers.Length) + // Detect grand totals from definition (OOXML mapping is swapped) + bool? rowGT = pivotDef.ColumnGrandTotals?.Value == false ? false : null; + bool? colGT = pivotDef.RowGrandTotals?.Value == false ? false : null; + + // Detect subtotals + bool? defaultSubtotal = null; + if (pivotDef.PivotFields != null) { - var displayName = $"{char.ToUpper(func[0])}{func[1..]} of {headers[fieldIdx]}"; - result.Add((fieldIdx, func, displayName)); + foreach (var pf in pivotDef.PivotFields.Elements()) + { + if (pf.DefaultSubtotal?.Value == false) + { + defaultSubtotal = false; + break; + } + } } - } - return result; - } - private static DataConsolidateFunctionValues ParseSubtotal(string func) - { - return func.ToLowerInvariant() switch - { - "sum" => DataConsolidateFunctionValues.Sum, - "count" => DataConsolidateFunctionValues.Count, - "average" or "avg" => DataConsolidateFunctionValues.Average, - "max" => DataConsolidateFunctionValues.Maximum, - "min" => DataConsolidateFunctionValues.Minimum, - "product" => DataConsolidateFunctionValues.Product, - "stddev" => DataConsolidateFunctionValues.StandardDeviation, - "var" => DataConsolidateFunctionValues.Variance, - _ => DataConsolidateFunctionValues.Sum - }; - } - - private static (string col, int row) ParseCellRef(string cellRef) - { - int i = 0; - while (i < cellRef.Length && char.IsLetter(cellRef[i])) i++; - var col = cellRef[..i].ToUpperInvariant(); - var row = int.TryParse(cellRef[i..], out var r) ? r : 1; - return (col, row); - } + // Push thread-static options for the render pass + var prevLayout = _layoutMode; + var prevRowGT = _rowGrandTotals; + var prevColGT = _colGrandTotals; + var prevSubtotal = _defaultSubtotal; + try + { + _layoutMode = layoutMode; + _rowGrandTotals = rowGT; + _colGrandTotals = colGT; + _defaultSubtotal = defaultSubtotal; + + // Determine anchor position from the existing Location + var locationRef = pivotDef.Location?.Reference?.Value; + var anchorRef = locationRef?.Split(':')[0] ?? "A1"; + + // Clear old cells and re-render + var ws = worksheetPart.Worksheet; + var sheetData = ws?.GetFirstChild(); + if (ws != null && sheetData != null && locationRef != null) + { + ClearPivotRangeCells(sheetData, locationRef); - private static int ColToIndex(string col) - { - int result = 0; - foreach (var c in col.ToUpperInvariant()) - result = result * 26 + (c - 'A' + 1); - return result; + // Try to get source column styles for number formatting + uint?[]? sourceColumnStyleIds = null; + try + { + var wbPart = worksheetPart.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) + { + var (_, _, ids) = ReadSourceData(srcWsPart, srcRef); + sourceColumnStyleIds = ids; + } + } + } + catch { /* best-effort */ } + + RenderPivotIntoSheet( + worksheetPart, anchorRef, cacheHeaders, cacheColumnData, + rowFieldIndices, colFieldIndices, valueFields, filterFieldIndices, + sourceColumnStyleIds); + + DedupeSheetDataRows(sheetData); + } + } + finally + { + _layoutMode = prevLayout; + _rowGrandTotals = prevRowGT; + _colGrandTotals = prevColGT; + _defaultSubtotal = prevSubtotal; + } + } } } diff --git a/src/officecli/Core/RawXmlHelper.cs b/src/officecli/Core/RawXmlHelper.cs index 60aea790d..7f2d68145 100644 --- a/src/officecli/Core/RawXmlHelper.cs +++ b/src/officecli/Core/RawXmlHelper.cs @@ -14,7 +14,7 @@ namespace OfficeCli.Core; /// Shared helper for raw XML operations (read/write via XPath). /// This enables AI to perform any OpenXML operation by manipulating XML directly. /// -public static class RawXmlHelper +internal static class RawXmlHelper { /// /// Perform a raw XML operation on a document part's root element. diff --git a/src/officecli/Core/ResidentClient.cs b/src/officecli/Core/ResidentClient.cs deleted file mode 100644 index 5471cd4e4..000000000 --- a/src/officecli/Core/ResidentClient.cs +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright 2025 OfficeCli (officecli.ai) -// SPDX-License-Identifier: Apache-2.0 - -using System.IO.Pipes; -using System.Text; - -namespace OfficeCli.Core; - -public static class ResidentClient -{ - /// - /// Check if a resident is running for this file (without consuming a connection). - /// Just tries to connect briefly. - /// - public static bool TryConnect(string filePath, out string pipeName) - { - pipeName = ResidentServer.GetPipeName(filePath); - try - { - 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); - - var responseLine = reader.ReadLine(); - if (responseLine == null) return false; - - var response = System.Text.Json.JsonSerializer.Deserialize(responseLine, ResidentJsonContext.Default.ResidentResponse); - if (response == null) return false; - - // Stdout contains the file path when responding to ping - if (string.IsNullOrEmpty(response.Stdout)) return false; - var residentFilePath = Path.GetFullPath(response.Stdout); - var requestedFilePath = Path.GetFullPath(filePath); - return string.Equals(residentFilePath, requestedFilePath, StringComparison.OrdinalIgnoreCase); - } - catch - { - return false; - } - } - - /// - /// Send a command to the resident server in a single connection. - /// Returns null if no resident is running or the file doesn't match. - /// - public static ResidentResponse? TrySend(string filePath, ResidentRequest request, int maxRetries = 2) - { - var pipeName = ResidentServer.GetPipeName(filePath); - for (int attempt = 0; attempt <= maxRetries; attempt++) - { - try - { - 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); - - var responseLine = reader.ReadLine(); - if (responseLine == null) continue; - - var response = System.Text.Json.JsonSerializer.Deserialize(responseLine, ResidentJsonContext.Default.ResidentResponse); - if (response != null) return response; - } - catch - { - if (attempt == maxRetries) return null; - Thread.Sleep(50 * (attempt + 1)); // brief backoff before retry - } - } - return null; - } - - /// - /// Send a close command to the resident server. - /// - public static bool SendClose(string filePath) - { - // Send close via the dedicated ping pipe (always responsive) - var pipeName = ResidentServer.GetPipeName(filePath) + "-ping"; - try - { - 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); - - var responseLine = reader.ReadLine(); - if (responseLine == null) return false; - - var response = System.Text.Json.JsonSerializer.Deserialize(responseLine, ResidentJsonContext.Default.ResidentResponse); - return response != null && response.ExitCode == 0; - } - catch - { - return false; - } - } -} diff --git a/src/officecli/Core/ResidentServer.cs b/src/officecli/Core/ResidentServer.cs deleted file mode 100644 index 1b1a7175c..000000000 --- a/src/officecli/Core/ResidentServer.cs +++ /dev/null @@ -1,763 +0,0 @@ -// Copyright 2025 OfficeCli (officecli.ai) -// SPDX-License-Identifier: Apache-2.0 - -using System.IO.Pipes; -using System.Security.Cryptography; -using System.Text; - -namespace OfficeCli.Core; - -public class ResidentServer : IDisposable -{ - private readonly IDocumentHandler _handler; - private readonly string _filePath; - private readonly string _pipeName; - private CancellationTokenSource _cts = new(); - private readonly SemaphoreSlim _commandLock = new(1, 1); - private readonly TimeSpan _idleTimeout = TimeSpan.FromMinutes(12); - private CancellationTokenSource _idleCts = new(); - private bool _disposed; - - public string PipeName => _pipeName; - - public ResidentServer(string filePath, bool editable = true) - { - _filePath = Path.GetFullPath(filePath); - _pipeName = GetPipeName(_filePath); - _handler = DocumentHandlerFactory.Open(_filePath, editable); - } - - public static string GetPipeName(string filePath) - { - var fullPath = Path.GetFullPath(filePath); - if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()) - fullPath = fullPath.ToUpperInvariant(); - var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(fullPath)))[..16]; - return $"officecli-{hash}"; - } - - public async Task RunAsync(CancellationToken externalToken = default) - { - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, externalToken); - var token = linkedCts.Token; - - // Start ping responder on a dedicated pipe (never blocked by business commands) - var pingTask = RunPingResponderAsync(token); - - // Start idle watchdog - var idleTask = RunIdleWatchdogAsync(token); - - // Main command loop - accept connections concurrently, serialize command execution - while (!token.IsCancellationRequested) - { - var server = new NamedPipeServerStream(_pipeName, PipeDirection.InOut, - NamedPipeServerStream.MaxAllowedServerInstances, - PipeTransmissionMode.Byte, PipeOptions.Asynchronous); - try - { - await server.WaitForConnectionAsync(token); - // Handle client asynchronously so we can accept the next connection - _ = HandleClientWithLockAsync(server, token); - } - catch (OperationCanceledException) - { - await server.DisposeAsync(); - break; - } - catch (Exception ex) - { - Console.Error.WriteLine($"Resident error: {ex.Message}"); - await server.DisposeAsync(); - } - } - - // Both tasks observe the same token; swallow cancellation on shutdown - try { await pingTask; } catch (OperationCanceledException) { } - try { await idleTask; } catch (OperationCanceledException) { } - } - - private void ResetIdleTimer() - { - // Cancel the old idle CTS to restart the delay; do not Dispose because - // RunIdleWatchdogAsync may race between Volatile.Read and .Token access. - var oldCts = Interlocked.Exchange(ref _idleCts, new CancellationTokenSource()); - oldCts.Cancel(); - } - - private async Task RunIdleWatchdogAsync(CancellationToken token) - { - while (!token.IsCancellationRequested) - { - try - { - // Snapshot the current idle CTS; ResetIdleTimer() swaps it to restart the wait - var idleCts = Volatile.Read(ref _idleCts); - using var linked = CancellationTokenSource.CreateLinkedTokenSource(idleCts.Token, token); - await Task.Delay(_idleTimeout, linked.Token); - - // Reached here = idle timeout elapsed without reset - Console.Error.WriteLine($"Resident idle for {_idleTimeout.TotalMinutes} minutes, closing."); - _cts.Cancel(); - break; - } - catch (OperationCanceledException) when (!token.IsCancellationRequested) - { - // _idleCts was cancelled (timer reset), loop and wait again - } - } - } - - private async Task RunPingResponderAsync(CancellationToken token) - { - var pingPipeName = _pipeName + "-ping"; - while (!token.IsCancellationRequested) - { - var server = new NamedPipeServerStream(pingPipeName, PipeDirection.InOut, - NamedPipeServerStream.MaxAllowedServerInstances, - PipeTransmissionMode.Byte, PipeOptions.Asynchronous); - try - { - 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) - { - 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__") - { - var response = MakeResponse(0, "Closing resident.", ""); - await writer.WriteLineAsync(response.AsMemory(), token); - _cts.Cancel(); - break; - } - } - } - catch (OperationCanceledException) - { - break; - } - catch - { - // Ignore ping errors - } - finally - { - await server.DisposeAsync(); - } - } - } - - private async Task HandleClientWithLockAsync(NamedPipeServerStream server, CancellationToken token) - { - try - { - await _commandLock.WaitAsync(token); - try - { - ResetIdleTimer(); - await HandleClientAsync(server, token); - } - finally - { - _commandLock.Release(); - ResetIdleTimer(); - } - } - catch (OperationCanceledException) { } - catch (Exception ex) - { - Console.Error.WriteLine($"Resident error: {ex.Message}"); - } - finally - { - await server.DisposeAsync(); - } - } - - 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); - if (requestLine == null) return; - - var response = ProcessRequest(requestLine); - await writer.WriteLineAsync(response.AsMemory(), token); - } - - private string ProcessRequest(string requestLine) - { - ResidentRequest? request = null; - try - { - request = System.Text.Json.JsonSerializer.Deserialize(requestLine, ResidentJsonContext.Default.ResidentRequest); - if (request == null) - return MakeResponse(1, "", "Invalid request"); - - // Capture stdout/stderr (safe: _commandLock serializes all commands) - var stdoutWriter = new StringWriter(); - var stderrWriter = new StringWriter(); - var origOut = Console.Out; - var origErr = Console.Error; - Console.SetOut(stdoutWriter); - Console.SetError(stderrWriter); - - try - { - ExecuteCommand(request); - } - finally - { - Console.SetOut(origOut); - Console.SetError(origErr); - } - - var stdout = stdoutWriter.ToString().TrimEnd('\r', '\n'); - var stderr = stderrWriter.ToString().TrimEnd('\r', '\n'); - - if (request.Json) - { - // JSON mode: server builds the envelope so client just passes through - var warnings = BuildWarnings(stderr); - var isFailure = string.IsNullOrEmpty(stdout) && warnings is { Count: > 0 } - || stdout.StartsWith("No properties applied", StringComparison.Ordinal); - var envelope = IsJson(stdout) - ? OutputFormatter.WrapEnvelope(stdout, warnings) - : isFailure - ? OutputFormatter.WrapEnvelopeError(stdout, warnings) - : OutputFormatter.WrapEnvelopeText(stdout, warnings); - return MakeResponse(0, envelope, ""); - } - - return MakeResponse(0, stdout, stderr); - } - catch (Exception ex) - { - if (request?.Json == true) - { - // JSON mode: wrap error in envelope - return MakeResponse(1, OutputFormatter.WrapErrorEnvelope(ex), ""); - } - return MakeResponse(1, "", ex.Message); - } - } - - private static bool IsJson(string s) - { - var trimmed = s.AsSpan().TrimStart(); - return trimmed.Length > 0 && (trimmed[0] == '{' || trimmed[0] == '['); - } - - private static List? BuildWarnings(string stderr) - { - if (string.IsNullOrEmpty(stderr)) return null; - var lines = stderr.Split('\n', StringSplitOptions.RemoveEmptyEntries); - if (lines.Length == 0) return null; - return lines.Select(line => - { - var warning = new CliWarning { Message = line.Trim() }; - if (line.Contains("UNSUPPORTED")) warning.Code = "unsupported_property"; - else if (line.Contains("VALIDATION")) warning.Code = "validation_error"; - else warning.Code = "warning"; - return warning; - }).ToList(); - } - - private void ExecuteCommand(ResidentRequest request) - { - var format = request.Json ? OutputFormat.Json : OutputFormat.Text; - - switch (request.Command) - { - case "view": - ExecuteView(request, format); - break; - case "get": - ExecuteGet(request, format); - break; - case "query": - ExecuteQuery(request, format); - break; - case "set": - ExecuteSet(request); - NotifyWatchSlideChanged(request.GetArg("path")); - break; - case "add": - { - var oldCount = GetPptSlideCount(); - ExecuteAdd(request); - var parent = request.GetArg("parent"); - if (parent == "/") - NotifyWatchRootChanged(oldCount); - else - NotifyWatchSlideChanged(parent); - break; - } - case "remove": - { - var oldCount = GetPptSlideCount(); - var path = request.GetArg("path"); - ExecuteRemove(request); - if (WatchMessage.ExtractSlideNum(path) > 0 && path != null && !path.Contains("/shape[")) - NotifyWatchRootChanged(oldCount); - else - NotifyWatchSlideChanged(path); - break; - } - case "move": - ExecuteMove(request); - NotifyWatchSlideChanged(request.GetArg("path")); - break; - case "raw": - ExecuteRaw(request); - break; - case "raw-set": - ExecuteRawSet(request); - NotifyWatchFullRefresh(); - break; - case "add-part": - ExecuteAddPart(request); - NotifyWatchFullRefresh(); - break; - case "validate": - ExecuteValidate(); - break; - default: - Console.Error.WriteLine($"Unknown command: {request.Command}"); - break; - } - } - - // ==================== Watch notification helpers ==================== - - private int GetPptSlideCount() - { - if (_handler is OfficeCli.Handlers.PowerPointHandler ppt) - return ppt.GetSlideCount(); - return 0; - } - - private void NotifyWatchSlideChanged(string? changedPath) - { - if (_handler is OfficeCli.Handlers.ExcelHandler excel) - { - string? scrollTo = null; - var sheetName = WatchMessage.ExtractSheetName(changedPath); - if (sheetName != null) - { - var idx = excel.GetSheetIndex(sheetName); - if (idx >= 0) scrollTo = $".sheet-content[data-sheet=\"{idx}\"]"; - } - WatchNotifier.NotifyIfWatching(_filePath, new WatchMessage { Action = "full", FullHtml = excel.ViewAsHtml(), ScrollTo = scrollTo }); - return; - } - if (_handler is OfficeCli.Handlers.WordHandler word) - { - var scrollTo = WatchMessage.ExtractWordScrollTarget(changedPath); - WatchNotifier.NotifyIfWatching(_filePath, new WatchMessage { Action = "full", FullHtml = word.ViewAsHtml(), ScrollTo = scrollTo }); - return; - } - if (_handler is not OfficeCli.Handlers.PowerPointHandler ppt) return; - var slideNum = WatchMessage.ExtractSlideNum(changedPath); - if (slideNum > 0) - { - var html = ppt.RenderSlideHtml(slideNum); - if (html != null) - { - WatchNotifier.NotifyIfWatching(_filePath, new WatchMessage { Action = "replace", Slide = slideNum, Html = html }); - return; - } - } - WatchNotifier.NotifyIfWatching(_filePath, new WatchMessage { Action = "full" }); - } - - private void NotifyWatchRootChanged(int oldSlideCount) - { - if (_handler is OfficeCli.Handlers.WordHandler word) - { - var html = word.ViewAsHtml(); - var pageCount = System.Text.RegularExpressions.Regex.Matches(html, @"data-page=""\d+""").Count; - var scrollTo = pageCount > 0 ? $".page[data-page=\"{pageCount}\"]" : null; - WatchNotifier.NotifyIfWatching(_filePath, new WatchMessage { Action = "full", FullHtml = html, ScrollTo = scrollTo }); - return; - } - if (_handler is OfficeCli.Handlers.ExcelHandler excel) - { - WatchNotifier.NotifyIfWatching(_filePath, new WatchMessage { Action = "full", FullHtml = excel.ViewAsHtml() }); - return; - } - if (_handler is not OfficeCli.Handlers.PowerPointHandler ppt) return; - var newCount = ppt.GetSlideCount(); - if (newCount > oldSlideCount) - { - var html = ppt.RenderSlideHtml(newCount); - if (html != null) - { - WatchNotifier.NotifyIfWatching(_filePath, new WatchMessage { Action = "add", Slide = newCount, Html = html, FullHtml = ppt.ViewAsHtml() }); - return; - } - } - else if (newCount < oldSlideCount) - { - WatchNotifier.NotifyIfWatching(_filePath, new WatchMessage { Action = "remove", Slide = oldSlideCount, FullHtml = ppt.ViewAsHtml() }); - return; - } - WatchNotifier.NotifyIfWatching(_filePath, new WatchMessage { Action = "full", FullHtml = ppt.ViewAsHtml() }); - } - - private void NotifyWatchFullRefresh() - { - string? fullHtml = null; - if (_handler is OfficeCli.Handlers.PowerPointHandler ppt) - fullHtml = ppt.ViewAsHtml(); - else if (_handler is OfficeCli.Handlers.ExcelHandler excel) - fullHtml = excel.ViewAsHtml(); - else if (_handler is OfficeCli.Handlers.WordHandler word) - fullHtml = word.ViewAsHtml(); - if (fullHtml != null) - WatchNotifier.NotifyIfWatching(_filePath, new WatchMessage { Action = "full", FullHtml = fullHtml }); - } - - private void ExecuteView(ResidentRequest req, OutputFormat format) - { - var mode = req.GetArg("mode", "text")!; - var start = req.GetIntArg("start"); - var end = req.GetIntArg("end"); - var maxLines = req.GetIntArg("max-lines"); - var issueType = req.GetArgOrNull("type"); - var limit = req.GetIntArg("limit"); - var cols = req.GetCols("cols"); - var pageFilter = req.GetArgOrNull("page"); - - if (mode!.ToLowerInvariant() is "html" or "h") - { - string? html = null; - if (_handler is OfficeCli.Handlers.PowerPointHandler pptHandler) - html = pptHandler.ViewAsHtml(start, end); - else if (_handler is OfficeCli.Handlers.ExcelHandler excelHandler) - html = excelHandler.ViewAsHtml(); - else if (_handler is OfficeCli.Handlers.WordHandler wordHandler) - html = wordHandler.ViewAsHtml(pageFilter); - - if (html != null) - { - if (req.Json) - { - Console.Write(html); - } - else - { - var htmlPath = Path.Combine(Path.GetTempPath(), $"officecli_preview_{Path.GetFileNameWithoutExtension(_filePath)}_{DateTime.Now:HHmmss}.html"); - File.WriteAllText(htmlPath, html); - Console.WriteLine(htmlPath); - try - { - var psi = new System.Diagnostics.ProcessStartInfo(htmlPath) { UseShellExecute = true }; - System.Diagnostics.Process.Start(psi); - } - catch { /* silently ignore if browser can't be opened */ } - } - } - else - { - Console.Error.WriteLine("HTML preview is only supported for .pptx, .xlsx, and .docx files."); - } - return; - } - - if (mode!.ToLowerInvariant() is "svg" or "g") - { - if (_handler is OfficeCli.Handlers.PowerPointHandler pptSvgHandler) - { - var slideNum = start ?? 1; - var svg = pptSvgHandler.ViewAsSvg(slideNum); - Console.Write(svg); - } - else - { - Console.Error.WriteLine("SVG preview is only supported for .pptx files."); - } - return; - } - - if (req.Json) - { - var modeKey = mode!.ToLowerInvariant(); - if (modeKey is "stats" or "s") - Console.WriteLine(_handler.ViewAsStatsJson().ToJsonString(OutputFormatter.PublicJsonOptions)); - else if (modeKey is "outline" or "o") - Console.WriteLine(_handler.ViewAsOutlineJson().ToJsonString(OutputFormatter.PublicJsonOptions)); - else if (modeKey is "text" or "t") - Console.WriteLine(_handler.ViewAsTextJson(start, end, maxLines, cols).ToJsonString(OutputFormatter.PublicJsonOptions)); - else if (modeKey is "annotated" or "a") - Console.WriteLine(OutputFormatter.FormatView(mode, _handler.ViewAsAnnotated(start, end, maxLines, cols), format)); - else if (modeKey is "issues" or "i") - Console.WriteLine(OutputFormatter.FormatIssues(_handler.ViewAsIssues(issueType, limit), format)); - else if (modeKey is "forms" or "f") - { - if (_handler is OfficeCli.Handlers.WordHandler wordFormsHandler) - Console.WriteLine(wordFormsHandler.ViewAsFormsJson().ToJsonString(OutputFormatter.PublicJsonOptions)); - else - Console.Error.WriteLine("Forms view is only supported for .docx files."); - } - else - Console.WriteLine($"Unknown mode: {mode}. Available: text, annotated, outline, stats, issues, html, forms"); - } - else - { - var output = mode!.ToLowerInvariant() switch - { - "text" or "t" => _handler.ViewAsText(start, end, maxLines, cols), - "annotated" or "a" => _handler.ViewAsAnnotated(start, end, maxLines, cols), - "outline" or "o" => _handler.ViewAsOutline(), - "stats" or "s" => _handler.ViewAsStats(), - "issues" or "i" => OutputFormatter.FormatIssues(_handler.ViewAsIssues(issueType, limit), format), - "forms" or "f" => _handler is OfficeCli.Handlers.WordHandler wfh - ? wfh.ViewAsForms() - : "Forms view is only supported for .docx files.", - _ => $"Unknown mode: {mode}. Available: text, annotated, outline, stats, issues, html, forms" - }; - Console.WriteLine(output); - } - } - - private void ExecuteGet(ResidentRequest req, OutputFormat format) - { - var path = req.GetArg("path", "/"); - var depth = req.GetIntArg("depth") ?? 1; - var node = _handler.Get(path, depth); - Console.WriteLine(OutputFormatter.FormatNode(node, format)); - } - - private void ExecuteQuery(ResidentRequest req, OutputFormat format) - { - var selector = req.GetArg("selector", ""); - var filters = AttributeFilter.Parse(selector); - var (results, warnings) = AttributeFilter.ApplyWithWarnings(_handler.Query(selector), filters); - var textFilter = req.GetArgOrNull("text"); - if (!string.IsNullOrEmpty(textFilter)) - results = results.Where(n => n.Text != null && n.Text.Contains(textFilter, StringComparison.OrdinalIgnoreCase)).ToList(); - foreach (var w in warnings) Console.Error.WriteLine(w); - Console.WriteLine(OutputFormatter.FormatNodes(results, format)); - } - - private void ExecuteSet(ResidentRequest req) - { - var path = req.GetArg("path", "/"); - var properties = req.GetProps(); - var unsupported = _handler.Set(path, properties); - var applied = properties.Where(kv => !unsupported.Contains(kv.Key)).ToList(); - if (applied.Count > 0) - Console.WriteLine($"Updated {path}: {string.Join(", ", applied.Select(kv => $"{kv.Key}={kv.Value}"))}"); - else if (unsupported.Count > 0) - Console.WriteLine($"No properties applied to {path}"); - if (unsupported.Count > 0) - Console.Error.WriteLine($"UNSUPPORTED props (use raw-set instead): {string.Join(", ", unsupported)}"); - } - - private void ExecuteAdd(ResidentRequest req) - { - var parentPath = req.GetArg("parent", "/body"); - var from = req.GetArgOrNull("from"); - var index = req.GetIntArg("index"); - - if (!string.IsNullOrEmpty(from)) - { - var resultPath = _handler.CopyFrom(from, parentPath, index); - Console.WriteLine($"Copied to {resultPath}"); - } - else - { - var type = req.GetArg("type", ""); - var properties = req.GetProps(); - var resultPath = _handler.Add(parentPath, type, index, properties); - Console.WriteLine($"Added {type} at {resultPath}"); - } - } - - private void ExecuteRemove(ResidentRequest req) - { - var path = req.GetArg("path", "/"); - _handler.Remove(path); - Console.WriteLine($"Removed {path}"); - } - - 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); - Console.WriteLine($"Moved to {resultPath}"); - } - - private void ExecuteRaw(ResidentRequest req) - { - var partPath = req.GetArg("part", "/document"); - var startRow = req.GetIntArg("start"); - var endRow = req.GetIntArg("end"); - var cols = req.GetCols("cols"); - Console.WriteLine(_handler.Raw(partPath, startRow, endRow, cols)); - } - - private void ExecuteRawSet(ResidentRequest req) - { - var partPath = req.GetArg("part", "/document"); - var xpath = req.GetArg("xpath", ""); - var action = req.GetArg("action", ""); - var xml = req.GetArgOrNull("xml"); - - var errorsBefore = _handler.Validate().Select(e => e.Description).ToHashSet(); - _handler.RawSet(partPath, xpath, action, xml); - - var errorsAfter = _handler.Validate(); - var newErrors = errorsAfter.Where(e => !errorsBefore.Contains(e.Description)).ToList(); - if (newErrors.Count > 0) - { - Console.WriteLine($"VALIDATION: {newErrors.Count} new error(s) introduced:"); - foreach (var err in newErrors) - { - Console.WriteLine($" [{err.ErrorType}] {err.Description}"); - if (err.Path != null) Console.WriteLine($" Path: {err.Path}"); - if (err.Part != null) Console.WriteLine($" Part: {err.Part}"); - } - } - } - - private void ExecuteAddPart(ResidentRequest req) - { - var parent = req.GetArg("parent", "/"); - var type = req.GetArg("type", ""); - var errorsBefore = _handler.Validate().Select(e => e.Description).ToHashSet(); - var (relId, partPath) = _handler.AddPart(parent, type); - Console.WriteLine($"Created {type} part: relId={relId} path={partPath}"); - - var errorsAfter = _handler.Validate(); - var newErrors = errorsAfter.Where(e => !errorsBefore.Contains(e.Description)).ToList(); - if (newErrors.Count > 0) - { - Console.WriteLine($"VALIDATION: {newErrors.Count} new error(s) introduced:"); - foreach (var err in newErrors) - { - Console.WriteLine($" [{err.ErrorType}] {err.Description}"); - if (err.Path != null) Console.WriteLine($" Path: {err.Path}"); - if (err.Part != null) Console.WriteLine($" Part: {err.Part}"); - } - } - } - - private void ExecuteValidate() - { - var errors = _handler.Validate(); - if (errors.Count == 0) - { - Console.WriteLine("Validation passed: no errors found."); - } - else - { - Console.WriteLine($"Found {errors.Count} validation error(s):"); - foreach (var err in errors) - { - Console.WriteLine($" [{err.ErrorType}] {err.Description}"); - if (err.Path != null) Console.WriteLine($" Path: {err.Path}"); - if (err.Part != null) Console.WriteLine($" Part: {err.Part}"); - } - } - } - - private static string MakeResponse(int exitCode, string stdout, string stderr) - { - var response = new ResidentResponse { ExitCode = exitCode, Stdout = stdout, Stderr = stderr }; - return System.Text.Json.JsonSerializer.Serialize(response, ResidentJsonContext.Default.ResidentResponse); - } - - public void Dispose() - { - if (!_disposed) - { - _disposed = true; - _cts.Cancel(); - - // Run the entire shutdown sequence on a background thread. - // A watchdog on the calling thread ensures the process always exits. - var shutdownTask = Task.Run(() => - { - // Wait for any in-flight command to finish (preserves data integrity) - _commandLock.Wait(); - _commandLock.Release(); - - try { _handler.Dispose(); } - catch (Exception ex) { Console.Error.WriteLine($"Warning: handler dispose error: {ex.Message}"); } - - _commandLock.Dispose(); - }); - - // Watchdog: if shutdown takes longer than 10min, force exit - if (!shutdownTask.Wait(TimeSpan.FromMinutes(10))) - { - Console.Error.WriteLine("Warning: shutdown timed out after 10 minutes, forcing exit."); - Environment.Exit(1); - } - - _cts.Dispose(); - _idleCts.Dispose(); - } - } -} - -public class ResidentRequest -{ - public string Command { get; set; } = ""; - public Dictionary Args { get; set; } = new(); - public Dictionary? Props { get; set; } - public bool Json { get; set; } - - public string GetArg(string key, string defaultValue = "") - { - return Args.TryGetValue(key, out var val) ? val : defaultValue; - } - - public string? GetArgOrNull(string key) - { - return Args.TryGetValue(key, out var val) ? val : null; - } - - public int? GetIntArg(string key) - { - if (Args.TryGetValue(key, out var val) && int.TryParse(val, out var n)) - return n; - return null; - } - - public HashSet? GetCols(string key) - { - var val = GetArgOrNull(key); - if (val == null) return null; - return new HashSet(val.Split(',').Select(c => c.Trim().ToUpperInvariant())); - } - - public Dictionary GetProps() - { - return Props ?? new Dictionary(); - } -} - -public class ResidentResponse -{ - public int ExitCode { get; set; } - public string Stdout { get; set; } = ""; - public string Stderr { get; set; } = ""; -} - -[System.Text.Json.Serialization.JsonSourceGenerationOptions] -[System.Text.Json.Serialization.JsonSerializable(typeof(ResidentRequest))] -[System.Text.Json.Serialization.JsonSerializable(typeof(ResidentResponse))] -internal partial class ResidentJsonContext : System.Text.Json.Serialization.JsonSerializerContext; diff --git a/src/officecli/Core/SkillInstaller.cs b/src/officecli/Core/SkillInstaller.cs index 575c02543..760bd3ae5 100644 --- a/src/officecli/Core/SkillInstaller.cs +++ b/src/officecli/Core/SkillInstaller.cs @@ -12,7 +12,7 @@ namespace OfficeCli.Core; /// - officecli skills install morph-ppt → specific skill to all detected agents /// - officecli skills install claude → base SKILL.md to specific agent (legacy) /// -public static class SkillInstaller +internal static class SkillInstaller { private static readonly (string[] Aliases, string DisplayName, string DetectDir, string SkillDir)[] Tools = [ diff --git a/src/officecli/Core/SpacingConverter.cs b/src/officecli/Core/SpacingConverter.cs index 3c47d372a..5fbedb60a 100644 --- a/src/officecli/Core/SpacingConverter.cs +++ b/src/officecli/Core/SpacingConverter.cs @@ -27,7 +27,7 @@ namespace OfficeCli.Core; /// lineSpacing multiplier → "1.5x" /// lineSpacing fixed → "18pt" /// -public static class SpacingConverter +internal static class SpacingConverter { private const double PointsPerCm = 72.0 / 2.54; // ~28.3465 private const double PointsPerInch = 72.0; diff --git a/src/officecli/Core/SvgImageHelper.cs b/src/officecli/Core/SvgImageHelper.cs new file mode 100644 index 000000000..f9f6745cc --- /dev/null +++ b/src/officecli/Core/SvgImageHelper.cs @@ -0,0 +1,219 @@ +// Copyright 2025 OfficeCli (officecli.ai) +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.RegularExpressions; +using System.Xml; +using A = DocumentFormat.OpenXml.Drawing; + +namespace OfficeCli.Core; + +/// +/// Helpers for embedding SVG images into OOXML documents. +/// +/// OOXML requires a dual representation for SVG: +/// - The main a:blip/@r:embed points to a raster fallback (PNG) so older +/// Office versions render something. +/// - An a:blip/a:extLst/a:ext[@uri="{96DAC541-7B7A-43D3-8B79-37D633B846F1}"] +/// contains an asvg:svgBlip whose r:embed points to the SVG part. +/// Modern Office (2016+) picks up the SVG; older versions fall back to the PNG. +/// +internal static class SvgImageHelper +{ + public const string SvgExtensionUri = "{96DAC541-7B7A-43D3-8B79-37D633B846F1}"; + public const string SvgNamespace = "http://schemas.microsoft.com/office/drawing/2016/SVG/main"; + public const string RelsNamespace = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + + /// + /// 1×1 transparent PNG used as the default raster fallback when the + /// caller does not supply an explicit fallback image. Modern Office + /// renders the SVG directly; this placeholder is only what older + /// viewers see. + /// + public static byte[] TransparentPng1x1 { get; } = new byte[] + { + 0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A, + 0x00,0x00,0x00,0x0D,0x49,0x48,0x44,0x52, + 0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01, + 0x08,0x06,0x00,0x00,0x00,0x1F,0x15,0xC4, + 0x89,0x00,0x00,0x00,0x0D,0x49,0x44,0x41, + 0x54,0x78,0x9C,0x63,0x00,0x01,0x00,0x00, + 0x05,0x00,0x01,0x0D,0x0A,0x2D,0xB4,0x00, + 0x00,0x00,0x00,0x49,0x45,0x4E,0x44,0xAE, + 0x42,0x60,0x82 + }; + + /// + /// Append (or replace) the Office SVG extension on a a:blip element, + /// wiring it to the SVG image part's relationship id. + /// + public static void AppendSvgExtension(A.Blip blip, string svgRelId) + { + if (blip is null) throw new ArgumentNullException(nameof(blip)); + if (string.IsNullOrEmpty(svgRelId)) throw new ArgumentException("svgRelId required", nameof(svgRelId)); + + var extList = blip.GetFirstChild(); + if (extList == null) + { + extList = new A.BlipExtensionList(); + blip.AppendChild(extList); + } + + // Drop any pre-existing SVG extension first — we only want one. + var existing = extList.Elements() + .FirstOrDefault(e => string.Equals(e.Uri?.Value, SvgExtensionUri, StringComparison.OrdinalIgnoreCase)); + existing?.Remove(); + + var ext = new A.BlipExtension { Uri = SvgExtensionUri }; + var svgBlip = new DocumentFormat.OpenXml.OpenXmlUnknownElement( + "asvg", "svgBlip", SvgNamespace); + svgBlip.SetAttribute(new DocumentFormat.OpenXml.OpenXmlAttribute( + "r", "embed", RelsNamespace, svgRelId)); + ext.AppendChild(svgBlip); + extList.AppendChild(ext); + } + + /// + /// Return the r:embed rel id from the SVG extension on this blip, or + /// null if the blip has no SVG extension. + /// + public static string? GetSvgRelId(A.Blip blip) + { + if (blip is null) return null; + var extList = blip.GetFirstChild(); + if (extList == null) return null; + foreach (var ext in extList.Elements()) + { + if (!string.Equals(ext.Uri?.Value, SvgExtensionUri, StringComparison.OrdinalIgnoreCase)) + continue; + // asvg:svgBlip is stored as a non-strongly-typed child; walk + // descendants by LocalName to find the r:embed attribute. + foreach (var child in ext.ChildElements) + { + if (child.LocalName != "svgBlip") continue; + foreach (var attr in child.GetAttributes()) + { + if (attr.LocalName == "embed" && attr.NamespaceUri == RelsNamespace) + return attr.Value; + } + } + } + return null; + } + + /// + /// Try to parse pixel dimensions from an SVG document's <svg> root. + /// Handles width/height attributes (px, pt, in, cm, mm, or bare numbers) + /// and falls back to the viewBox's width/height. The stream position is + /// restored on return. Returns null if parsing fails. + /// + public static (int Width, int Height)? TryGetSvgDimensions(Stream stream) + { + if (stream is null || !stream.CanSeek) return null; + + var startPos = stream.Position; + try + { + stream.Position = 0; + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Ignore, + XmlResolver = null, + IgnoreWhitespace = true, + IgnoreComments = true, + IgnoreProcessingInstructions = true, + CloseInput = false + }; + using var reader = XmlReader.Create(stream, settings); + while (reader.Read()) + { + if (reader.NodeType != XmlNodeType.Element) continue; + if (reader.LocalName != "svg") continue; + + var w = reader.GetAttribute("width"); + var h = reader.GetAttribute("height"); + var vb = reader.GetAttribute("viewBox"); + + double? wd = ParseSvgLength(w); + double? hd = ParseSvgLength(h); + + if ((wd is null || hd is null) && !string.IsNullOrEmpty(vb)) + { + var vbParts = vb.Split(new[] { ' ', ',', '\t', '\n', '\r' }, + StringSplitOptions.RemoveEmptyEntries); + if (vbParts.Length == 4 + && double.TryParse(vbParts[2], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var vbW) + && double.TryParse(vbParts[3], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var vbH)) + { + wd ??= vbW; + hd ??= vbH; + } + } + + if (wd is > 0 && hd is > 0) + return ((int)Math.Round(wd.Value), (int)Math.Round(hd.Value)); + return null; + } + return null; + } + catch + { + return null; + } + finally + { + try { stream.Position = startPos; } catch (IOException) { } + } + } + + private static readonly Regex _svgLengthRegex = + new(@"^\s*([+-]?\d+(?:\.\d+)?)\s*(px|pt|in|cm|mm|pc|em|ex|%)?\s*$", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static double? ParseSvgLength(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var m = _svgLengthRegex.Match(value); + if (!m.Success) return null; + if (!double.TryParse(m.Groups[1].Value, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out var n)) + return null; + var unit = m.Groups[2].Success ? m.Groups[2].Value.ToLowerInvariant() : "px"; + // Convert to pixels at 96dpi so aspect-ratio calculations in + // ImageSource.TryGetDimensions land on the same scale as PNG/JPEG. + return unit switch + { + "px" or "" => n, + "pt" => n * 96.0 / 72.0, + "in" => n * 96.0, + "cm" => n * 96.0 / 2.54, + "mm" => n * 96.0 / 25.4, + "pc" => n * 16.0, + "em" or "ex" => n * 16.0, + "%" => null, // needs viewport context — fall back to viewBox + _ => n + }; + } + + /// + /// Sniff whether the byte stream looks like SVG XML. Used to recover + /// when a caller resolved the source but didn't tell us the content + /// type up front. + /// + public static bool LooksLikeSvg(byte[] bytes) + { + if (bytes is null || bytes.Length < 5) return false; + // Skip leading whitespace + BOM. + int i = 0; + if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) i = 3; + while (i < bytes.Length && (bytes[i] == ' ' || bytes[i] == '\t' + || bytes[i] == '\r' || bytes[i] == '\n')) i++; + // Look for -public static class TemplateMerger +internal static class TemplateMerger { private static readonly Regex PlaceholderPattern = new(@"\{\{(\w[\w.]*)\}\}", RegexOptions.Compiled); diff --git a/src/officecli/Core/ThemeColorResolver.cs b/src/officecli/Core/ThemeColorResolver.cs index cc546b3f8..67d0fa9fd 100644 --- a/src/officecli/Core/ThemeColorResolver.cs +++ b/src/officecli/Core/ThemeColorResolver.cs @@ -10,7 +10,7 @@ namespace OfficeCli.Core; /// Shared theme color resolution. Builds a scheme-color-name → hex dictionary /// from an OOXML ColorScheme. Used by both PowerPoint and Word handlers. /// -public static class ThemeColorResolver +internal static class ThemeColorResolver { /// /// Build a map of scheme color names to hex values from a ColorScheme. @@ -20,6 +20,16 @@ public static class ThemeColorResolver /// If true, adds PPT-specific aliases: text1, text2, background1, background2. /// Word uses a smaller alias set. /// + // Strict hex check (3/6/8 chars) to guard the theme → CSS pipeline. + private static bool IsHex(string? s) + { + if (string.IsNullOrEmpty(s)) return false; + if (s.Length is not (3 or 6 or 8)) return false; + foreach (var c in s) + if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) return false; + return true; + } + public static Dictionary BuildColorMap( Drawing.ColorScheme? colorScheme, bool includePptAliases = false) { @@ -33,7 +43,10 @@ void Add(string name, OpenXmlCompositeElement? color) var sys = color.GetFirstChild(); var srgb = sys?.LastColor?.Value ?? sys?.Val?.InnerText; var hex = rgb ?? srgb; - if (hex != null) map[name] = hex; + // Hex-gate the theme color at the source — downstream CSS + // sinks interpolate these as `#{hex}` into inline style, so + // an adversarial theme1.xml otherwise becomes an XSS vector. + if (hex != null && IsHex(hex)) map[name] = hex; } Add("dk1", colorScheme.Dark1Color); diff --git a/src/officecli/Core/ThemeHandler.cs b/src/officecli/Core/ThemeHandler.cs index 34b5820e2..bcc8d7728 100644 --- a/src/officecli/Core/ThemeHandler.cs +++ b/src/officecli/Core/ThemeHandler.cs @@ -10,7 +10,7 @@ namespace OfficeCli.Core; /// Shared Theme Get/Set logic for all document types. /// Operates on ThemePart which has identical structure across Word/Excel/PowerPoint. /// -public static class ThemeHandler +internal static class ThemeHandler { // ColorScheme slot names → accessor pairs private static readonly (string Key, Func Get, Action Set)[] ColorSlots = diff --git a/src/officecli/Core/UpdateChecker.cs b/src/officecli/Core/UpdateChecker.cs index 8bfe27807..0ab80c6b5 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() @@ -248,12 +274,39 @@ private static void SpawnRefreshProcess() FileName = exePath, Arguments = "__update-check__", UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, + // Redirect child stdio away from the parent's console. Without + // these flags the child inherits the parent's stdout/stderr, + // which is a problem in two concrete scenarios: + // (a) the parent is an MCP server — its stdout carries the + // JSON-RPC protocol stream, and any byte the update- + // check writes there would corrupt the protocol and + // disconnect the MCP client; + // (b) the parent is an interactive shell command that exits + // before the child finishes — the child's "downloaded + // v1.2.3" or error messages would then surface on the + // user's terminal at a seemingly random later moment. + // We redirect to pipes and never Read them; the pipes are + // closed when the child exits. This cannot break the upgrade + // itself: RunRefresh() only writes to stdout/stderr for + // debugging/never (it's silent-on-success, silent-on-failure + // by design), and the download / verify / File.Move chain + // doesn't touch the console stream at all. + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true }; var process = Process.Start(startInfo); - // Don't wait — let it run independently - process?.Dispose(); + if (process == null) return; + // Close our end of stdin immediately so the child sees EOF if it + // ever tries to read (defensive — RunRefresh doesn't read stdin). + try { process.StandardInput.Close(); } catch { } + // Don't wait, don't Read the redirected streams. When the child + // exits the OS closes its side of the pipes; the .NET runtime's + // SIGCHLD reaper waits on it so it never becomes a zombie even + // though we never call WaitForExit. + process.Dispose(); } catch { } } @@ -350,11 +403,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 +421,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/Watch/WatchMark.cs b/src/officecli/Core/Watch/WatchMark.cs new file mode 100644 index 000000000..c272888b3 --- /dev/null +++ b/src/officecli/Core/Watch/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". +/// +internal 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. +internal 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. +internal 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. +/// +internal 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. +internal 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. +/// +internal 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/Watch/WatchNotifier.cs b/src/officecli/Core/Watch/WatchNotifier.cs new file mode 100644 index 000000000..e9528e55c --- /dev/null +++ b/src/officecli/Core/Watch/WatchNotifier.cs @@ -0,0 +1,378 @@ +// 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; +using System.Text.Json; + +namespace OfficeCli.Core; + +/// +/// Sends refresh notifications (with rendered HTML) to a running watch process. +/// Non-blocking, fire-and-forget. Silently does nothing if no watch is running. +/// All pipe I/O is bounded by a timeout to prevent hangs. +/// +internal static class WatchNotifier +{ + private static readonly TimeSpan PipeTimeout = TimeSpan.FromSeconds(5); + + /// + /// Notify watch with a pre-built message. + /// The watch server never opens the file — all rendering is done by the caller. + /// + public static void NotifyIfWatching(string filePath, WatchMessage message) + { + try + { + RunWithTimeout(() => + { + var pipeName = WatchServer.GetWatchPipeName(filePath); + using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut); + client.Connect(100); // fast fail if no watch + + var json = JsonSerializer.Serialize(message, WatchMessageJsonContext.Default.WatchMessage); + + // Write first, then read. Creating StreamReader before writing + // causes a deadlock: StreamReader's constructor probes for BOM by + // reading from the pipe, but the server is waiting for our write. + using var writer = new StreamWriter(client, new UTF8Encoding(false), leaveOpen: true) { AutoFlush = true }; + writer.WriteLine(json); + + using var reader = new StreamReader(client, new UTF8Encoding(false), detectEncodingFromByteOrderMarks: false, leaveOpen: true); + reader.ReadLine(); // wait for ack + }, PipeTimeout); + } + catch + { + // No watch process running, or timed out — silently ignore + } + } + + /// + /// 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. + /// + public static bool SendClose(string filePath) + { + try + { + bool result = false; + RunWithTimeout(() => + { + var pipeName = WatchServer.GetWatchPipeName(filePath); + using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut); + client.Connect(200); + + // Write first, then read — same ordering as NotifyIfWatching + // to avoid BOM-detection deadlock on the pipe. + using var writer = new StreamWriter(client, new UTF8Encoding(false), leaveOpen: true) { AutoFlush = true }; + writer.WriteLine("close"); + writer.Flush(); + + using var reader = new StreamReader(client, new UTF8Encoding(false), detectEncodingFromByteOrderMarks: false, leaveOpen: true); + reader.ReadLine(); + result = true; + }, PipeTimeout); + return result; + } + catch + { + return false; + } + } + + /// + /// Run an action on a background thread with a timeout. + /// Prevents the calling thread from hanging if the pipe server dies mid-conversation. + /// + private static void RunWithTimeout(Action action, TimeSpan timeout) + { + var task = Task.Run(action); + if (!task.Wait(timeout)) + throw new TimeoutException("Pipe communication timed out"); + task.GetAwaiter().GetResult(); // propagate exceptions + } +} + +/// +/// Message sent from command processes to the watch server via named pipe. +/// +internal class WatchMessage +{ + /// "replace", "add", "remove", or "full" + public string Action { get; set; } = "full"; + + /// Slide number (0 for full refresh) + public int Slide { get; set; } + + /// Single slide HTML fragment (for replace/add) + public string? Html { get; set; } + + /// Full HTML of the entire presentation (for caching by watch server) + public string? FullHtml { get; set; } + + /// CSS selector for the element to scroll to after full refresh (Word/Excel) + public string? ScrollTo { get; set; } + + /// Incremental version number for ordering and gap detection. + public int Version { get; set; } + + /// Version the client must have before applying these patches. + public int BaseVersion { get; set; } + + /// Word block-level patches (for action="word-patch"). + public List? Patches { get; set; } + + public static int ExtractSlideNum(string? path) + { + if (string.IsNullOrEmpty(path)) return 0; + var match = System.Text.RegularExpressions.Regex.Match(path, @"/slide\[(\d+)\]"); + if (match.Success && int.TryParse(match.Groups[1].Value, out var num)) + return num; + return 0; + } + + /// Extract a CSS selector scroll target from a Word document path like /p[5] or /table[2]. + public static string? ExtractWordScrollTarget(string? path) + { + if (string.IsNullOrEmpty(path)) return null; + var match = System.Text.RegularExpressions.Regex.Match(path, @"/(p|paragraph|table)\[(\d+)\]"); + if (!match.Success) return null; + var type = match.Groups[1].Value; + if (type == "paragraph") type = "p"; + return $"#w-{type}-{match.Groups[2].Value}"; + } + + /// Extract sheet name from an Excel document path like /Sheet1/A1 or Sheet1!A1. + public static string? ExtractSheetName(string? path) + { + if (string.IsNullOrEmpty(path)) return null; + // Match /SheetName/... or SheetName!... + var match = System.Text.RegularExpressions.Regex.Match(path, @"^/?([^/!]+)[/!]"); + return match.Success ? match.Groups[1].Value : null; + } +} + +/// A single block-level change for Word incremental updates. +internal class WordPatch +{ + /// "replace", "add", or "remove" + public string Op { get; set; } = ""; + + /// Block number (matches marker) + public int Block { get; set; } + + /// New HTML content (null for remove) + public string? Html { get; set; } +} + +[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. +/// +internal 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/Watch/WatchServer.cs b/src/officecli/Core/Watch/WatchServer.cs new file mode 100644 index 000000000..7d4429e85 --- /dev/null +++ b/src/officecli/Core/Watch/WatchServer.cs @@ -0,0 +1,1987 @@ +// 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; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; + +namespace OfficeCli.Core; + +/// +/// Pure SSE relay server. Never opens the document file. +/// Receives pre-rendered HTML from command processes via named pipe, +/// forwards to browsers via SSE. +/// +internal class WatchServer : IDisposable +{ + private readonly string _filePath; + private readonly string _pipeName; + private readonly int _port; + private readonly TcpListener _tcpListener; + private readonly List _sseClients = new(); + private readonly object _sseLock = new(); + private CancellationTokenSource _cts = new(); + private string _currentHtml = ""; + private int _version = 0; + private bool _disposed; + private DateTime _lastActivityTime = DateTime.UtcNow; + private readonly TimeSpan _idleTimeout; + + // Shared shutdown Task so every teardown entrypoint — idle watchdog, + // unwatch command, SIGTERM/SIGINT, Dispose — converges on a single + // ordered sequence. Before this, idle/unwatch just called + // _cts.Cancel() and hoped the async chain would unwind; but + // TcpListener.AcceptTcpClientAsync on macOS under .NET 10 does NOT + // reliably honour the cancellation token, so the main loop would + // hang indefinitely in `await AcceptTcpClientAsync(token)` and the + // process would ignore SIGINT for 15+ seconds (observed in + // stress test) until something else kicked the TCP listener. + private readonly object _shutdownLock = new(); + private Task? _shutdownTask; + + // 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... + +

    Waiting for first update...

    Run an officecli command to see the preview.

    + """; + + // SSE script content loaded from embedded resources (watch-sse-core.js + watch-overlay.js). + // Layer 1 (sse-core) handles SSE connection, DOM updates, word diff/patch, slide ops. + // Layer 2 (overlay) handles selection, marks, rubber-band, CSS injection. + // Coupling: Layer 1 calls window._watchReapplyHook() after DOM mutations; + // Layer 2 sets that hook to reapplyDecorations(). + private static readonly Lazy _sseScriptBlock = new(() => + { + var core = LoadWatchResource("Resources.watch-sse-core.js"); + var overlay = LoadWatchResource("Resources.watch-overlay.js"); + return $"\n"; + }); + + // Test access: allows tests to verify SSE script content without reflection on a const field. + internal static string SseScriptContent => _sseScriptBlock.Value; + + private static string LoadWatchResource(string name) + { + var assembly = typeof(WatchServer).Assembly; + var fullName = $"OfficeCli.{name}"; + using var stream = assembly.GetManifestResourceStream(fullName); + if (stream == null) return $"/* Resource not found: {fullName} */"; + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + // Idle timeout is configurable via OFFICECLI_WATCH_IDLE_SECONDS so + // tests can exercise the auto-shutdown path in seconds instead of + // minutes. Callers that pass an explicit TimeSpan (tests that need + // fixed values) bypass the env var. Valid range: 1s .. 24h. + private static TimeSpan ResolveIdleTimeout() + { + var raw = Environment.GetEnvironmentVariable("OFFICECLI_WATCH_IDLE_SECONDS"); + if (!string.IsNullOrWhiteSpace(raw) + && int.TryParse(raw, out var secs) + && secs >= 1 && secs <= 86400) + { + return TimeSpan.FromSeconds(secs); + } + return TimeSpan.FromMinutes(5); + } + + public WatchServer(string filePath, int port, TimeSpan? idleTimeout = null, string? initialHtml = null) + { + _filePath = Path.GetFullPath(filePath); + _pipeName = GetWatchPipeName(_filePath); + _port = port; + _idleTimeout = idleTimeout ?? ResolveIdleTimeout(); + _tcpListener = new TcpListener(IPAddress.Loopback, _port); + if (!string.IsNullOrEmpty(initialHtml)) + _currentHtml = initialHtml; + } + + public static string GetWatchPipeName(string filePath) + { + var fullPath = Path.GetFullPath(filePath); + if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()) + fullPath = fullPath.ToUpperInvariant(); + var hash = Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(fullPath)))[..16]; + return $"officecli-watch-{hash}"; + } + + /// + /// Path of the on-disk marker that records {pid, port} for a running + /// watch. Used by and + /// to answer "is anyone watching this file?" + /// without a pipe round-trip. Same hash key as the pipe name — one + /// file ↔ one pipe ↔ one marker. + /// + public static string GetWatchMarkerPath(string filePath) + { + return Path.Combine(Path.GetTempPath(), GetWatchPipeName(filePath) + ".port"); + } + + /// + /// Check if another watch process is already running for this file. + /// Returns the port number if running, or null if not. + /// + /// Implementation: reads the on-disk marker file ({pid}\n{port}\n) and + /// validates the pid is still alive. Replaces the pre-1.0.51 pipe ping + /// probe, which cost ~100ms and falsely reported "not watching" when + /// the pipe server was momentarily busy with another connection. + /// + public static int? GetExistingWatchPort(string filePath) + { + var markerPath = GetWatchMarkerPath(filePath); + try + { + if (!File.Exists(markerPath)) return null; + var lines = File.ReadAllLines(markerPath); + if (lines.Length < 2) return null; + if (!int.TryParse(lines[0], out var pid)) return null; + if (!int.TryParse(lines[1], out var port)) return null; + if (!IsProcessAlive(pid)) + { + // Stale marker — writer crashed or was killed without cleanup. + // Best-effort remove so the caller can start a fresh watch. + try { File.Delete(markerPath); } catch { } + return null; + } + return port; + } + catch + { + return null; + } + } + + public static bool IsWatching(string filePath) + { + return GetExistingWatchPort(filePath).HasValue; + } + + private static bool IsProcessAlive(int pid) + { + try + { + using var p = System.Diagnostics.Process.GetProcessById(pid); + return !p.HasExited; + } + catch (ArgumentException) { return false; } + catch (InvalidOperationException) { return false; } + } + + private void WriteMarker() + { + var markerPath = GetWatchMarkerPath(_filePath); + try + { + File.WriteAllText(markerPath, + $"{System.Diagnostics.Process.GetCurrentProcess().Id}\n{_port}\n"); + } + catch { /* best-effort; IsWatching just reports false if marker absent */ } + } + + private void DeleteMarker() + { + try + { + var markerPath = GetWatchMarkerPath(_filePath); + if (File.Exists(markerPath)) File.Delete(markerPath); + } + catch { /* best-effort cleanup */ } + } + + public async Task RunAsync(CancellationToken externalToken = default) + { + // Prevent duplicate watch processes for the same file + var existingPort = GetExistingWatchPort(_filePath); + if (existingPort.HasValue) + { + var url = existingPort.Value > 0 ? $" at http://localhost:{existingPort.Value}" : ""; + throw new InvalidOperationException($"Another watch process is already running{url} for {_filePath}"); + } + + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, externalToken); + var token = linkedCts.Token; + + _tcpListener.Start(); + WriteMarker(); + Console.WriteLine($"Watch: http://localhost:{_port}"); + Console.WriteLine($"Watching: {_filePath}"); + Console.WriteLine("Press Ctrl+C to stop."); + + // Hook graceful shutdown signals. Cooperatively terminating a + // watch process needs to (a) stop the TCP listener — the only + // reliable way to kick AcceptTcpClientAsync on macOS, which + // does NOT honour cancellation tokens on .NET 10 — and (b) + // delete the $TMPDIR/CoreFxPipe_ socket file (.NET doesn't, + // BUG-BT-003). Both steps happen inside StopAsync. + // + // Two signal paths cover the realistic user scenarios: + // + // 1. PosixSignalRegistration for SIGTERM / SIGHUP / SIGQUIT. + // These are the usual "kill this daemon" signals; they fire + // whether or not the process has a controlling TTY. Works + // reliably for `pkill officecli`, launcher kill, and + // terminal-close-while-backgrounded. + // + // 2. Console.CancelKeyPress for Ctrl+C (SIGINT). This fires + // when watch is running in the foreground of an interactive + // terminal — the realistic user scenario for "I pressed + // Ctrl+C to stop the watch I just started". + // + // Known limitation: sending SIGINT or SIGQUIT to a BACKGROUNDED + // watch process (e.g. `officecli watch file & ; kill -INT %1`) + // does not trigger either path because .NET's runtime gates + // SIGINT/SIGQUIT handling on having a controlling TTY. This is + // not a realistic daemon-termination pattern — callers who + // need to stop a backgrounded watch should use `officecli + // unwatch file` or SIGTERM, both of which work. + var signalRegs = new List(); + void DoShutdownFromSignal() + { + try { StopAsync().Wait(TimeSpan.FromSeconds(10)); } catch { } + Environment.Exit(0); + } + void HandleSignal(PosixSignalContext ctx) + { + ctx.Cancel = true; + DoShutdownFromSignal(); + } + void TryRegister(PosixSignal sig) + { + try { signalRegs.Add(PosixSignalRegistration.Create(sig, HandleSignal)); } + catch (PlatformNotSupportedException) { /* host doesn't support this signal */ } + } + TryRegister(PosixSignal.SIGTERM); + TryRegister(PosixSignal.SIGHUP); + TryRegister(PosixSignal.SIGQUIT); + + ConsoleCancelEventHandler cancelHandler = (_, e) => + { + e.Cancel = true; + DoShutdownFromSignal(); + }; + Console.CancelKeyPress += cancelHandler; + + var pipeTask = RunPipeListenerAsync(token); + var idleTask = RunIdleWatchdogAsync(token); + + while (!token.IsCancellationRequested) + { + try + { + var client = await _tcpListener.AcceptTcpClientAsync(token); + _ = HandleClientAsync(client, token); + } + catch (OperationCanceledException) { break; } + catch (SocketException) { break; } + catch (ObjectDisposedException) { break; } + catch (Exception ex) + { + Console.Error.WriteLine($"Watch HTTP error: {ex.Message}"); + } + } + + // Main loop exited — drive the shared shutdown path. This cleans + // up TCP listener, pipe listener, CoreFxPipe_ socket, and SSE + // clients in order. Idempotent, so signal-driven and + // cancellation-driven paths both converge here safely. + try { await StopAsync(); } catch { } + + try { await pipeTask; } catch (OperationCanceledException) { } + try { await idleTask; } catch (OperationCanceledException) { } + + foreach (var reg in signalRegs) + try { reg.Dispose(); } catch { } + Console.CancelKeyPress -= cancelHandler; + } + + /// + /// Idempotent, ordered shutdown. Every teardown path (idle watchdog, + /// unwatch pipe command, SIGTERM/SIGINT/SIGHUP, Dispose) funnels + /// through this method and awaits the same cached Task. + /// + /// Order: + /// 1. Cancel _cts — idle watchdog and pipe listener exit their loops. + /// 2. Call TcpListener.Stop() — only reliable way to unstick + /// AcceptTcpClientAsync on macOS under .NET 10. + /// 3. Close all live SSE client streams so RunSseClientAsync + /// coroutines drop their references. + /// 4. Kick the pipe listener via a local NamedPipeClientStream + /// connect so RunPipeListenerAsync unsticks on Windows (where + /// WaitForConnectionAsync doesn't honour cancellation). + /// 5. On Unix, delete the stale $TMPDIR/CoreFxPipe_ socket file + /// (.NET doesn't clean it up — BUG-BT-003). + /// + public Task StopAsync() + { + lock (_shutdownLock) + { + return _shutdownTask ??= Task.Run(DoStopAsync); + } + } + + private async Task DoStopAsync() + { + // 1. Signal everything to stop. + try { _cts.Cancel(); } catch (ObjectDisposedException) { } + + // 2. Stop the TCP listener. AcceptTcpClientAsync(token) on macOS + // under .NET 10 does not reliably respect cancellation; Stop() + // force-closes the underlying socket which makes the pending + // accept throw ObjectDisposedException and unwind the loop. + try { _tcpListener.Stop(); } catch { } + + // 3. Close live SSE streams so the per-client coroutines unwind + // promptly. (They would eventually notice token cancellation, + // but a blocking write to a dead client can hang for seconds.) + lock (_sseLock) + { + foreach (var s in _sseClients) + { + try { s.Close(); } catch { } + } + _sseClients.Clear(); + } + + // 4. Kick the pipe listener out of WaitForConnectionAsync. + try + { + using var kick = new System.IO.Pipes.NamedPipeClientStream( + ".", _pipeName, System.IO.Pipes.PipeDirection.InOut); + kick.Connect(500); + } + catch { } + + // 4b. Delete the on-disk watch marker so external IsWatching() probes + // immediately see "no watch running". + DeleteMarker(); + + // 5. Delete the stale CoreFxPipe_ socket on Unix. .NET does not + // do this on its own (BUG-BT-003 — fuzzer found 302 stale + // files). Run here in StopAsync rather than Dispose so it + // also works when the process exits via SIGTERM signal path. + if (!OperatingSystem.IsWindows()) + { + try + { + var sockPath = Path.Combine(Path.GetTempPath(), "CoreFxPipe_" + _pipeName); + if (File.Exists(sockPath)) File.Delete(sockPath); + } + catch { /* best-effort cleanup */ } + } + + // Small yield so any synchronous continuations scheduled on the + // now-cancelled token get a chance to run before the caller + // proceeds. Not strictly required for correctness. + await Task.Yield(); + } + + private async Task RunIdleWatchdogAsync(CancellationToken token) + { + var checkInterval = TimeSpan.FromSeconds(Math.Min(30, Math.Max(1, _idleTimeout.TotalSeconds / 2))); + while (!token.IsCancellationRequested) + { + await Task.Delay(checkInterval, token); + int clientCount; + lock (_sseLock) { clientCount = _sseClients.Count; } + if (clientCount == 0 && DateTime.UtcNow - _lastActivityTime > _idleTimeout) + { + Console.WriteLine("Watch: idle timeout, shutting down."); + // Go through the shared ordered shutdown path instead of + // raw-cancelling _cts, so TcpListener.Stop() gets called + // and the main loop doesn't hang waiting for an accept + // that never completes. + _ = StopAsync(); + break; + } + } + } + + private async Task RunPipeListenerAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + var server = new System.IO.Pipes.NamedPipeServerStream( + _pipeName, System.IO.Pipes.PipeDirection.InOut, + System.IO.Pipes.NamedPipeServerStream.MaxAllowedServerInstances, + System.IO.Pipes.PipeTransmissionMode.Byte, + System.IO.Pipes.PipeOptions.Asynchronous); + try + { + await server.WaitForConnectionAsync(token); + } + catch (OperationCanceledException) { await server.DisposeAsync(); break; } + catch { await server.DisposeAsync(); continue; } + + // Handle the client on a background task and immediately loop back + // to accept another connection. This avoids a tiny window where the + // pipe is not listening between iterations and back-to-back CLI + // calls (e.g. multiple mark adds in a tight test loop) get refused. + _ = Task.Run(async () => + { + using (server) + { + try { await HandleSinglePipeClientAsync(server, token); } + catch { /* ignore individual client errors */ } + } + }, token); + } + } + + private async Task HandleSinglePipeClientAsync(System.IO.Pipes.NamedPipeServerStream server, CancellationToken token) + { + try + { + var noBom = new UTF8Encoding(false); + using var reader = new StreamReader(server, noBom, detectEncodingFromByteOrderMarks: false, leaveOpen: true); + using var writer = new StreamWriter(server, noBom, leaveOpen: true) { AutoFlush = true }; + + var message = await reader.ReadLineAsync(token); + _lastActivityTime = DateTime.UtcNow; + + if (message == "close") + { + await writer.WriteLineAsync("ok".AsMemory(), token); + Console.WriteLine("Watch closed by remote command."); + // Go through shared shutdown — idempotent, ordered, + // also cleans up CoreFxPipe_ socket on Unix. + _ = StopAsync(); + return; + } + else if (message == "get-selection") + { + // Return current selection as a JSON array of paths. + // Empty selection → "[]". Never null. + string[] snapshot; + lock (_selectionLock) { snapshot = _currentSelection.ToArray(); } + var json = JsonSerializer.Serialize(snapshot, WatchSelectionJsonOptions.StringArrayInfo); + await writer.WriteLineAsync(json.AsMemory(), token); + } + else if (message == "get-marks") + { + // Return {"version":N,"marks":[...]} so callers can do CAS-style + // detection. Empty marks → []. Never null. + // Uses Relaxed options so CJK content emits literal chars. + WatchMark[] snapshot; + int version; + lock (_marksLock) + { + snapshot = _currentMarks.ToArray(); + version = _marksVersion; + } + var resp = new MarksResponse { Version = version, Marks = snapshot }; + var payload = JsonSerializer.Serialize(resp, WatchMarkJsonOptions.MarksResponseInfo); + await writer.WriteLineAsync(payload.AsMemory(), token); + } + else if (message != null && message.StartsWith("mark ", StringComparison.Ordinal)) + { + // "mark " — add a mark, return assigned id + var payload = message.Substring(5); + var resp = HandleMarkAdd(payload); + await writer.WriteLineAsync(resp.AsMemory(), token); + } + else if (message != null && message.StartsWith("unmark ", StringComparison.Ordinal)) + { + // "unmark " — remove marks by path or all + var payload = message.Substring(7); + var resp = HandleMarkRemove(payload); + await writer.WriteLineAsync(resp.AsMemory(), token); + } + else if (message != null) + { + await writer.WriteLineAsync("ok".AsMemory(), token); + // Try to parse as WatchMessage JSON + HandleWatchMessage(message); + } + } + catch (OperationCanceledException) { return; } + catch { /* ignore pipe errors */ } + } + + private void HandleWatchMessage(string json) + { + try + { + var msg = JsonSerializer.Deserialize(json, WatchMessageJsonContext.Default.WatchMessage); + if (msg == null) return; + + var oldHtml = _currentHtml; + var baseVersion = _version; + + // Always update cached full HTML when provided (authoritative snapshot) + if (!string.IsNullOrEmpty(msg.FullHtml)) + { + _currentHtml = msg.FullHtml; + } + + // Apply incremental patch when no full HTML was provided + if (string.IsNullOrEmpty(msg.FullHtml)) + { + if (msg.Action == "replace" && msg.Slide > 0 && msg.Html != null) + _currentHtml = PatchSlideInHtml(_currentHtml, msg.Slide, msg.Html); + else if (msg.Action == "add" && msg.Html != null) + _currentHtml = AppendSlideToHtml(_currentHtml, msg.Html); + else if (msg.Action == "remove" && msg.Slide > 0) + _currentHtml = RemoveSlideFromHtml(_currentHtml, msg.Slide); + } + + _version++; + + // Reconcile all marks against the freshly updated snapshot. Flips + // stale flags and refreshes matched_text when the underlying text + // changed. CONSISTENCY(path-stability): same naive resolve used on + // initial add, no fingerprint. + ReconcileAllMarks(); + + // Word: try block-level diff instead of full refresh + if (msg.Action == "full" && !string.IsNullOrEmpty(msg.FullHtml) + && !string.IsNullOrEmpty(oldHtml) && oldHtml.Contains("data-block=\"1\"")) + { + var patches = ComputeWordPatches(oldHtml, msg.FullHtml); + // Check if CSS styles changed + var oldStyle = ExtractStyleBlock(oldHtml); + var newStyle = ExtractStyleBlock(msg.FullHtml); + var styleChanged = oldStyle != newStyle; + + if (patches != null || styleChanged) + { + patches ??= new List(); + if (styleChanged) + patches.Insert(0, new WordPatch { Op = "style", Block = 0, Html = newStyle }); + SendSseWordPatch(patches, _version, baseVersion, msg.ScrollTo); + return; + } + } + + // Excel: try row-level diff instead of full refresh. + // Skip when table chrome (colgroup/thead/table width) changed — + // row patches can't express those changes, so fall through to + // full-action so the browser rebuilds the whole body. + if (msg.Action == "full" && !string.IsNullOrEmpty(msg.FullHtml) + && !string.IsNullOrEmpty(oldHtml) && oldHtml.Contains("data-row=\"") + && TableChromeSignature(oldHtml) == TableChromeSignature(msg.FullHtml)) + { + var excelPatches = ComputeExcelPatches(oldHtml, msg.FullHtml); + var oldStyle = ExtractStyleBlock(oldHtml); + var newStyle = ExtractStyleBlock(msg.FullHtml); + var styleChanged = oldStyle != newStyle; + + if (excelPatches != null || styleChanged) + { + excelPatches ??= new List<(string Op, string Row, string? Html)>(); + if (styleChanged) + excelPatches.Insert(0, ("style", "", newStyle)); + SendSseExcelPatch(excelPatches, _version, baseVersion, msg.ScrollTo); + return; + } + } + + // Forward to SSE clients (full or PPT incremental) + SendSseEvent(msg.Action, msg.Slide, msg.Html, msg.ScrollTo, _version); + } + catch + { + // Legacy format or parse error — treat as full refresh signal + _version++; + SendSseEvent("full", 0, null, null, _version); + } + } + + // ==================== Marks ==================== + + /// + /// Add a new mark. Normalizes find: if regex flag (truthy via the find + /// payload's "regex" field would be parsed by the CLI side; the server + /// receives the canonical form already wrapped as r"..." or literal). + /// However we ALSO accept the bare-find form here so that callers that + /// don't pre-wrap still get correct behaviour. The CLI passes either + /// the literal or a pre-wrapped r"..." string. + /// + internal string HandleMarkAdd(string json) + { + try + { + var req = JsonSerializer.Deserialize(json, WatchMarkJsonContext.Default.MarkRequest); + if (req == null) + return "{\"error\":\"invalid request\"}"; + + // BUG-FUZZER-003/004: path hardening. + // 1. Normalize: Trim() strips ASCII + Unicode whitespace from edges. + // 2. Reject whitespace-only paths (IsNullOrWhiteSpace catches NBSP, + // U+3000 ideographic space, etc.). + // 3. Require leading '/': zero-width space U+200B and BOM U+FEFF + // are not .NET whitespace but are never valid data-path prefixes, + // so a StartsWith('/') check also filters them out. + // 4. Store the trimmed form so later `unmark --path /body/p[1]` + // matches what the user typed, not `" /body/p[1] "` with padding. + // BUG-BT-R303: error messages must be actionable for AI agents — say + // what the accepted format is, not just "invalid". + var trimmedPath = req.Path?.Trim() ?? ""; + if (string.IsNullOrWhiteSpace(trimmedPath) || !trimmedPath.StartsWith("/")) + return "{\"error\":\"invalid path: must start with '/' (e.g. /body/p[1] for Word, /slide[1]/shape[@id=N] for PowerPoint)\"}"; + + // BUG-TESTER-002: validate color server-side. The browser sets + // el.style.backgroundColor = mark.color verbatim, so an unsanitized + // value injects CSS into every connected SSE client. Server is the + // single trust boundary for both human-typed CLI and machine agents. + // CONSISTENCY(mark-color-validation): one validator, both Add and + // any future Set/update path must call IsValidMarkColor. + // + // BUG-FUZZER-001: Trim() before validation AND before storage, so + // `"red\n"` doesn't end up stored as `"red\n"` after being accepted + // (the validator trims for matching but used to leave the raw form + // in the stored mark, causing a validator-vs-storage inconsistency). + var trimmedColor = req.Color?.Trim(); + // BUG-A-R2-M01: accept bare hex (FF00FF, F0F) for consistency with the + // rest of officecli's color parsers. The validator below requires the + // canonical #-prefixed form, so promote 3/6/8-digit bare hex to that + // form before validation. Anything else (named colors, rgb(...), + // already-hashed hex) passes through unchanged. + trimmedColor = NormalizeMarkColorInput(trimmedColor); + // BUG-BT-R303: actionable error message — list the accepted formats + // so AI agents can self-correct without reading the source. + if (!string.IsNullOrEmpty(trimmedColor) && !IsValidMarkColor(trimmedColor)) + return "{\"error\":\"invalid color: accepted forms are #RGB / #RRGGBB / #RRGGBBAA hex (with or without # prefix), rgb(r,g,b), rgba(r,g,b,a), or named colors (red, blue, yellow, orange, green, purple, ...)\"}"; + + var mark = new WatchMark + { + Path = trimmedPath, + Find = req.Find, + Color = string.IsNullOrEmpty(trimmedColor) ? "#ffeb3b" : trimmedColor, + Note = req.Note, + Tofix = req.Tofix, + MatchedText = Array.Empty(), + Stale = false, + CreatedAt = DateTime.UtcNow, + }; + + string assignedId; + WatchMark[] snapshot; + string htmlSnapshot; + lock (_marksLock) + { + assignedId = _nextMarkId.ToString(); + _nextMarkId++; + mark.Id = assignedId; + // Snapshot _currentHtml under the lock so a concurrent + // full-refresh can't race the resolve step. + htmlSnapshot = _currentHtml; + var resolved = ResolveMark(mark, htmlSnapshot); + _currentMarks.Add(resolved); + _marksVersion++; + snapshot = _currentMarks.ToArray(); + } + _lastActivityTime = DateTime.UtcNow; + BroadcastMarkUpdate(snapshot); + + return JsonSerializer.Serialize( + new MarkResponse { Id = assignedId }, + WatchMarkJsonContext.Default.MarkResponse); + } + catch + { + return "{\"error\":\"parse failed\"}"; + } + } + + /// + /// Remove marks. UnmarkRequest must have either Path set, or All=true, + /// not both. Returns the number of marks removed. + /// + internal string HandleMarkRemove(string json) + { + try + { + var req = JsonSerializer.Deserialize(json, WatchMarkJsonContext.Default.UnmarkRequest); + if (req == null) return "{\"removed\":0}"; + + int removed = 0; + WatchMark[] snapshot; + lock (_marksLock) + { + if (req.All) + { + removed = _currentMarks.Count; + _currentMarks.Clear(); + } + else + { + // BUG-FUZZER-003/004: Trim and require leading '/' for symmetry + // with HandleMarkAdd. Without Trim a `unmark --path " /p[1] "` + // would silently miss a mark added as `/p[1]` and vice versa. + var unmarkPath = req.Path?.Trim() ?? ""; + if (!string.IsNullOrWhiteSpace(unmarkPath) && unmarkPath.StartsWith("/")) + { + removed = _currentMarks.RemoveAll(m => + string.Equals(m.Path, unmarkPath, StringComparison.Ordinal)); + } + } + if (removed > 0) _marksVersion++; + snapshot = _currentMarks.ToArray(); + } + _lastActivityTime = DateTime.UtcNow; + if (removed > 0) BroadcastMarkUpdate(snapshot); + + return JsonSerializer.Serialize( + new UnmarkResponse { Removed = removed }, + WatchMarkJsonContext.Default.UnmarkResponse); + } + catch + { + return "{\"removed\":0}"; + } + } + + /// Test-only accessor for current marks snapshot. + internal WatchMark[] GetMarksSnapshot() + { + lock (_marksLock) { return _currentMarks.ToArray(); } + } + + /// Test-only accessor for the current marks version. + internal int GetMarksVersion() + { + lock (_marksLock) { return _marksVersion; } + } + + /// + /// Test-only hook: install a full HTML snapshot synchronously and trigger + /// mark reconciliation. Used by WatchMarkTests to verify ResolveMark without + /// racing the pipe's "ack first, process later" ordering. + /// + internal void ApplyFullHtmlForTests(string html) + { + _currentHtml = html ?? ""; + _version++; + ReconcileAllMarks(); + } + + // -------- Mark resolution (server-side reconcile) -------- + // + // CONSISTENCY(path-stability): resolution uses naive positional + // data-path lookup — no fingerprinting, no drift detection. If an + // element is later removed or its find target no longer matches, + // the mark is flipped to Stale=true with MatchedText=[]. Same + // limitations as selection. grep "CONSISTENCY(path-stability)" for + // all deferred sites that should move together if we ever switch + // to stable IDs. See CLAUDE.md "Watch Server Rules". + // + // watch-isolation: this code runs pure-regex string-scraping on + // the html snapshot already cached in _currentHtml. It does not + // open the document, does not depend on OfficeCli.Handlers, and + // does not reference any DOM parser. A real HTML parser would be + // more correct but would introduce coupling; the MVP trades + // precision for isolation and matches the browser-side + // applyMarks() fallback behaviour. + + private static readonly System.Text.RegularExpressions.Regex _tagStripRx = + new("<[^>]+>", System.Text.RegularExpressions.RegexOptions.Compiled); + + // BUG-TESTER-001: ResolveMark accepts arbitrary user regex via r"..." find + // strings. A catastrophically backtracking pattern (e.g. r"(a+)+$") against + // a long input would freeze the watch reconcile loop indefinitely. Bound + // every user-supplied regex evaluation with this match timeout. + private static readonly TimeSpan MarkRegexMatchTimeout = TimeSpan.FromMilliseconds(500); + + // BUG-TESTER-003: "). These regexes + // strip the element including children, case-insensitive, dot-matches-newline. + private static readonly System.Text.RegularExpressions.Regex _scriptBodyRx = + new("]*>.*?", + System.Text.RegularExpressions.RegexOptions.Compiled + | System.Text.RegularExpressions.RegexOptions.IgnoreCase + | System.Text.RegularExpressions.RegexOptions.Singleline); + private static readonly System.Text.RegularExpressions.Regex _styleBodyRx = + new("]*>.*?", + System.Text.RegularExpressions.RegexOptions.Compiled + | System.Text.RegularExpressions.RegexOptions.IgnoreCase + | System.Text.RegularExpressions.RegexOptions.Singleline); + + // BUG-TESTER-002: server-side color whitelist for mark.color. Anything + // accepted here gets written verbatim into el.style.backgroundColor on + // every connected browser, so the validator must REJECT anything that + // isn't unambiguously a color value. Three accepted shapes: + // 1. #RGB / #RRGGBB / #RRGGBBAA hex + // 2. rgb(r,g,b) / rgba(r,g,b,a) with numeric components + // 3. one of the named colors in MarkNamedColors + // CONSISTENCY(mark-color-validation): grep this tag if expanding the set. + private static readonly System.Text.RegularExpressions.Regex _hexColorRx = + new("^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$", + System.Text.RegularExpressions.RegexOptions.Compiled); + private static readonly System.Text.RegularExpressions.Regex _rgbFuncRx = + new("^rgba?\\(\\s*\\d+(?:\\.\\d+)?\\s*,\\s*\\d+(?:\\.\\d+)?\\s*,\\s*\\d+(?:\\.\\d+)?(?:\\s*,\\s*\\d+(?:\\.\\d+)?)?\\s*\\)$", + System.Text.RegularExpressions.RegexOptions.Compiled); + private static readonly HashSet MarkNamedColors = new(StringComparer.OrdinalIgnoreCase) + { + "red", "green", "blue", "yellow", "orange", "purple", "pink", "cyan", + "magenta", "brown", "black", "white", "gray", "grey", "lime", "teal", + "navy", "olive", "maroon", "silver", "gold", "transparent", + }; + + // BUG-A-R2-M01 / BUG-TESTER-R302: Promote bare 3-, 6-, or 8-digit hex to + // #-prefixed form so the validator and storage match the rest of officecli's + // color convention. Returns the input unchanged for any other shape (named, + // rgb(...), already #-prefixed, or null/empty). Idempotent. + private static readonly System.Text.RegularExpressions.Regex _bareHex6Rx = + new("^[0-9a-fA-F]{6}$", System.Text.RegularExpressions.RegexOptions.Compiled); + private static readonly System.Text.RegularExpressions.Regex _bareHex3Rx = + new("^[0-9a-fA-F]{3}$", System.Text.RegularExpressions.RegexOptions.Compiled); + private static readonly System.Text.RegularExpressions.Regex _bareHex8Rx = + new("^[0-9a-fA-F]{8}$", System.Text.RegularExpressions.RegexOptions.Compiled); + internal static string? NormalizeMarkColorInput(string? color) + { + if (string.IsNullOrEmpty(color)) return color; + if (color[0] == '#') return color; + if (_bareHex6Rx.IsMatch(color)) + return "#" + color.ToUpperInvariant(); + if (_bareHex8Rx.IsMatch(color)) + return "#" + color.ToUpperInvariant(); + if (_bareHex3Rx.IsMatch(color)) + { + var c = color.ToUpperInvariant(); + return $"#{c[0]}{c[0]}{c[1]}{c[1]}{c[2]}{c[2]}"; + } + return color; + } + + internal static bool IsValidMarkColor(string color) + { + if (string.IsNullOrWhiteSpace(color)) return false; + var c = color.Trim(); + if (c.Length > 64) return false; // defensive bound + if (MarkNamedColors.Contains(c)) return true; + if (_hexColorRx.IsMatch(c)) return true; + if (_rgbFuncRx.IsMatch(c)) return true; + return false; + } + + /// + /// Locate the element with the given data-path in the cached HTML snapshot + /// and return its inner HTML fragment (start tag + children + end tag). + /// Uses bracket-depth counting of sibling tags to find the matching close. + /// Returns null if the path is not present. + /// + private static string? FindDataPathInHtml(string html, string path) + { + if (string.IsNullOrEmpty(html) || string.IsNullOrEmpty(path)) return null; + // Anchor the search on the data-path attribute. Path may contain [] so + // we match it as a literal substring inside quotes. + var marker = "data-path=\"" + path + "\""; + var idx = html.IndexOf(marker, StringComparison.Ordinal); + if (idx < 0) return null; + // Walk back to the opening '<' of this element's start tag. + var start = html.LastIndexOf('<', idx); + if (start < 0) return null; + // Find the end of the start tag. + var startEnd = html.IndexOf('>', idx); + if (startEnd < 0) return null; + // Self-closing tag? (extremely unlikely for data-path targets but be safe) + if (html[startEnd - 1] == '/') + return html.Substring(start, startEnd - start + 1); + // Extract the tag name so we can match its close. + var tagEnd = start + 1; + while (tagEnd < html.Length && !char.IsWhiteSpace(html[tagEnd]) && html[tagEnd] != '>') + tagEnd++; + var tag = html.Substring(start + 1, tagEnd - start - 1).ToLowerInvariant(); + var openToken = "<" + tag; + var closeToken = " 0) + { + var nextOpen = html.IndexOf(openToken, cursor, StringComparison.OrdinalIgnoreCase); + var nextClose = html.IndexOf(closeToken, cursor, StringComparison.OrdinalIgnoreCase); + if (nextClose < 0) return null; + if (nextOpen >= 0 && nextOpen < nextClose) + { + // Ensure the candidate open isn't actually part of a longer tag name + var after = nextOpen + openToken.Length; + if (after < html.Length && (html[after] == ' ' || html[after] == '>' || html[after] == '\t' || html[after] == '\n')) + { + depth++; + cursor = after; + continue; + } + cursor = nextOpen + openToken.Length; + continue; + } + depth--; + cursor = nextClose + closeToken.Length; + if (depth == 0) + { + // Advance past the close tag's '>' + var gt = html.IndexOf('>', cursor); + if (gt < 0) return null; + return html.Substring(start, gt - start + 1); + } + } + return null; + } + + /// + /// Extract plain text content from an HTML fragment: strip all tags, decode + /// HTML entities, collapse whitespace minimally, and NFC-normalize. Pure + /// regex — no DOM parser dependency. + /// + internal static string ExtractTextContent(string htmlFragment) + { + if (string.IsNullOrEmpty(htmlFragment)) return ""; + // BUG-TESTER-003: drop and 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) + { + var (start, end) = FindSlideFragmentRange(html, slideNum); + if (start < 0) return html; + return string.Concat(html.AsSpan(0, start), newFragment, html.AsSpan(end)); + } + + /// Append a slide fragment before the last closing tag of the main container. + private static string AppendSlideToHtml(string html, string fragment) + { + // Find the last before — that's the .main container's closing tag + var bodyClose = html.LastIndexOf("", StringComparison.OrdinalIgnoreCase); + if (bodyClose < 0) return html + fragment; + // Find the just before + var mainClose = html.LastIndexOf("", bodyClose, StringComparison.OrdinalIgnoreCase); + if (mainClose < 0) return html; + return string.Concat(html.AsSpan(0, mainClose), fragment, "\n", html.AsSpan(mainClose)); + } + + /// Remove a slide fragment from the full HTML. + private static string RemoveSlideFromHtml(string html, int slideNum) + { + var (start, end) = FindSlideFragmentRange(html, slideNum); + if (start < 0) return html; + return string.Concat(html.AsSpan(0, start), html.AsSpan(end)); + } + + /// Find the start/end character positions of a slide-container div in the HTML. + private static (int Start, int End) FindSlideFragmentRange(string html, int slideNum) + { + // The sidebar also emits `
    `, so matching + // on `data-slide="N"` alone hits the thumb first and leaves the main + // slide-container stale — user-visible as a white main view on every + // incremental update. Pin to the slide-container class. + var marker = $"class=\"slide-container\" data-slide=\"{slideNum}\""; + var idx = html.IndexOf(marker, StringComparison.Ordinal); + if (idx < 0) return (-1, -1); + + var start = html.LastIndexOf("
    by counting nesting + var depth = 0; + var pos = start; + while (pos < html.Length) + { + var nextOpen = html.IndexOf("", pos, StringComparison.OrdinalIgnoreCase); + + if (nextClose < 0) break; + + if (nextOpen >= 0 && nextOpen < nextClose) + { + depth++; + pos = nextOpen + 4; + } + else + { + depth--; + if (depth == 0) + return (start, nextClose + 6); + pos = nextClose + 6; + } + } + + return (-1, -1); + } + + /// Extract all <style> blocks from HTML head, concatenated. + private static string? ExtractStyleBlock(string html) + { + var sb = new StringBuilder(); + var idx = 0; + while (true) + { + var start = html.IndexOf("", start, StringComparison.OrdinalIgnoreCase); + if (end < 0) break; + end += 8; // include + sb.Append(html, start, end - start); + idx = end; + } + return sb.Length > 0 ? sb.ToString() : null; + } + + /// Split Word HTML into blocks keyed by block number. Returns dict of blockNum → content. + private static Dictionary SplitWordBlocks(string html) + { + var blocks = new Dictionary(); + var beginRx = new System.Text.RegularExpressions.Regex(@""); + var matches = beginRx.Matches(html); + for (int i = 0; i < matches.Count; i++) + { + var m = matches[i]; + var blockNum = int.Parse(m.Groups[1].Value); + var contentStart = m.Index + m.Length; + var endMarker = $""; + var endIdx = html.IndexOf(endMarker, contentStart, StringComparison.Ordinal); + if (endIdx >= 0) + blocks[blockNum] = html[contentStart..endIdx]; + } + return blocks; + } + + /// Compute block-level patches between old and new Word HTML. Returns null if diff is too large (fallback to full). + internal static List? ComputeWordPatches(string oldHtml, string newHtml) + { + // Only diff if both are Word documents with block markers + if (string.IsNullOrEmpty(oldHtml) || string.IsNullOrEmpty(newHtml)) + return null; + if (!oldHtml.Contains("data-block=\"1\"") || !newHtml.Contains("data-block=\"1\"")) + return null; + + var oldBlocks = SplitWordBlocks(oldHtml); + var newBlocks = SplitWordBlocks(newHtml); + + if (oldBlocks.Count == 0 && newBlocks.Count == 0) return null; + + var patches = new List(); + + // Find max block number across both + var maxBlock = 0; + foreach (var k in oldBlocks.Keys) if (k > maxBlock) maxBlock = k; + foreach (var k in newBlocks.Keys) if (k > maxBlock) maxBlock = k; + + for (int b = 1; b <= maxBlock; b++) + { + var inOld = oldBlocks.TryGetValue(b, out var oldContent); + var inNew = newBlocks.TryGetValue(b, out var newContent); + + if (inOld && inNew) + { + if (oldContent != newContent) + patches.Add(new WordPatch { Op = "replace", Block = b, Html = newContent }); + // else: unchanged, skip + } + else if (!inOld && inNew) + { + patches.Add(new WordPatch { Op = "add", Block = b, Html = newContent }); + } + else if (inOld && !inNew) + { + patches.Add(new WordPatch { Op = "remove", Block = b }); + } + } + + if (patches.Count == 0) return null; // no changes + + // If more than 60% of blocks changed (and enough blocks to matter), fallback to full refresh + var totalBlocks = Math.Max(oldBlocks.Count, newBlocks.Count); + if (totalBlocks >= 5 && patches.Count > totalBlocks * 0.6) + return null; + + return patches; + } + + private void SendSseWordPatch(List patches, int version, int baseVersion, string? scrollTo) + { + var sb = new StringBuilder(); + sb.Append("{\"action\":\"word-patch\""); + sb.Append(",\"version\":").Append(version); + sb.Append(",\"baseVersion\":").Append(baseVersion); + sb.Append(",\"patches\":["); + for (int i = 0; i < patches.Count; i++) + { + if (i > 0) sb.Append(','); + sb.Append("{\"op\":\"").Append(patches[i].Op).Append('"'); + sb.Append(",\"block\":").Append(patches[i].Block); + if (patches[i].Html != null) + { + sb.Append(",\"html\":"); + AppendJsonString(sb, patches[i].Html!); + } + sb.Append('}'); + } + sb.Append(']'); + if (scrollTo != null) + { + sb.Append(",\"scrollTo\":"); + AppendJsonString(sb, scrollTo); + } + sb.Append('}'); + BroadcastSse(sb.ToString()); + } + + // ==================== Excel Row-Level Diff ==================== + + /// + /// Signature of chart overlay positions — concatenation of all data-from-row/col + /// values in document order. Different signature → chart was moved → need full refresh. + /// + private static string ChartOverlaySignature(string html) + { + var sb = new System.Text.StringBuilder(); + var rx = new System.Text.RegularExpressions.Regex(@"data-from-(?:row|col)=""(\d+)"""); + foreach (System.Text.RegularExpressions.Match m in rx.Matches(html)) + sb.Append(m.Value).Append(','); + return sb.ToString(); + } + + /// + /// Signature of Excel table chrome — concatenates each sheet's <colgroup>, + /// <thead>, and the <table> open tag (which carries table width style). + /// Row-level patches only swap <tr> nodes, so if this signature changes + /// between old and new HTML (column added/removed, column width changed, + /// thead style changed) the browser needs a full body refresh — otherwise + /// new headers/widths stay stale until a manual reload. + /// + private static string TableChromeSignature(string html) + { + var sb = new System.Text.StringBuilder(); + foreach (System.Text.RegularExpressions.Match m in + System.Text.RegularExpressions.Regex.Matches( + html, @".*?", + System.Text.RegularExpressions.RegexOptions.Singleline)) + sb.Append(m.Value).Append('|'); + foreach (System.Text.RegularExpressions.Match m in + System.Text.RegularExpressions.Regex.Matches( + html, @".*?", + System.Text.RegularExpressions.RegexOptions.Singleline)) + sb.Append(m.Value).Append('|'); + foreach (System.Text.RegularExpressions.Match m in + System.Text.RegularExpressions.Regex.Matches(html, @"]*>")) + sb.Append(m.Value).Append('|'); + return sb.ToString(); + } + + /// Split Excel HTML into rows keyed by "sheetIdx-rowNum" from data-row attributes. + private static Dictionary SplitExcelRows(string html) + { + var rows = new Dictionary(); + + // Static mode: extract elements + var rx = new System.Text.RegularExpressions.Regex(@"]*data-row=""([^""]+)""[^>]*>"); + var matches = rx.Matches(html); + for (int i = 0; i < matches.Count; i++) + { + var m = matches[i]; + var key = m.Groups[1].Value; + var contentStart = m.Index; + var endTag = ""; + var endIdx = html.IndexOf(endTag, contentStart + m.Length, StringComparison.Ordinal); + if (endIdx >= 0) + rows[key] = html[contentStart..(endIdx + endTag.Length)]; + } + + // Virt mode: extract rows from "); + var rowRx = new System.Text.RegularExpressions.Regex( + @"""r"":(\d+).*?""html"":""((?:[^""\\]|\\.)*)"""); + var heightRx = new System.Text.RegularExpressions.Regex(@"""h"":(\d+(?:\.\d+)?)"); + foreach (System.Text.RegularExpressions.Match scriptMatch in scriptRx.Matches(html)) + { + var sheetIdx = scriptMatch.Groups[1].Value; + var json = scriptMatch.Groups[2].Value; + foreach (System.Text.RegularExpressions.Match rowMatch in rowRx.Matches(json)) + { + var rowNum = rowMatch.Groups[1].Value; + var key = $"{sheetIdx}-{rowNum}"; + if (rows.ContainsKey(key)) continue; // frozen row already captured from static + var innerHtml = rowMatch.Groups[2].Value + .Replace("\\\"", "\"").Replace("\\\\", "\\") + .Replace("\\n", "\n").Replace("\\r", "\r").Replace("\\t", "\t"); + // Extract row height from metadata fields (the portion before "html":) + var htmlFieldOffset = rowMatch.Value.IndexOf("\"html\":", StringComparison.Ordinal); + var metaStr = htmlFieldOffset >= 0 ? rowMatch.Value.Substring(0, htmlFieldOffset) : ""; + var hm = heightRx.Match(metaStr); + var heightStyle = hm.Success ? $" style=\"height:{hm.Groups[1].Value}pt\"" : ""; + rows[key] = $"{innerHtml}"; + } + } + + return rows; + } + + /// Compute row-level patches between old and new Excel HTML. Returns null if diff is too large (fallback to full). + internal static List<(string Op, string Row, string? Html)>? ComputeExcelPatches(string oldHtml, string newHtml) + { + if (string.IsNullOrEmpty(oldHtml) || string.IsNullOrEmpty(newHtml)) + return null; + // Two valid row-data signals: + // static: data-row="X..." where the value starts with an alphanumeric char (real keys + // are "N-M" or "word-N-M"; JS template literals have data-row="' + ... which + // starts with a single-quote, not alphanumeric). + // virt: id="virt-data-N" on - """; - - public WatchServer(string filePath, int port, TimeSpan? idleTimeout = null, string? initialHtml = null) - { - _filePath = Path.GetFullPath(filePath); - _pipeName = GetWatchPipeName(_filePath); - _port = port; - _idleTimeout = idleTimeout ?? TimeSpan.FromMinutes(5); - _tcpListener = new TcpListener(IPAddress.Loopback, _port); - if (!string.IsNullOrEmpty(initialHtml)) - _currentHtml = initialHtml; - } - - public static string GetWatchPipeName(string filePath) - { - var fullPath = Path.GetFullPath(filePath); - if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()) - fullPath = fullPath.ToUpperInvariant(); - var hash = Convert.ToHexString( - System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(fullPath)))[..16]; - return $"officecli-watch-{hash}"; - } - - /// - /// Check if another watch process is already running for this file. - /// Returns the port number if running, or null if not. - /// - public static int? GetExistingWatchPort(string filePath) - { - try - { - int? result = null; - var task = Task.Run(() => - { - var pipeName = GetWatchPipeName(filePath); - using var client = new System.IO.Pipes.NamedPipeClientStream(".", pipeName, System.IO.Pipes.PipeDirection.InOut); - client.Connect(100); - var noBom = new UTF8Encoding(false); - using var writer = new StreamWriter(client, noBom, leaveOpen: true) { AutoFlush = true }; - writer.WriteLine("ping"); - writer.Flush(); - using var reader = new StreamReader(client, noBom, detectEncodingFromByteOrderMarks: false, leaveOpen: true); - var response = reader.ReadLine(); - result = int.TryParse(response, out var port) ? port : 0; - }); - return task.Wait(TimeSpan.FromSeconds(2)) ? result : null; - } - catch - { - return null; // not running - } - } - - public async Task RunAsync(CancellationToken externalToken = default) - { - // Prevent duplicate watch processes for the same file - var existingPort = GetExistingWatchPort(_filePath); - if (existingPort.HasValue) - { - var url = existingPort.Value > 0 ? $" at http://localhost:{existingPort.Value}" : ""; - throw new InvalidOperationException($"Another watch process is already running{url} for {_filePath}"); - } - - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, externalToken); - var token = linkedCts.Token; - - _tcpListener.Start(); - Console.WriteLine($"Watch: http://localhost:{_port}"); - Console.WriteLine($"Watching: {_filePath}"); - Console.WriteLine("Press Ctrl+C to stop."); - - var pipeTask = RunPipeListenerAsync(token); - var idleTask = RunIdleWatchdogAsync(token); - - while (!token.IsCancellationRequested) - { - try - { - var client = await _tcpListener.AcceptTcpClientAsync(token); - _ = HandleClientAsync(client, token); - } - catch (OperationCanceledException) { break; } - catch (SocketException) { break; } - catch (ObjectDisposedException) { break; } - catch (Exception ex) - { - Console.Error.WriteLine($"Watch HTTP error: {ex.Message}"); - } - } - - // Pipe listener may not cancel promptly on Windows (WaitForConnectionAsync - // ignores CancellationToken on some OS versions). Connect-and-drop to unblock it. - try - { - using var kickPipe = new System.IO.Pipes.NamedPipeClientStream(".", _pipeName, System.IO.Pipes.PipeDirection.InOut); - kickPipe.Connect(500); - } - catch { } - - try { await pipeTask; } catch (OperationCanceledException) { } - try { await idleTask; } catch (OperationCanceledException) { } - } - - private async Task RunIdleWatchdogAsync(CancellationToken token) - { - var checkInterval = TimeSpan.FromSeconds(Math.Min(30, Math.Max(1, _idleTimeout.TotalSeconds / 2))); - while (!token.IsCancellationRequested) - { - await Task.Delay(checkInterval, token); - int clientCount; - lock (_sseLock) { clientCount = _sseClients.Count; } - if (clientCount == 0 && DateTime.UtcNow - _lastActivityTime > _idleTimeout) - { - Console.WriteLine("Watch: idle timeout, shutting down."); - _cts.Cancel(); - break; - } - } - } - - private async Task RunPipeListenerAsync(CancellationToken token) - { - while (!token.IsCancellationRequested) - { - var server = new System.IO.Pipes.NamedPipeServerStream( - _pipeName, System.IO.Pipes.PipeDirection.InOut, - System.IO.Pipes.NamedPipeServerStream.MaxAllowedServerInstances, - System.IO.Pipes.PipeTransmissionMode.Byte, - System.IO.Pipes.PipeOptions.Asynchronous); - try - { - await server.WaitForConnectionAsync(token); - var noBom = new UTF8Encoding(false); - using var reader = new StreamReader(server, noBom, detectEncodingFromByteOrderMarks: false, leaveOpen: true); - using var writer = new StreamWriter(server, noBom, leaveOpen: true) { AutoFlush = true }; - - var message = await reader.ReadLineAsync(token); - _lastActivityTime = DateTime.UtcNow; - - if (message == "close") - { - await writer.WriteLineAsync("ok".AsMemory(), token); - Console.WriteLine("Watch closed by remote command."); - _cts.Cancel(); - break; - } - else if (message == "ping") - { - // Return port so callers can find the existing watch URL - await writer.WriteLineAsync(_port.ToString().AsMemory(), token); - } - else if (message != null) - { - await writer.WriteLineAsync("ok".AsMemory(), token); - // Try to parse as WatchMessage JSON - HandleWatchMessage(message); - } - } - catch (OperationCanceledException) { break; } - catch { /* ignore pipe errors */ } - finally - { - await server.DisposeAsync(); - } - } - } - - private void HandleWatchMessage(string json) - { - try - { - var msg = JsonSerializer.Deserialize(json, WatchMessageJsonContext.Default.WatchMessage); - if (msg == null) return; - - // Always update cached full HTML when provided (authoritative snapshot) - if (!string.IsNullOrEmpty(msg.FullHtml)) - { - _currentHtml = msg.FullHtml; - } - - // Apply incremental patch when no full HTML was provided - if (string.IsNullOrEmpty(msg.FullHtml)) - { - if (msg.Action == "replace" && msg.Slide > 0 && msg.Html != null) - _currentHtml = PatchSlideInHtml(_currentHtml, msg.Slide, msg.Html); - else if (msg.Action == "add" && msg.Html != null) - _currentHtml = AppendSlideToHtml(_currentHtml, msg.Html); - else if (msg.Action == "remove" && msg.Slide > 0) - _currentHtml = RemoveSlideFromHtml(_currentHtml, msg.Slide); - } - - // Forward to SSE clients - SendSseEvent(msg.Action, msg.Slide, msg.Html, msg.ScrollTo); - } - catch - { - // Legacy format or parse error — treat as full refresh signal - SendSseEvent("full", 0, null); - } - } - - /// Replace a single slide fragment in the full HTML by data-slide number. - private static string PatchSlideInHtml(string html, int slideNum, string newFragment) - { - var (start, end) = FindSlideFragmentRange(html, slideNum); - if (start < 0) return html; - return string.Concat(html.AsSpan(0, start), newFragment, html.AsSpan(end)); - } - - /// Append a slide fragment before the last closing tag of the main container. - private static string AppendSlideToHtml(string html, string fragment) - { - // Find the last
    before — that's the .main container's closing tag - var bodyClose = html.LastIndexOf("", StringComparison.OrdinalIgnoreCase); - if (bodyClose < 0) return html + fragment; - // Find the
    just before - var mainClose = html.LastIndexOf("", bodyClose, StringComparison.OrdinalIgnoreCase); - if (mainClose < 0) return html; - return string.Concat(html.AsSpan(0, mainClose), fragment, "\n", html.AsSpan(mainClose)); - } - - /// Remove a slide fragment from the full HTML. - private static string RemoveSlideFromHtml(string html, int slideNum) - { - var (start, end) = FindSlideFragmentRange(html, slideNum); - if (start < 0) return html; - return string.Concat(html.AsSpan(0, start), html.AsSpan(end)); - } - - /// Find the start/end character positions of a slide-container div in the HTML. - private static (int Start, int End) FindSlideFragmentRange(string html, int slideNum) - { - var marker = $"data-slide=\"{slideNum}\""; - var idx = html.IndexOf(marker, StringComparison.Ordinal); - if (idx < 0) return (-1, -1); - - var start = html.LastIndexOf("
    by counting nesting - var depth = 0; - var pos = start; - while (pos < html.Length) - { - var nextOpen = html.IndexOf("", pos, StringComparison.OrdinalIgnoreCase); - - if (nextClose < 0) break; - - if (nextOpen >= 0 && nextOpen < nextClose) - { - depth++; - pos = nextOpen + 4; - } - else - { - depth--; - if (depth == 0) - return (start, nextClose + 6); - pos = nextClose + 6; - } - } - - return (-1, -1); - } - - private void SendSseEvent(string action, int slideNum, string? html, string? scrollTo = null) - { - // Build JSON manually to avoid dependency - var sb = new StringBuilder(); - sb.Append("{\"action\":\"").Append(action).Append('"'); - sb.Append(",\"slide\":").Append(slideNum); - if (html != null) - { - sb.Append(",\"html\":"); - AppendJsonString(sb, html); - } - if (scrollTo != null) - { - sb.Append(",\"scrollTo\":"); - AppendJsonString(sb, scrollTo); - } - sb.Append('}'); - - var sseJson = sb.ToString(); - - lock (_sseLock) - { - var dead = new List(); - foreach (var client in _sseClients) - { - try - { - var data = Encoding.UTF8.GetBytes($"event: update\ndata: {sseJson}\n\n"); - client.Write(data); - client.Flush(); - } - catch - { - dead.Add(client); - } - } - foreach (var d in dead) _sseClients.Remove(d); - } - } - - private static void AppendJsonString(StringBuilder sb, string value) - { - sb.Append('"'); - foreach (var ch in value) - { - switch (ch) - { - case '"': sb.Append("\\\""); break; - case '\\': sb.Append("\\\\"); break; - case '\n': sb.Append("\\n"); break; - case '\r': sb.Append("\\r"); break; - case '\t': sb.Append("\\t"); break; - default: - if (ch < 0x20) - sb.Append($"\\u{(int)ch:X4}"); - else - sb.Append(ch); - break; - } - } - sb.Append('"'); - } - - private async Task HandleClientAsync(TcpClient client, CancellationToken token) - { - try - { - var stream = client.GetStream(); - var requestLine = await ReadHttpRequestAsync(stream, token); - - if (requestLine.Contains("GET /events")) - { - try - { - await HandleSseAsync(stream, token); - } - finally - { - client.Close(); - } - } - else - { - 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); - client.Close(); - } - } - catch - { - try { client.Close(); } catch { } - } - } - - private static async Task ReadHttpRequestAsync(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; - } - - private async Task HandleSseAsync(NetworkStream stream, CancellationToken token) - { - var header = Encoding.UTF8.GetBytes( - "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nCache-Control: no-cache\r\nConnection: keep-alive\r\nAccess-Control-Allow-Origin: *\r\n\r\n"); - await stream.WriteAsync(header, token); - - _lastActivityTime = DateTime.UtcNow; - lock (_sseLock) { _sseClients.Add(stream); } - - try - { - while (!token.IsCancellationRequested) - { - await Task.Delay(30000, token); - var heartbeat = Encoding.UTF8.GetBytes(": heartbeat\n\n"); - await stream.WriteAsync(heartbeat, token); - } - } - catch { } - finally - { - lock (_sseLock) { _sseClients.Remove(stream); } - } - } - - private static string InjectSseScript(string html) - { - var idx = html.LastIndexOf("", StringComparison.OrdinalIgnoreCase); - if (idx >= 0) - return html[..idx] + SseScript + html[idx..]; - return html + SseScript; - } - - public void Dispose() - { - if (!_disposed) - { - _disposed = true; - _cts.Cancel(); - try { _tcpListener.Stop(); } catch { } - - // Kick the pipe listener out of WaitForConnectionAsync — it may not - // honour CancellationToken on some Windows versions. - try - { - using var kick = new System.IO.Pipes.NamedPipeClientStream(".", _pipeName, System.IO.Pipes.PipeDirection.InOut); - kick.Connect(500); - } - catch { } - - _cts.Dispose(); - } - } -} diff --git a/src/officecli/Core/WordNumFmtRenderer.cs b/src/officecli/Core/WordNumFmtRenderer.cs new file mode 100644 index 000000000..1d39f4d43 --- /dev/null +++ b/src/officecli/Core/WordNumFmtRenderer.cs @@ -0,0 +1,416 @@ +// Copyright 2025 OfficeCli (officecli.ai) +// SPDX-License-Identifier: Apache-2.0 + +using System.Globalization; +using System.Text; + +namespace OfficeCli.Core; + +/// +/// Converts a 1-based counter into the OOXML w:numFmt marker glyphs. +/// Covers the numFmt enum from ECMA-376 §17.18.59 that Word ships with; +/// unknown or unmapped values fall back to decimal. +/// +public static class WordNumFmtRenderer +{ + public static string Render(int n, string? numFmt) + { + if (n < 1) n = 1; + switch ((numFmt ?? "decimal").ToLowerInvariant()) + { + case "decimal": return n.ToString(CultureInfo.InvariantCulture); + case "decimalzero": return n < 10 ? $"0{n}" : n.ToString(CultureInfo.InvariantCulture); + case "upperroman": return ToRoman(n).ToUpperInvariant(); + case "lowerroman": return ToRoman(n).ToLowerInvariant(); + case "upperletter": return ToAlpha(n, uppercase: true); + case "lowerletter": return ToAlpha(n, uppercase: false); + case "ordinal": return ToOrdinal(n); + case "cardinaltext": return ToEnglishCardinal(n); + case "ordinaltext": return ToEnglishOrdinal(n); + case "chinesecounting": + case "japanesecounting": + return ToChineseCounting(n, formal: false); + case "chinesecountingthousand": + case "taiwanesecounting": + case "taiwanesecountingthousand": + return ToChineseCounting(n, formal: true); + case "chineselegalsimplified": + return ToChineseLegalSimplified(n); + case "ideographdigital": + case "taiwanesedigital": + case "japanesedigitaltenthousand": + return ToIdeographDigital(n); + case "koreandigital": + case "koreandigital2": + return ToKoreanDigital(n); + case "koreancounting": + return ToKoreanCounting(n); + case "koreanlegal": + return ToKoreanLegal(n); + case "japaneselegal": + return ToJapaneseLegal(n); + case "ideographtraditional": + return ToHeavenlyStems(n); + case "ideographzodiac": + return ToEarthlyBranches(n); + case "decimalenclosedcircle": + case "decimalenclosedcirclechinese": + return ToEnclosedCircle(n); + case "decimalenclosedfullstop": + return $"{n}."; + case "decimalenclosedparen": + return $"({n})"; + case "decimalfullwidth": + case "decimalfullwidth2": + return ToFullWidthDigits(n); + case "decimalhalfwidth": + return n.ToString(CultureInfo.InvariantCulture); + case "arabicabjad": + return ToArabicAbjad(n); + case "arabicalpha": + return ToArabicAlpha(n); + case "hebrew1": + case "hebrew2": + return ToHebrewNumeral(n); + case "thainumbers": + case "thaicounting": + return ToThaiDigits(n); + case "thailetters": + return ToThaiLetters(n); + case "hindinumbers": + case "hindicounting": + case "hindicardinaltext": + return ToDevanagariDigits(n); + case "hindiletters": + return ToHindiLetters(n); + case "hindivowels": + return ToHindiVowels(n); + case "russianlower": + return ToRussianAlpha(n, uppercase: false); + case "russianupper": + return ToRussianAlpha(n, uppercase: true); + case "none": return ""; + case "bullet": return "\u2022"; + default: return n.ToString(CultureInfo.InvariantCulture); + } + } + + // ---------- helpers ---------- + + private static string ToRoman(int n) + { + if (n <= 0 || n > 3999) return n.ToString(CultureInfo.InvariantCulture); + int[] vals = { 1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1 }; + string[] syms = { "M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I" }; + var sb = new StringBuilder(); + for (int i = 0; i < vals.Length; i++) + while (n >= vals[i]) { sb.Append(syms[i]); n -= vals[i]; } + return sb.ToString(); + } + + private static string ToAlpha(int n, bool uppercase) + { + // Word's behavior: A,B,...,Z,AA,BB,CC,... (repeating letter at 27+), not Excel column-style. + if (n < 1) n = 1; + var letter = (char)(((n - 1) % 26) + (uppercase ? 'A' : 'a')); + // Cap repeat to a sensible upper bound — an adversarial + // otherwise allocates a 160MB string + // per list item (DoS). Word itself stops reasonably at a few + // dozen repeats in practice. + var repeat = Math.Min(((n - 1) / 26) + 1, 64); + return new string(letter, repeat); + } + + private static string ToOrdinal(int n) + { + int mod100 = n % 100, mod10 = n % 10; + string suffix = (mod100 is >= 11 and <= 13) ? "th" : mod10 switch + { + 1 => "st", 2 => "nd", 3 => "rd", _ => "th" + }; + return $"{n}{suffix}"; + } + + private static readonly string[] EnglishOnes = + { + "", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", + "Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", + "Seventeen", "Eighteen", "Nineteen" + }; + private static readonly string[] EnglishTens = + { + "", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety" + }; + + private static string ToEnglishCardinal(int n) + { + if (n == 0) return "Zero"; + if (n < 0) return $"Negative {ToEnglishCardinal(-n)}"; + var sb = new StringBuilder(); + if (n >= 1000) { sb.Append(ToEnglishCardinal(n / 1000)).Append(" Thousand"); n %= 1000; if (n > 0) sb.Append(' '); } + if (n >= 100) { sb.Append(EnglishOnes[n / 100]).Append(" Hundred"); n %= 100; if (n > 0) sb.Append(' '); } + if (n >= 20) { sb.Append(EnglishTens[n / 10]); n %= 10; if (n > 0) sb.Append('-').Append(EnglishOnes[n]); } + else if (n > 0) sb.Append(EnglishOnes[n]); + return sb.ToString(); + } + + private static string ToEnglishOrdinal(int n) + { + var card = ToEnglishCardinal(n); + // Only transform the trailing word. + var lastSpace = card.LastIndexOf(' '); + var lastHyphen = card.LastIndexOf('-'); + var split = Math.Max(lastSpace, lastHyphen); + var head = split >= 0 ? card[..(split + 1)] : ""; + var tail = split >= 0 ? card[(split + 1)..] : card; + string suffixMap(string w) => w switch + { + "One" => "First", "Two" => "Second", "Three" => "Third", "Five" => "Fifth", + "Eight" => "Eighth", "Nine" => "Ninth", "Twelve" => "Twelfth", + _ => w.EndsWith("y", StringComparison.Ordinal) ? w[..^1] + "ieth" + : w.EndsWith("e", StringComparison.Ordinal) ? w[..^1] + "th" + : w + "th" + }; + return head + suffixMap(tail); + } + + private static readonly char[] CnDigits = { '零', '一', '二', '三', '四', '五', '六', '七', '八', '九' }; + private static readonly char[] CnFormalDigits = { '零', '壹', '貳', '參', '肆', '伍', '陸', '柒', '捌', '玖' }; + private static readonly char[] CnLegalSimplDigits = { '零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖' }; + + private static string ToChineseCounting(int n, bool formal) + { + var digits = formal ? CnFormalDigits : CnDigits; + char shi = formal ? '拾' : '十'; + char bai = formal ? '佰' : '百'; + char qian = formal ? '仟' : '千'; + char wan = formal ? '萬' : '万'; + return BuildCjkPositional(n, digits, shi, bai, qian, wan); + } + + private static string ToChineseLegalSimplified(int n) + => BuildCjkPositional(n, CnLegalSimplDigits, '拾', '佰', '仟', '万'); + + private static string BuildCjkPositional(int n, char[] digits, char shi, char bai, char qian, char wan) + { + if (n == 0) return digits[0].ToString(); + if (n < 0) return "-" + BuildCjkPositional(-n, digits, shi, bai, qian, wan); + if (n >= 10000) + { + var hi = n / 10000; + var lo = n % 10000; + var s = BuildCjkPositional(hi, digits, shi, bai, qian, wan) + wan; + if (lo == 0) return s; + if (lo < 1000) s += digits[0]; + return s + BuildCjkPositional(lo, digits, shi, bai, qian, wan); + } + // 0..9999 + var sb = new StringBuilder(); + int q = n / 1000, b = (n / 100) % 10, sh = (n / 10) % 10, u = n % 10; + bool emittedNonZero = false; + bool pendingZero = false; + void emitDigit(int d, char? unit) + { + if (d == 0) + { + if (emittedNonZero) pendingZero = true; + return; + } + if (pendingZero) { sb.Append(digits[0]); pendingZero = false; } + // Special case: leading "一十" → "十" in informal spelling when n<20. + if (unit == shi && d == 1 && !emittedNonZero) + sb.Append(unit); + else + { + sb.Append(digits[d]); + if (unit.HasValue) sb.Append(unit.Value); + } + emittedNonZero = true; + } + emitDigit(q, qian); + emitDigit(b, bai); + emitDigit(sh, shi); + emitDigit(u, null); + return sb.ToString(); + } + + private static string ToIdeographDigital(int n) + { + // 〇一二三四五六七八九, positional: 25 → 二五, 100 → 一〇〇 + var s = n.ToString(CultureInfo.InvariantCulture); + var sb = new StringBuilder(s.Length); + foreach (var c in s) + sb.Append(c == '0' ? '〇' : CnDigits[c - '0']); + return sb.ToString(); + } + + private static readonly string[] HeavenlyStems = + { "甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸" }; + private static readonly string[] EarthlyBranches = + { "子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥" }; + + private static string ToHeavenlyStems(int n) => HeavenlyStems[(n - 1) % 10]; + private static string ToEarthlyBranches(int n) => EarthlyBranches[(n - 1) % 12]; + + private static string ToEnclosedCircle(int n) + { + // ① .. ⑳ = U+2460..U+2473 (1..20) + if (n >= 1 && n <= 20) return ((char)(0x2460 + n - 1)).ToString(); + // 21..35 at U+3251..U+325F (Word uses similar enclosed glyphs); fallback to (n) + if (n >= 21 && n <= 35) return ((char)(0x3251 + n - 21)).ToString(); + if (n >= 36 && n <= 50) return ((char)(0x32B1 + n - 36)).ToString(); + return $"({n})"; + } + + private static string ToFullWidthDigits(int n) + { + var s = n.ToString(CultureInfo.InvariantCulture); + var sb = new StringBuilder(s.Length); + foreach (var c in s) + sb.Append(c is >= '0' and <= '9' ? (char)('\uFF10' + (c - '0')) : c); + return sb.ToString(); + } + + // Arabic alphabet (abjad order): 1..28 + private static readonly string[] AbjadLetters = + { + "أ", "ب", "ج", "د", "ه", "و", "ز", "ح", "ط", "ي", + "ك", "ل", "م", "ن", "س", "ع", "ف", "ص", "ق", "ر", + "ش", "ت", "ث", "خ", "ذ", "ض", "ظ", "غ" + }; + private static string ToArabicAbjad(int n) + => n >= 1 && n <= AbjadLetters.Length + ? AbjadLetters[n - 1] + : n.ToString(CultureInfo.InvariantCulture); + + // Arabic alphabet (alphabetical / hijā'ī order): 1..28 + private static readonly string[] ArabicAlphaLetters = + { + "أ", "ب", "ت", "ث", "ج", "ح", "خ", "د", "ذ", "ر", + "ز", "س", "ش", "ص", "ض", "ط", "ظ", "ع", "غ", "ف", + "ق", "ك", "ل", "م", "ن", "ه", "و", "ي" + }; + private static string ToArabicAlpha(int n) + => n >= 1 && n <= ArabicAlphaLetters.Length + ? ArabicAlphaLetters[n - 1] + : n.ToString(CultureInfo.InvariantCulture); + + // Hebrew numerals (gematria), supports 1..999. + private static string ToHebrewNumeral(int n) + { + if (n < 1 || n > 999) return n.ToString(CultureInfo.InvariantCulture); + string[] ones = { "", "א", "ב", "ג", "ד", "ה", "ו", "ז", "ח", "ט" }; + string[] tens = { "", "י", "כ", "ל", "מ", "נ", "ס", "ע", "פ", "צ" }; + string[] hundreds = { "", "ק", "ר", "ש", "ת", "תק", "תר", "תש", "תת", "תתק" }; + var sb = new StringBuilder(); + sb.Append(hundreds[n / 100]); + int rem = n % 100; + if (rem == 15) sb.Append("טו"); + else if (rem == 16) sb.Append("טז"); + else { sb.Append(tens[rem / 10]); sb.Append(ones[rem % 10]); } + return sb.ToString(); + } + + private static readonly string[] RussianAlphaLower = + { + "а", "б", "в", "г", "д", "е", "ж", "з", "и", "к", + "л", "м", "н", "о", "п", "р", "с", "т", "у", "ф", + "х", "ц", "ч", "ш", "щ", "э", "ю", "я" + }; + // Korean numerals ------------------------------------------------------ + + private static readonly char[] KoreanSinoDigits = // 〇일이삼사오육칠팔구 + { '〇', '일', '이', '삼', '사', '오', '육', '칠', '팔', '구' }; + private static readonly string[] KoreanNativeCounting = // 하나..열 + { "", "하나", "둘", "셋", "넷", "다섯", "여섯", "일곱", "여덟", "아홉", "열" }; + + /// Positional sino-korean digits: 1 → 일, 25 → 이오, 100 → 일〇〇. + private static string ToKoreanDigital(int n) + { + var s = n.ToString(CultureInfo.InvariantCulture); + var sb = new StringBuilder(s.Length); + foreach (var c in s) + sb.Append(c == '0' ? '〇' : KoreanSinoDigits[c - '0']); + return sb.ToString(); + } + + /// Native Korean counting 1..10, beyond that falls back to sino-korean digital. + private static string ToKoreanCounting(int n) + => n is >= 1 and <= 10 ? KoreanNativeCounting[n] : ToKoreanDigital(n); + + /// Korean legal (formal) numerals share the Chinese formal hanzi set. + private static string ToKoreanLegal(int n) + => ToChineseCounting(n, formal: true); + + /// Japanese legal uses modern formal kanji 壱弐参肆伍陸漆捌玖拾. + private static readonly char[] JpFormalDigits = + { '零', '壱', '弐', '参', '肆', '伍', '陸', '漆', '捌', '玖' }; + private static string ToJapaneseLegal(int n) + => BuildCjkPositional(n, JpFormalDigits, '拾', '佰', '仟', '萬'); + + // Thai & Devanagari ---------------------------------------------------- + + /// Positional Thai digits ๐๑๒...: 1 → ๑, 25 → ๒๕. + private static string ToThaiDigits(int n) + { + var s = n.ToString(CultureInfo.InvariantCulture); + var sb = new StringBuilder(s.Length); + foreach (var c in s) + sb.Append(c is >= '0' and <= '9' ? (char)('\u0E50' + (c - '0')) : c); + return sb.ToString(); + } + + // Thai consonants (44 letters), Word cycles after 44. + private static string ToThaiLetters(int n) + { + // U+0E01..U+0E2E are the 46 code points but ฃ (U+0E03) and ฅ (U+0E05) + // are obsolete; Word's enumeration skips them. + char[] letters = + { + '\u0E01','\u0E02','\u0E04','\u0E06','\u0E07','\u0E08','\u0E09','\u0E0A','\u0E0B', + '\u0E0C','\u0E0D','\u0E0E','\u0E0F','\u0E10','\u0E11','\u0E12','\u0E13','\u0E14', + '\u0E15','\u0E16','\u0E17','\u0E18','\u0E19','\u0E1A','\u0E1B','\u0E1C','\u0E1D', + '\u0E1E','\u0E1F','\u0E20','\u0E21','\u0E22','\u0E23','\u0E24','\u0E25','\u0E26', + '\u0E27','\u0E28','\u0E29','\u0E2A','\u0E2B','\u0E2C','\u0E2D','\u0E2E' + }; + return letters[(n - 1) % letters.Length].ToString(); + } + + /// Positional Devanagari digits ०१२...: 1 → १, 25 → २५. + private static string ToDevanagariDigits(int n) + { + var s = n.ToString(CultureInfo.InvariantCulture); + var sb = new StringBuilder(s.Length); + foreach (var c in s) + sb.Append(c is >= '0' and <= '9' ? (char)('\u0966' + (c - '0')) : c); + return sb.ToString(); + } + + // Devanagari consonants क, ख, ग, ... + private static string ToHindiLetters(int n) + { + char[] letters = + { + 'क','ख','ग','घ','ङ','च','छ','ज','झ','ञ', + 'ट','ठ','ड','ढ','ण','त','थ','द','ध','न', + 'प','फ','ब','भ','म','य','र','ल','व','श', + 'ष','स','ह' + }; + return letters[(n - 1) % letters.Length].ToString(); + } + + // Devanagari vowels अ, आ, इ, ... + private static string ToHindiVowels(int n) + { + char[] vowels = { 'अ','आ','इ','ई','उ','ऊ','ऋ','ए','ऐ','ओ','औ' }; + return vowels[(n - 1) % vowels.Length].ToString(); + } + + private static string ToRussianAlpha(int n, bool uppercase) + { + if (n < 1 || n > RussianAlphaLower.Length) + return n.ToString(CultureInfo.InvariantCulture); + var s = RussianAlphaLower[n - 1]; + return uppercase ? s.ToUpperInvariant() : s; + } +} diff --git a/src/officecli/Core/WordStrictAttributeSanitizer.cs b/src/officecli/Core/WordStrictAttributeSanitizer.cs new file mode 100644 index 000000000..ad74e3a8d --- /dev/null +++ b/src/officecli/Core/WordStrictAttributeSanitizer.cs @@ -0,0 +1,131 @@ +// Copyright 2025 OfficeCli (officecli.ai) +// SPDX-License-Identifier: Apache-2.0 + +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; + +namespace OfficeCli.Core; + +// Real-world docx files from legacy editors (WPS, older Word, third-party tools) +// sometimes carry attribute values that violate the OOXML schema — e.g. +// `` or ``. Native Word is lenient, +// but DocumentFormat.OpenXml throws FormatException the moment any reader +// accesses `.Val.Value` on the typed property. Since the crash is lazy, it +// surfaces unpredictably deep inside rendering code (HtmlPreview.Css, +// styling, etc.) rather than at open time. +// +// This sanitizer walks raw XML attributes (no typed conversion) right after +// Open, repairs or strips the offending values, and lets every downstream +// reader operate normally. Corresponds to KNOWN_ISSUES §9. +internal static class WordStrictAttributeSanitizer +{ + private const string W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + + private static readonly HashSet OnOffValid = new(StringComparer.OrdinalIgnoreCase) + { "true", "false", "on", "off", "0", "1" }; + + // Elements whose `w:val` attribute is an OnOff. Invalid values → strip val + // (the element's mere presence means "true", matching Word's behavior). + private static readonly HashSet OnOffElements = new(StringComparer.Ordinal) + { + "b", "bCs", "i", "iCs", "caps", "smallCaps", "strike", "dstrike", + "vanish", "specVanish", "webHidden", "noProof", + "emboss", "imprint", "outline", "shadow", "snapToGrid", + "contextualSpacing", "kinsoku", "overflowPunct", "topLinePunct", + "autoSpaceDE", "autoSpaceDN", "wordWrap", + "suppressAutoHyphens", "suppressLineNumbers", "suppressOverlap", + "widowControl", "keepNext", "keepLines", "pageBreakBefore", + "hidden", "cantSplit", "tblHeader", + "bookFoldPrinting", "bookFoldRevPrinting", + "evenAndOddHeaders", "titlePg", + }; + + // Elements whose `w:val` is an enum. Invalid values → strip the whole + // element (default behavior of the parent kicks in). + private static readonly Dictionary> EnumElements = new(StringComparer.Ordinal) + { + ["jc"] = new(StringComparer.Ordinal) + { + "left", "center", "right", "both", "start", "end", + "distribute", "mediumKashida", "lowKashida", "highKashida", + "thaiDistribute", "numTab", + }, + ["vAlign"] = new(StringComparer.Ordinal) { "top", "center", "bottom", "both" }, + ["textDirection"] = new(StringComparer.Ordinal) + { "lrTb", "tbRl", "btLr", "lrTbV", "tbRlV", "tbLrV", "rl", "lr" }, + }; + + public static void Sanitize(WordprocessingDocument doc) + { + var main = doc.MainDocumentPart; + if (main == null) return; + + // Wrap each part access: `main.Document` getter throws if the file + // isn't actually WordML (e.g. xlsx opened as docx). Existing tests + // document that WordHandler silently tolerates wrong-format opens, + // so we mirror that by skipping parts we can't load. + TrySanitize(() => main.Document); + TrySanitize(() => main.StyleDefinitionsPart?.Styles); + TrySanitize(() => main.NumberingDefinitionsPart?.Numbering); + TrySanitize(() => main.FootnotesPart?.Footnotes); + TrySanitize(() => main.EndnotesPart?.Endnotes); + TrySanitize(() => main.DocumentSettingsPart?.Settings); + foreach (var h in main.HeaderParts) TrySanitize(() => h.Header); + foreach (var f in main.FooterParts) TrySanitize(() => f.Footer); + } + + private static void TrySanitize(Func getRoot) + { + OpenXmlPartRootElement? root; + try { root = getRoot(); } + catch { return; } + if (root != null) SanitizePart(root); + } + + private static void SanitizePart(OpenXmlPartRootElement root) + { + // Snapshot first — we may mutate (remove elements) during sanitize. + var nodes = root.Descendants().ToList(); + var toRemove = new List(); + + foreach (var elem in nodes) + { + if (elem.NamespaceUri != W) continue; + var name = elem.LocalName; + + if (OnOffElements.Contains(name)) + { + var raw = ReadValAttribute(elem); + if (raw != null && !OnOffValid.Contains(raw)) + { + // Strip val — bare element = true, matching Word's + // lenient handling of ``. + elem.RemoveAttribute("val", W); + } + } + else if (EnumElements.TryGetValue(name, out var valid)) + { + var raw = ReadValAttribute(elem); + if (raw != null && !valid.Contains(raw)) + { + toRemove.Add(elem); + } + } + } + + foreach (var elem in toRemove) + { + elem.Parent?.RemoveChild(elem); + } + } + + private static string? ReadValAttribute(OpenXmlElement elem) + { + foreach (var a in elem.GetAttributes()) + { + if (a.LocalName == "val" && (a.NamespaceUri == W || a.NamespaceUri == "")) + return a.Value; + } + return null; + } +} diff --git a/src/officecli/Core/DocumentHandlerFactory.cs b/src/officecli/Handlers/DocumentHandlerFactory.cs similarity index 98% rename from src/officecli/Core/DocumentHandlerFactory.cs rename to src/officecli/Handlers/DocumentHandlerFactory.cs index 1c6cbcd8d..1b5a50fd4 100644 --- a/src/officecli/Core/DocumentHandlerFactory.cs +++ b/src/officecli/Handlers/DocumentHandlerFactory.cs @@ -4,9 +4,9 @@ using System.IO.Compression; using System.Text; using System.Text.RegularExpressions; -using OfficeCli.Handlers; +using OfficeCli.Core; -namespace OfficeCli.Core; +namespace OfficeCli.Handlers; public static class DocumentHandlerFactory { diff --git a/src/officecli/Handlers/Excel/ExcelDataFormatter.cs b/src/officecli/Handlers/Excel/ExcelDataFormatter.cs new file mode 100644 index 000000000..32d3ac9c5 --- /dev/null +++ b/src/officecli/Handlers/Excel/ExcelDataFormatter.cs @@ -0,0 +1,169 @@ +// Copyright 2025 OfficeCli (officecli.ai) +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.RegularExpressions; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; + +namespace OfficeCli.Handlers; + +/// +/// Applies Excel number format codes to raw cell values, producing display strings. +/// Mirrors Apache POI's DataFormatter — raw double + numFmtId + formatCode → display string. +/// +internal static class ExcelDataFormatter +{ + // Built-in Excel number format IDs that are date/time formats (ECMA-376 18.8.30) + private static readonly HashSet BuiltInDateFormatIds = new() + { 14, 15, 16, 17, 18, 19, 20, 21, 22, 45, 46, 47 }; + + // Built-in format codes by ID + private static readonly Dictionary BuiltInFormats = new() + { + [0] = "General", + [1] = "0", + [2] = "0.00", + [3] = "#,##0", + [4] = "#,##0.00", + [9] = "0%", + [10] = "0.00%", + [11] = "0.00E+00", + [12] = "# ?/?", + [13] = "# ??/??", + [14] = "m/d/yy", + [15] = "d-mmm-yy", + [16] = "d-mmm", + [17] = "mmm-yy", + [18] = "h:mm AM/PM", + [19] = "h:mm:ss AM/PM", + [20] = "h:mm", + [21] = "h:mm:ss", + [22] = "m/d/yy h:mm", + [37] = "#,##0 ;(#,##0)", + [38] = "#,##0 ;[Red](#,##0)", + [39] = "#,##0.00;(#,##0.00)", + [40] = "#,##0.00;[Red](#,##0.00)", + [45] = "mm:ss", + [46] = "[h]:mm:ss", + [47] = "mmss.0", + [48] = "##0.0E+0", + [49] = "@", + }; + + // Regex to detect date tokens in a format code (after stripping quoted strings and brackets) + private static readonly Regex DateTokenRegex = new(@"[yYdD]|(? + /// Format a raw numeric cell value using its number format. + /// Returns null if no formatting is needed (raw value is fine as-is). + /// + public static string? TryFormat(double value, uint numFmtId, string? customFormatCode) + { + var formatCode = customFormatCode ?? (BuiltInFormats.TryGetValue(numFmtId, out var b) ? b : null); + + if (IsDateFormat(numFmtId, formatCode)) + return FormatDate(value, formatCode); + + if (IsPercentFormat(formatCode)) + return FormatPercent(value, formatCode!); + + return null; // let caller fall back to raw value + } + + /// + /// Look up a cell's numFmtId and custom format code from the workbook stylesheet. + /// Returns (0, null) if no style is applied. + /// + public static (uint numFmtId, string? formatCode) GetCellFormat(Cell cell, WorkbookPart? wbPart) + { + if (wbPart?.WorkbookStylesPart?.Stylesheet == null) + return (0, null); + + var styleIndex = cell.StyleIndex?.Value ?? 0; + var cellFormats = wbPart.WorkbookStylesPart.Stylesheet.CellFormats; + if (cellFormats == null) return (0, null); + + var xfList = cellFormats.Elements().ToList(); + if (styleIndex >= (uint)xfList.Count) return (0, null); + + var xf = xfList[(int)styleIndex]; + var numFmtId = xf.NumberFormatId?.Value ?? 0; + if (numFmtId == 0) return (0, null); + + // Look up custom format code if not built-in + string? formatCode = null; + var numFmts = wbPart.WorkbookStylesPart.Stylesheet.NumberingFormats; + if (numFmts != null) + { + formatCode = numFmts.Elements() + .FirstOrDefault(nf => nf.NumberFormatId?.Value == numFmtId) + ?.FormatCode?.Value; + } + + return (numFmtId, formatCode); + } + + private static bool IsDateFormat(uint numFmtId, string? formatCode) + { + if (BuiltInDateFormatIds.Contains(numFmtId)) return true; + if (formatCode == null) return false; + + // Strip quoted strings and bracket codes before scanning for date tokens + var stripped = Regex.Replace(formatCode, "\"[^\"]*\"", ""); + stripped = BracketCodeRegex.Replace(stripped, ""); + + return DateTokenRegex.IsMatch(stripped); + } + + private static bool IsPercentFormat(string? formatCode) + { + if (formatCode == null) return false; + var stripped = Regex.Replace(formatCode, "\"[^\"]*\"", ""); + return stripped.Contains('%'); + } + + private static string FormatDate(double value, string? formatCode) + { + try + { + var dt = DateTime.FromOADate(value); + + // Detect whether time component is significant + bool hasTime = false; + if (formatCode != null) + { + var stripped = Regex.Replace(formatCode, "\"[^\"]*\"", ""); + stripped = BracketCodeRegex.Replace(stripped, ""); + hasTime = TimeTokenRegex.IsMatch(stripped); + } + + if (hasTime) + { + // If fractional seconds are zero, omit them + return dt.Second == 0 && dt.Millisecond == 0 + ? dt.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture) + : dt.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture); + } + + return dt.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture); + } + catch + { + return value.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + } + + private static string FormatPercent(double value, string formatCode) + { + // Count decimal places from format code (e.g. "0.00%" → 2) + var match = Regex.Match(formatCode, @"0\.(0+)%"); + int decimals = match.Success ? match.Groups[1].Value.Length : 0; + return (value * 100).ToString($"F{decimals}", System.Globalization.CultureInfo.InvariantCulture) + "%"; + } +} diff --git a/src/officecli/Handlers/Excel/ExcelHandler.Add.cs b/src/officecli/Handlers/Excel/ExcelHandler.Add.cs index 016351342..26c39246e 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); @@ -106,6 +107,8 @@ public string Add(string parentPath, string type, int? index, Dictionary() ?? GetSheet(cellWorksheet).AppendChild(new SheetData()); + // R7-1: if path tail is a cell-ref (e.g. /Sheet1/Z99), treat it + // as the target address — equivalent to --prop ref=Z99. Parity + // with the `comment` case below which already does this. + string? cellRefFromPath = null; + if (cellSegments.Length > 1 && Regex.IsMatch(cellSegments[1], @"^[A-Z]+\d+$", RegexOptions.IgnoreCase)) + cellRefFromPath = cellSegments[1].ToUpperInvariant(); + string cellRef; if (properties.ContainsKey("ref")) { cellRef = properties["ref"]; + if (cellRefFromPath != null && !cellRefFromPath.Equals(cellRef, StringComparison.OrdinalIgnoreCase)) + Console.Error.WriteLine($"warning: path tail '{cellRefFromPath}' does not match --prop ref='{cellRef}'; using ref='{cellRef}'."); } else if (properties.ContainsKey("address")) { cellRef = properties["address"]; + if (cellRefFromPath != null && !cellRefFromPath.Equals(cellRef, StringComparison.OrdinalIgnoreCase)) + Console.Error.WriteLine($"warning: path tail '{cellRefFromPath}' does not match --prop address='{cellRef}'; using address='{cellRef}'."); + } + else if (cellRefFromPath != null) + { + cellRef = cellRefFromPath; } else { @@ -142,15 +160,55 @@ public string Add(string parentPath, string type, int? index, Dictionary(CellValues.String); + // R13-1: reject values longer than Excel's 32767-char limit + // before doing any conversion/serialization. + EnsureCellValueLength(value, cellRef); + // R13-3: if both value= and formula= are supplied, formula wins + // (established precedence — formula is written after value) but + // the discarded value is easy to miss. Warn on stderr. + if (properties.ContainsKey("formula")) + { + Console.Error.WriteLine( + "Warning: Both value= and formula= supplied — using formula, value ignored."); + } + // Auto-detect formula: value starting with '=' is treated as formula + if (value.StartsWith('=') && value.Length > 1) + { + cell.CellFormula = new CellFormula(Core.ModernFunctionQualifier.Qualify(value.TrimStart('='))); + cell.CellValue = null; + } + else + { + // CONSISTENCY(formula-stale): writing a literal value must + // clear any prior CellFormula on the same cell. Otherwise + // the old formula re-evaluates on open / in html preview + // and overrides the literal the caller just set. + cell.CellFormula = null; + // R2-2: strip XML-illegal chars (e.g. U+0000) from the cell + // value before it gets serialized to sheet1.xml. Without + // this, a NUL byte from upstream data would crash every + // downstream save (including the pivot cache write). + var safeValue = OfficeCli.Core.PivotTableHelper.SanitizeXmlText(value); + cell.CellValue = new CellValue(safeValue); + if (!double.TryParse(safeValue, out _)) + cell.DataType = new EnumValue(CellValues.String); + } } if (properties.TryGetValue("formula", out var formula)) { - cell.CellFormula = new CellFormula(formula.TrimStart('=')); + // Strip a leading '=' (formula-bar copy) and reject + // literal `{...}` array-formula wrapping — users must use + // the dedicated `arrayformula=` prop for that, since + // `{=...}` causes Excel to reject the file. + var fTrim = formula.TrimStart('=').Trim(); + if (fTrim.StartsWith("{") && fTrim.EndsWith("}")) + throw new ArgumentException("Literal braces '{...}' around a formula create an Excel-rejected file. Use --prop arrayformula=... (without braces) to declare a CSE array formula."); + cell.CellFormula = new CellFormula(Core.ModernFunctionQualifier.Qualify(fTrim)); cell.CellValue = null; } + // CE1: allow `runs=` without an explicit `type=richtext`. + if (!properties.ContainsKey("type") && properties.ContainsKey("runs")) + properties["type"] = "richtext"; if (properties.TryGetValue("type", out var cellType)) { if (cellType.Equals("richtext", StringComparison.OrdinalIgnoreCase) || @@ -171,37 +229,89 @@ public string Add(string parentPath, string type, int? index, Dictionary k.StartsWith("run", StringComparison.OrdinalIgnoreCase) && k.Length > 3 && - int.TryParse(k.AsSpan(3), out _)) - .OrderBy(k => int.Parse(k.AsSpan(3).ToString())) - .ToList(); - foreach (var runKey in runKeys) + + // Gather runs from either: (a) runs= or + // (b) legacy run1=, run2=, ... mini-spec syntax. + // CE1 fix: `runs=[{"text":"Hello","bold":true,...},...]` + // is now the preferred, documented form. + var gatheredRuns = new List<(string text, Dictionary props)>(); + if (properties.TryGetValue("runs", out var runsJson) && !string.IsNullOrWhiteSpace(runsJson)) { - var runVal = properties[runKey]; - // Format: "text:prop=val;prop=val" or just "text" - var colonIdx = runVal.IndexOf(':'); - string runText; - string[] runProps; - if (colonIdx >= 0) + try + { + using var jdoc = System.Text.Json.JsonDocument.Parse(runsJson); + if (jdoc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Array) + throw new ArgumentException("'runs' must be a JSON array of run objects."); + foreach (var el in jdoc.RootElement.EnumerateArray()) + { + if (el.ValueKind != System.Text.Json.JsonValueKind.Object) + throw new ArgumentException("Each run in 'runs' must be a JSON object."); + string text = ""; + var pd = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var p in el.EnumerateObject()) + { + var sv = p.Value.ValueKind switch + { + System.Text.Json.JsonValueKind.True => "true", + System.Text.Json.JsonValueKind.False => "false", + System.Text.Json.JsonValueKind.Null => "", + System.Text.Json.JsonValueKind.Number => p.Value.GetRawText(), + _ => p.Value.GetString() ?? "" + }; + if (p.NameEquals("text")) text = sv; + else pd[p.Name] = sv; + } + gatheredRuns.Add((text, pd)); + } + } + catch (System.Text.Json.JsonException jex) { - runText = runVal[..colonIdx]; - runProps = runVal[(colonIdx + 1)..].Split(';'); + throw new ArgumentException($"Invalid JSON for 'runs': {jex.Message}"); } - else + } + else + { + // Legacy path: run1=text:prop=val;prop=val, run2=... + var runKeys = properties.Keys + .Where(k => k.StartsWith("run", StringComparison.OrdinalIgnoreCase) && k.Length > 3 && + int.TryParse(k.AsSpan(3), out _)) + .OrderBy(k => int.Parse(k.AsSpan(3).ToString())) + .ToList(); + foreach (var runKey in runKeys) { - runText = runVal; - runProps = []; + var runVal = properties[runKey]; + var colonIdx = runVal.IndexOf(':'); + string runText; + string[] runProps; + if (colonIdx >= 0) + { + runText = runVal[..colonIdx]; + runProps = runVal[(colonIdx + 1)..].Split(';'); + } + else + { + runText = runVal; + runProps = []; + } + var pd = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var prop in runProps) + { + var eqIdx = prop.IndexOf('='); + if (eqIdx < 0) continue; + pd[prop[..eqIdx].Trim()] = prop[(eqIdx + 1)..].Trim(); + } + gatheredRuns.Add((runText, pd)); } + } + + foreach (var (runText, pd) in gatheredRuns) + { var run = new Run(); var rp = new RunProperties(); - foreach (var prop in runProps) + foreach (var kv in pd) { - var eqIdx = prop.IndexOf('='); - if (eqIdx < 0) continue; - var pKey = prop[..eqIdx].Trim().ToLowerInvariant(); - var pVal = prop[(eqIdx + 1)..].Trim(); + var pKey = kv.Key.ToLowerInvariant(); + var pVal = kv.Value; switch (pKey) { case "bold" when ParseHelpers.IsTruthy(pVal): rp.AppendChild(new Bold()); break; @@ -214,6 +324,12 @@ 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, + // CE16 — accept `type=error value="#N/A"|"#DIV/0!"|...` → + // emits #N/A. Standard + // Excel error tokens: #N/A, #DIV/0!, #REF!, #NAME?, + // #NULL!, #NUM!, #VALUE!, #GETTING_DATA. + "error" or "err" => new EnumValue(CellValues.Error), + _ => throw new ArgumentException($"Invalid cell 'type' value '{cellType}'. Valid types: string, number, boolean, date, error, richtext.") }; // Convert boolean string values to OOXML-compliant 1/0 if (cellType.Equals("boolean", StringComparison.OrdinalIgnoreCase) || cellType.Equals("bool", StringComparison.OrdinalIgnoreCase)) @@ -268,6 +395,29 @@ public string Add(string parentPath, string type, int? index, Dictionary that corrupts the file. + var refVal = properties.GetValueOrDefault("ref", + properties.GetValueOrDefault("refersTo", + properties.GetValueOrDefault("formula", ""))); + // R7-2: per ECMA-376 §18.2.5, content must NOT + // have a leading '=' (unlike the formula-bar form in Excel UI). + // Excel rejects the file with 0x800A03EC if '=' is present. + if (refVal.StartsWith('=')) + refVal = refVal.TrimStart('='); var workbook = GetWorkbook(); var definedNames = workbook.GetFirstChild(); @@ -367,6 +580,28 @@ public string Add(string parentPath, string type, int? index, Dictionary(); + if (calcPr == null) + { + calcPr = new CalculationProperties(); + var insertBefore = (DocumentFormat.OpenXml.OpenXmlElement?)workbook.GetFirstChild() + ?? (DocumentFormat.OpenXml.OpenXmlElement?)workbook.GetFirstChild() + ?? (DocumentFormat.OpenXml.OpenXmlElement?)workbook.GetFirstChild(); + if (insertBefore != null) + workbook.InsertBefore(calcPr, insertBefore); + else + workbook.AppendChild(calcPr); + } + calcPr.FullCalculationOnLoad = true; + } + workbook.Save(); var nrIdx = definedNames.Elements().ToList().IndexOf(dn) + 1; @@ -404,6 +639,15 @@ public string Add(string parentPath, string type, int? index, Dictionary()!; var commentList = comments.GetFirstChild()!; + // CONSISTENCY(overlap-reject): duplicate comment on the same + // cell is ambiguous — mirror the table T4 overlap-reject + // pattern. User must `remove comment` first to replace it. + var cmtRefUpper = cmtRef.ToUpperInvariant(); + if (commentList.Elements().Any(c => + string.Equals(c.Reference?.Value, cmtRefUpper, StringComparison.OrdinalIgnoreCase))) + throw new ArgumentException( + $"comment already exists on {cmtRefUpper}. Remove it first before adding a new comment."); + uint authorId = 0; var existingAuthors = authors.Elements().ToList(); var authorIdx = existingAuthors.FindIndex(a => a.Text == cmtAuthor); @@ -416,11 +660,15 @@ public string Add(string parentPath, string type, int? index, Dictionary(properties, StringComparer.OrdinalIgnoreCase); @@ -516,6 +761,28 @@ public string Add(string parentPath, string type, int? index, Dictionary DataValidationErrorStyleValues.Stop, + "warning" or "warn" => DataValidationErrorStyleValues.Warning, + "information" or "info" => DataValidationErrorStyleValues.Information, + _ => throw new ArgumentException( + $"Unknown errorStyle: {dvErrStyle}. Use: stop, warning, information") + }; + } + + // V7 — showDropDown / inCellDropdown. OOXML `showDropDown` + // has INVERTED semantics: true = HIDE the in-cell arrow. + // Expose it as `inCellDropdown` (user-friendly sense) and + // the raw `showDropDown` (OOXML sense). + if (dvProps.TryGetValue("inCellDropdown", out var dvInCell)) + dv.ShowDropDown = !ParseHelpers.IsTruthy(dvInCell); + else if (dvProps.TryGetValue("showDropDown", out var dvShowDd)) + dv.ShowDropDown = ParseHelpers.IsTruthy(dvShowDd); + var wsEl = GetSheet(dvWorksheet); var dvs = wsEl.GetFirstChild(); if (dvs == null) @@ -550,6 +817,14 @@ public string Add(string parentPath, string type, int? index, Dictionary. + if (!Regex.IsMatch(afRange.Trim(), + @"^\$?[A-Z]+\$?\d+(?::\$?[A-Z]+\$?\d+)?$", + RegexOptions.IgnoreCase)) + throw new ArgumentException( + $"Invalid 'range' value: '{afRange}'. Expected a cell range like 'A1:F100' or 'A1'."); + var wsElement = GetSheet(afWorksheet); var autoFilter = wsElement.GetFirstChild(); if (autoFilter == null) @@ -567,45 +842,231 @@ public string Add(string parentPath, string type, int? index, Dictionary. + // Previous criteria for the same N are replaced. + var criteriaGroups = new Dictionary>(); + foreach (var (k, v) in properties) + { + var cm = Regex.Match(k, @"^criteria(\d+)\.([A-Za-z]+)$"); + if (!cm.Success) continue; + var colId = uint.Parse(cm.Groups[1].Value); + var op = cm.Groups[2].Value.ToLowerInvariant(); + if (!criteriaGroups.TryGetValue(colId, out var list)) + criteriaGroups[colId] = list = new List<(string, string)>(); + list.Add((op, v)); + } + // Strip any prior filterColumn entries so a re-Add is idempotent + foreach (var fc in autoFilter.Elements().ToList()) + fc.Remove(); + foreach (var (colId, entries) in criteriaGroups.OrderBy(kv => kv.Key)) + { + var filterColumn = new FilterColumn { ColumnId = colId }; + // Dispatch by operator family. Top-N, Blanks, value-list, + // and dynamicFilter build dedicated child elements; + // text/number ops feed into . + var customEntries = new List<(FilterOperatorValues fop, string val)>(); + bool customFilterAnd = false; + bool handledDedicated = false; + foreach (var (op, rawVal) in entries) + { + switch (op) + { + case "equals": + customEntries.Add((FilterOperatorValues.Equal, rawVal)); + break; + case "notequals": + customEntries.Add((FilterOperatorValues.NotEqual, rawVal)); + break; + case "contains": + { + var wild = rawVal.Contains('*') ? rawVal : $"*{rawVal}*"; + customEntries.Add((FilterOperatorValues.Equal, wild)); + break; + } + case "doesnotcontain": + { + var wild = rawVal.Contains('*') ? rawVal : $"*{rawVal}*"; + customEntries.Add((FilterOperatorValues.NotEqual, wild)); + break; + } + case "beginswith": + { + var wild = rawVal.EndsWith("*") ? rawVal : $"{rawVal}*"; + customEntries.Add((FilterOperatorValues.Equal, wild)); + break; + } + case "endswith": + { + var wild = rawVal.StartsWith("*") ? rawVal : $"*{rawVal}"; + customEntries.Add((FilterOperatorValues.Equal, wild)); + break; + } + case "gt": + customEntries.Add((FilterOperatorValues.GreaterThan, rawVal)); + break; + case "gte": + customEntries.Add((FilterOperatorValues.GreaterThanOrEqual, rawVal)); + break; + case "lt": + customEntries.Add((FilterOperatorValues.LessThan, rawVal)); + break; + case "lte": + customEntries.Add((FilterOperatorValues.LessThanOrEqual, rawVal)); + break; + case "between": + case "notbetween": + { + var parts = rawVal.Split(','); + if (parts.Length != 2) + throw new ArgumentException( + $"criteria{colId}.{op} requires 'lo,hi', got: '{rawVal}'"); + var lo = parts[0].Trim(); + var hi = parts[1].Trim(); + if (op == "between") + { + customEntries.Add((FilterOperatorValues.GreaterThanOrEqual, lo)); + customEntries.Add((FilterOperatorValues.LessThanOrEqual, hi)); + customFilterAnd = true; + } + else + { + // notBetween = lt lo OR gt hi (Excel default OR) + customEntries.Add((FilterOperatorValues.LessThan, lo)); + customEntries.Add((FilterOperatorValues.GreaterThan, hi)); + } + break; + } + case "top": + case "toppercent": + case "bottom": + case "bottompercent": + { + if (!double.TryParse(rawVal, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var topN)) + throw new ArgumentException( + $"criteria{colId}.{op} requires a numeric value, got: '{rawVal}'"); + filterColumn.Top10 = new Top10 + { + Top = op == "top" || op == "toppercent", + Percent = op == "toppercent" || op == "bottompercent", + Val = topN + }; + handledDedicated = true; + break; + } + case "blanks": + if (IsTruthy(rawVal)) + { + filterColumn.Filters = new Filters { Blank = true }; + handledDedicated = true; + } + break; + case "nonblanks": + if (IsTruthy(rawVal)) + { + customEntries.Add((FilterOperatorValues.NotEqual, "")); + } + break; + case "values": + { + // Discrete value-list filter: comma-separated + // (split+trim empty; escape \, not supported). + var vals = rawVal.Split(',') + .Select(s => s.Trim()) + .Where(s => s.Length > 0) + .ToList(); + var filters = filterColumn.Filters ?? (filterColumn.Filters = new Filters()); + foreach (var v in vals) + filters.AppendChild(new Filter { Val = v }); + handledDedicated = true; + break; + } + case "dynamic": + { + var dyn = new DynamicFilter + { + Type = new EnumValue(new DynamicFilterValues(rawVal)) + }; + filterColumn.DynamicFilter = dyn; + handledDedicated = true; + break; + } + default: + throw new ArgumentException( + $"Unsupported criteria operator: '{op}'. Valid: equals, notEquals, contains, doesNotContain, beginsWith, endsWith, gt, gte, lt, lte, between, notBetween, top, topPercent, bottom, bottomPercent, blanks, nonBlanks, values, dynamic."); + } + } + if (customEntries.Count > 0 && !handledDedicated) + { + var cf = new CustomFilters(); + if (customFilterAnd) + cf.And = true; + foreach (var (fop, val) in customEntries) + cf.AppendChild(new CustomFilter + { + Operator = fop, + Val = val + }); + filterColumn.CustomFilters = cf; + } + autoFilter.AppendChild(filterColumn); + } + SaveWorksheet(afWorksheet); return $"/{afSheetName}/autofilter"; } case "cf": { - // Dispatch to specific CF type based on "type" property - var cfType = properties.GetValueOrDefault("type", "databar").ToLowerInvariant(); + // Dispatch to specific CF type based on "type" (primary) or "rule" (alias) property. + // R2-2: `rule=cellIs` is also accepted — user expectation from real Excel vocabulary + // (Excel calls these "rules", OOXML calls them cfRule "type"). + var cfType = (properties.GetValueOrDefault("type") + ?? properties.GetValueOrDefault("rule") + ?? "databar").ToLowerInvariant(); return cfType switch { - "iconset" => 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" or "expression" => Add(parentPath, "formulacf", position, properties), + "cellis" => Add(parentPath, "cellis", 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), + "belowaverage" or "containsblanks" or "notcontainsblanks" or "containserrors" or "notcontainserrors" or "contains" or "notcontains" or "beginswith" or "endswith" + => Add(parentPath, "cfextended", position, properties), + _ => Add(parentPath, "conditionalformatting", position, properties) }; } case "databar": case "conditionalformatting": { - // Dispatch to specific CF type if "type" property is specified - if (properties.TryGetValue("type", out var cfTypeVal)) - { - var cfTypeLower = cfTypeVal.ToLowerInvariant(); - if (cfTypeLower is "iconset") return Add(parentPath, "iconset", index, properties); - if (cfTypeLower is "colorscale") return Add(parentPath, "colorscale", index, properties); - if (cfTypeLower is "formula") return Add(parentPath, "formulacf", index, properties); - if (cfTypeLower is "topn" or "top10") return Add(parentPath, "topn", index, properties); - if (cfTypeLower is "aboveaverage") return Add(parentPath, "aboveaverage", index, properties); - if (cfTypeLower is "uniquevalues") return Add(parentPath, "uniquevalues", index, properties); - if (cfTypeLower is "duplicatevalues") return Add(parentPath, "duplicatevalues", index, properties); - if (cfTypeLower is "containstext") return Add(parentPath, "containstext", index, properties); - if (cfTypeLower is "dateoccurring" or "timeperiod") return Add(parentPath, "dateoccurring", index, properties); + // Dispatch to specific CF type if "type" or "rule" property is specified. + // R2-2: `rule=` is an accepted alias for `type=` (matches Excel UI vocabulary). + var cfTypeProp = properties.GetValueOrDefault("type") ?? properties.GetValueOrDefault("rule"); + if (cfTypeProp != null) + { + var cfTypeLower = cfTypeProp.ToLowerInvariant(); + if (cfTypeLower is "iconset") return Add(parentPath, "iconset", position, properties); + if (cfTypeLower is "colorscale") return Add(parentPath, "colorscale", position, properties); + if (cfTypeLower is "formula" or "expression") return Add(parentPath, "formulacf", position, properties); + if (cfTypeLower is "cellis") return Add(parentPath, "cellis", position, properties); + if (cfTypeLower is "topn" or "top10") return Add(parentPath, "topn", position, properties); + if (cfTypeLower is "aboveaverage") return Add(parentPath, "aboveaverage", position, properties); + if (cfTypeLower is "uniquevalues") return Add(parentPath, "uniquevalues", position, properties); + if (cfTypeLower is "duplicatevalues") return Add(parentPath, "duplicatevalues", position, properties); + if (cfTypeLower is "containstext") return Add(parentPath, "containstext", position, properties); + if (cfTypeLower is "dateoccurring" or "timeperiod") return Add(parentPath, "dateoccurring", position, properties); + if (cfTypeLower is "belowaverage" or "containsblanks" or "notcontainsblanks" or "containserrors" or "notcontainserrors" or "contains" or "notcontains" or "beginswith" or "endswith") + return Add(parentPath, "cfextended", position, properties); } var cfSegments = parentPath.TrimStart('/').Split('/', 2); var cfSheetName = cfSegments[0]; @@ -624,18 +1085,44 @@ public string Add(string parentPath, string type, int? index, Dictionary X14.DataBarAxisPositionValues.Middle, + "none" => X14.DataBarAxisPositionValues.None, + _ => X14.DataBarAxisPositionValues.Automatic + }; + + var x14DataBar = new X14.DataBar + { + MinLength = 0U, + MaxLength = 100U, + AxisPosition = dbAxisPosVal + }; + var x14MinCfvo = new X14.ConditionalFormattingValueObject + { + Type = minVal != null + ? X14.ConditionalFormattingValueObjectTypeValues.Numeric + : X14.ConditionalFormattingValueObjectTypeValues.AutoMin + }; + if (minVal != null) x14MinCfvo.Append(new DocumentFormat.OpenXml.Office.Excel.Formula(minVal)); + x14DataBar.Append(x14MinCfvo); + var x14MaxCfvo = new X14.ConditionalFormattingValueObject + { + Type = maxVal != null + ? X14.ConditionalFormattingValueObjectTypeValues.Numeric + : X14.ConditionalFormattingValueObjectTypeValues.AutoMax + }; + if (maxVal != null) x14MaxCfvo.Append(new DocumentFormat.OpenXml.Office.Excel.Formula(maxVal)); + x14DataBar.Append(x14MaxCfvo); + x14DataBar.Append(new X14.FillColor { Rgb = normalizedColor }); + x14DataBar.Append(new X14.NegativeFillColor { Rgb = dbNegColor }); + x14DataBar.Append(new X14.BarAxisColor { Rgb = dbAxisColor }); + + var x14CfRule = new X14.ConditionalFormattingRule + { + Type = ConditionalFormatValues.DataBar, + Id = dbGuid + }; + x14CfRule.Append(x14DataBar); + + var x14Cf = new X14.ConditionalFormatting(); + x14Cf.AddNamespaceDeclaration("xm", "http://schemas.microsoft.com/office/excel/2006/main"); + x14Cf.Append(x14CfRule); + x14Cf.Append(new DocumentFormat.OpenXml.Office.Excel.ReferenceSequence(sqref)); + + EnsureWorksheetX14ConditionalFormatting(wsElement, x14Cf); + SaveWorksheet(cfWorksheet); var dbCfCount = wsElement.Elements().Count(); return $"/{cfSheetName}/cf[{dbCfCount}]"; @@ -666,10 +1204,14 @@ public string Add(string parentPath, string type, int? index, Dictionary100)"); - // Build DifferentialFormat (dxf) for the formatting + // Build DifferentialFormat (dxf) for the formatting. + // A dxf Font may carry: b, i, u, strike, sz, rFont, color. + // All sub-props are threaded together so users can combine + // (e.g. bold + italic + underline + custom size + name). var dxf = new DifferentialFormat(); - if (properties.TryGetValue("font.color", out var fontColor)) - { - var normalizedFontColor = ParseHelpers.NormalizeArgbColor(fontColor); - dxf.Append(new Font(new DocumentFormat.OpenXml.Spreadsheet.Color { Rgb = normalizedFontColor })); - } - else if (properties.TryGetValue("font.bold", out var fontBold) && IsTruthy(fontBold)) - { - dxf.Append(new Font(new Bold())); - } + var dxfFont = BuildFormulaCfFont(properties); + if (dxfFont != null) dxf.Append(dxfFont); if (properties.TryGetValue("fill", out var fillColor)) { @@ -788,13 +1328,6 @@ public string Add(string parentPath, string type, int? index, Dictionary(); - existingFont?.Append(new Bold()); - } - // Add dxf to stylesheet (ensure it exists) var fcfWbPart = _doc.WorkbookPart ?? throw new InvalidOperationException("Workbook not found"); @@ -810,7 +1343,7 @@ public string Add(string parentPath, string type, int? index, Dictionary().Count(); - stylesheet.Save(); + _dirtyStylesheet = true; var dxfId = dxfs.Count!.Value - 1; @@ -821,6 +1354,7 @@ public string Add(string parentPath, string type, int? index, Dictionary" => ConditionalFormattingOperatorValues.GreaterThan, + "lessthan" or "lt" or "<" => ConditionalFormattingOperatorValues.LessThan, + "greaterthanorequal" or "gte" or ">=" => ConditionalFormattingOperatorValues.GreaterThanOrEqual, + "lessthanorequal" or "lte" or "<=" => ConditionalFormattingOperatorValues.LessThanOrEqual, + "equal" or "eq" or "=" or "==" => ConditionalFormattingOperatorValues.Equal, + "notequal" or "ne" or "!=" or "<>" => ConditionalFormattingOperatorValues.NotEqual, + "between" => ConditionalFormattingOperatorValues.Between, + "notbetween" => ConditionalFormattingOperatorValues.NotBetween, + _ => throw new ArgumentException( + $"Unsupported cellIs operator '{opStr}'. Valid: greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual, equal, notEqual, between, notBetween.") + }; + + var primary = properties.GetValueOrDefault("value") + ?? properties.GetValueOrDefault("formula") + ?? properties.GetValueOrDefault("value1") + ?? throw new ArgumentException("cellIs conditional formatting requires 'value' property (e.g. value=50)."); + var secondary = properties.GetValueOrDefault("value2") + ?? properties.GetValueOrDefault("maxvalue"); + + // Build DifferentialFormat (dxf) + var cisDxf = new DifferentialFormat(); + if (properties.TryGetValue("font.color", out var cisFontColor)) + { + var normalizedFontColor = ParseHelpers.NormalizeArgbColor(cisFontColor); + cisDxf.Append(new Font(new DocumentFormat.OpenXml.Spreadsheet.Color { Rgb = normalizedFontColor })); + } + if (properties.TryGetValue("font.bold", out var cisFontBold) && IsTruthy(cisFontBold)) + { + var existingFont = cisDxf.GetFirstChild(); + if (existingFont != null) existingFont.Append(new Bold()); + else cisDxf.Append(new Font(new Bold())); + } + if (properties.TryGetValue("fill", out var cisFill)) + { + var normalizedFill = ParseHelpers.NormalizeArgbColor(cisFill); + cisDxf.Append(new Fill(new PatternFill( + new BackgroundColor { Rgb = normalizedFill }) + { PatternType = PatternValues.Solid })); + } + + var cisWbPart = _doc.WorkbookPart + ?? throw new InvalidOperationException("Workbook not found"); + var cisStyleMgr = new ExcelStyleManager(cisWbPart); + cisStyleMgr.EnsureStylesPart(); + var cisStylesheet = cisWbPart.WorkbookStylesPart!.Stylesheet!; + var cisDxfs = cisStylesheet.GetFirstChild(); + if (cisDxfs == null) + { + cisDxfs = new DifferentialFormats { Count = 0 }; + cisStylesheet.Append(cisDxfs); + } + cisDxfs.Append(cisDxf); + cisDxfs.Count = (uint)cisDxfs.Elements().Count(); + _dirtyStylesheet = true; + var cisDxfId = cisDxfs.Count!.Value - 1; + + var cisRule = new ConditionalFormattingRule + { + Type = ConditionalFormatValues.CellIs, + Priority = NextCfPriority(GetSheet(cisWorksheet)), + FormatId = cisDxfId, + Operator = opVal + }; + cisRule.Append(new Formula(primary)); + if ((opVal == ConditionalFormattingOperatorValues.Between + || opVal == ConditionalFormattingOperatorValues.NotBetween) + && secondary != null) + { + cisRule.Append(new Formula(secondary)); + } + ApplyStopIfTrue(cisRule, properties); + + var cisCf = new ConditionalFormatting(cisRule) + { + SequenceOfReferences = new ListValue( + cisSqref.Split(' ').Select(s => new StringValue(s))) + }; + + var cisWsElement = GetSheet(cisWorksheet); + InsertConditionalFormatting(cisWsElement, cisCf); + + SaveWorksheet(cisWorksheet); + var cisCfCount = cisWsElement.Elements().Count(); + return $"/{cisSheetName}/cf[{cisCfCount}]"; + } + + case "ole": + case "oleobject": + case "object": + case "embed": + { + // ---- Excel OLE insertion (modern form, Office 2010+) ---- + // + // Structure produced: + // Worksheet > oleObjects > oleObject(progId, shapeId, r:id=embedRel) + // > objectPr(defaultSize=0, r:id=iconRel) + // > anchor(moveWithCells=1) + // > from(col, colOff, row, rowOff) + // > to (col, colOff, row, rowOff) + // + // We skip the legacy VML shape that Excel historically + // generates as a fallback — when the modern objectPr/anchor + // is present, Office 2010+ renders from it directly. The + // constraint-required shapeId still needs a value, so we + // allocate one in the legal range (1-67098623) unique per + // worksheet. For round-trip fidelity, we also create an + // empty legacy VmlDrawingPart and register the shapeId + // there so the relationship target exists. + var oleSheetSegs = parentPath.TrimStart('/').Split('/', 2); + var oleSheetName = oleSheetSegs[0]; + var oleWorksheet = FindWorksheet(oleSheetName) + ?? throw new ArgumentException($"Sheet not found: {oleSheetName}"); + + var oleSrc = OfficeCli.Core.OleHelper.RequireSource(properties); + OfficeCli.Core.OleHelper.WarnOnUnknownOleProps(properties); + + // CONSISTENCY(excel-ole-display): Excel OLE does not have a + // DrawAspect concept — worksheet objects are always shown as + // icons via objectPr/anchor, so 'display' would be a no-op. + // Set already rejects it; Add must too, for symmetry. + if (properties.ContainsKey("display")) + throw new ArgumentException( + "'display' property is not supported for Excel OLE " + + "(Excel always shows objects as icon). Remove --prop display."); + + // CONSISTENCY(ole-name): Word/PPT OLE accept --prop name=... and + // round-trip it via Get. SpreadsheetML x:oleObject has no Name + // attribute in the schema, so there is nowhere to persist it. + // Throw explicitly rather than silently dropping the value — + // keep 'name' in KnownOleProps so Word/PPT still accept it. + if (properties.ContainsKey("name")) + throw new ArgumentException( + "'name' property is not supported for Excel OLE " + + "(Spreadsheet OleObject schema has no Name attribute). Remove --prop name."); + + // 1. Embedded payload. + var (oleEmbedRelId, _) = OfficeCli.Core.OleHelper.AddEmbeddedPart(oleWorksheet, oleSrc, _filePath); + + // 2. Icon preview image part. + var (_, oleIconRelId) = OfficeCli.Core.OleHelper.CreateIconPart(oleWorksheet, properties); + + // 3. Resolve ProgID. + var oleProgId = OfficeCli.Core.OleHelper.ResolveProgId(properties, oleSrc); + + // 4. Anchor: accept either cell range "B2:E6" or x/y/width/height (column units). + // CONSISTENCY(ole-width-units): sub-cell precision is carried in + // ColumnOffset/RowOffset (EMU) so unit-qualified widths like + // "6cm" survive a round-trip. When the user passes a cell range + // or a bare integer cell count, the remainder offsets are 0 and + // behavior matches the legacy whole-cell path. + int oleFromCol, oleFromRow, oleToCol, oleToRow; + // FromMarker offsets are always zero (anchor starts at cell boundary); + // ToMarker offsets carry the sub-cell EMU remainder for unit-qualified + // width/height inputs, preserving round-trip precision. + const long oleFromColOff = 0, oleFromRowOff = 0; + long oleToColOff = 0, oleToRowOff = 0; + if (properties.TryGetValue("anchor", out var oleAnchorStr) && !string.IsNullOrWhiteSpace(oleAnchorStr)) + { + // CONSISTENCY(ole-width-units): anchor= defines the full + // rectangle (start+end cells), so width/height on the same + // Add call would be ambiguous and are silently dropped. + // Warn loudly rather than fail, so existing scripts keep + // working but users notice the dropped value. + if (properties.ContainsKey("width") || properties.ContainsKey("height")) + Console.Error.WriteLine( + "Warning: 'width'/'height' are ignored when 'anchor' is provided (anchor defines the full rectangle)."); + var m = Regex.Match(oleAnchorStr, @"^([A-Z]+)(\d+)(?::([A-Z]+)(\d+))?$", RegexOptions.IgnoreCase); + if (!m.Success) + throw new ArgumentException($"Invalid anchor: '{oleAnchorStr}'. Expected e.g. 'B2' or 'B2:E6'."); + // CONSISTENCY(xdr-coords): XDR ColumnId/RowId are 0-based; + // ColumnNameToIndex returns 1-based, so subtract 1 here. + oleFromCol = ColumnNameToIndex(m.Groups[1].Value) - 1; + oleFromRow = int.Parse(m.Groups[2].Value) - 1; + if (m.Groups[3].Success) + { + oleToCol = ColumnNameToIndex(m.Groups[3].Value) - 1; + oleToRow = int.Parse(m.Groups[4].Value) - 1; + } + else + { + oleToCol = oleFromCol + 2; + oleToRow = oleFromRow + 3; + } + } + else + { + var (ax, ay, awEmu, ahEmu) = ParseAnchorBoundsEmu(properties, "1", "1", "3", "4"); + oleFromCol = ax; + oleFromRow = ay; + // Split the EMU extent into (whole cells, sub-cell offset). + // EmuPerCol/Row constants live in ExcelHandler.Helpers.cs. + long wholeCols = awEmu / EmuPerColApprox; + long remCols = awEmu % EmuPerColApprox; + long wholeRows = ahEmu / EmuPerRowApprox; + long remRows = ahEmu % EmuPerRowApprox; + oleToCol = ax + (int)wholeCols; + oleToRow = ay + (int)wholeRows; + oleToColOff = remCols; + oleToRowOff = remRows; + } + + // 5. Ensure the legacy VmlDrawingPart exists and carry an + // empty shape placeholder referencing our shapeId. This + // keeps the schema happy without writing VML rendering + // logic — Excel 2010+ renders from objectPr/anchor anyway. + var oleVmlPart = oleWorksheet.VmlDrawingParts.FirstOrDefault() + ?? oleWorksheet.AddNewPart(); + // Allocate a unique shapeId per worksheet (1025+N is the + // conventional Excel starting point for legacy VML shapes). + var existingOleCount = GetSheet(oleWorksheet).Descendants().Count(); + uint oleShapeId = (uint)(1025 + existingOleCount); + EnsureExcelVmlShapeForOle(oleVmlPart, oleShapeId, oleFromCol, oleFromRow, oleToCol, oleToRow); + + // Ensure worksheet references the VML drawing part. + var oleWsElement = GetSheet(oleWorksheet); + if (oleWsElement.GetFirstChild() == null) + { + var vmlRelId = oleWorksheet.GetIdOfPart(oleVmlPart); + // LegacyDrawing must sit after the AutoFilter/Phonetic + // region per schema order — safe to insert before the + // last known printing-related elements. Use InsertAfter + // relative to AutoFilter when present, else append. + var lgd = new LegacyDrawing { Id = vmlRelId }; + var pageSetup = oleWsElement.GetFirstChild(); + if (pageSetup != null) + oleWsElement.InsertAfter(lgd, pageSetup); + else + oleWsElement.AppendChild(lgd); + } + + // 6. Build the oleObject element + objectPr/anchor. + var oleObj = new OleObject + { + ProgId = oleProgId, + ShapeId = oleShapeId, + Id = oleEmbedRelId, + }; + var objectPr = new EmbeddedObjectProperties + { + DefaultSize = false, + Id = oleIconRelId, + }; + var anchor = new ObjectAnchor { MoveWithCells = true }; + anchor.AppendChild(new FromMarker( + new XDR.ColumnId(oleFromCol.ToString()), + new XDR.ColumnOffset(oleFromColOff.ToString()), + new XDR.RowId(oleFromRow.ToString()), + new XDR.RowOffset(oleFromRowOff.ToString()))); + anchor.AppendChild(new ToMarker( + new XDR.ColumnId(oleToCol.ToString()), + new XDR.ColumnOffset(oleToColOff.ToString()), + new XDR.RowId(oleToRow.ToString()), + new XDR.RowOffset(oleToRowOff.ToString()))); + objectPr.AppendChild(anchor); + oleObj.AppendChild(objectPr); + + // 7. Find/create oleObjects collection and append. + var oleObjects = oleWsElement.GetFirstChild(); + if (oleObjects == null) + { + oleObjects = new OleObjects(); + // Schema: oleObjects sits between picture and controls; + // safest is after tableParts if present, else before + // pageSetup, else append. + var insertBefore = oleWsElement.GetFirstChild() + ?? (OpenXmlElement?)null; + if (insertBefore != null) + oleWsElement.InsertBefore(oleObjects, insertBefore); + else + oleWsElement.AppendChild(oleObjects); + } + oleObjects.AppendChild(oleObj); + + SaveWorksheet(oleWorksheet); + + var oleCount = oleWsElement.Descendants().Count(); + return $"/{oleSheetName}/ole[{oleCount}]"; + } + case "picture": case "image": + case "img": { var picSegments = parentPath.TrimStart('/').Split('/', 2); var picSheetName = picSegments[0]; var picWorksheet = FindWorksheet(picSheetName) ?? throw new ArgumentException($"Sheet not found: {picSheetName}"); - var imgPath = properties.GetValueOrDefault("path", "") ?? ""; - if (string.IsNullOrEmpty(imgPath)) - imgPath = properties.GetValueOrDefault("src", ""); - if (string.IsNullOrEmpty(imgPath)) - throw new ArgumentException("picture requires a 'path' or 'src' property"); + if (!properties.TryGetValue("path", out var imgPath) + && !properties.TryGetValue("src", out imgPath)) + throw new ArgumentException("'src' property is required for picture type"); - var (px, py, pw, ph) = ParseAnchorBounds(properties, "0", "0", "5", "5"); - var alt = properties.GetValueOrDefault("alt", ""); + // CONSISTENCY(picture-emu): use ParseAnchorBoundsEmu like OLE, + // so width/height accept unit-qualified strings ("6cm", "2in") + // in addition to bare integer cell counts. + var (px, py, pwEmu, phEmu) = ParseAnchorBoundsEmu(properties, "0", "0", "5", "5"); + // P9: accept `altText=` as alias for `alt=`. + var alt = properties.GetValueOrDefault("alt") + ?? properties.GetValueOrDefault("altText") + ?? properties.GetValueOrDefault("alttext", ""); var picDrawingsPart = picWorksheet.DrawingsPart ?? picWorksheet.AddNewPart(); @@ -872,50 +1704,261 @@ public string Add(string parentPath, string type, int? index, Dictionary() .Select(p => (uint?)p.Id?.Value ?? 0u).DefaultIfEmpty(0u).Max() + 1; - var anchor = new XDR.TwoCellAnchor( - new XDR.FromMarker( - new XDR.ColumnId(px.ToString()), - new XDR.ColumnOffset("0"), - new XDR.RowId(py.ToString()), - new XDR.RowOffset("0") - ), - new XDR.ToMarker( - new XDR.ColumnId((px + pw).ToString()), - new XDR.ColumnOffset("0"), - new XDR.RowId((py + ph).ToString()), - new XDR.RowOffset("0") - ), - new XDR.Picture( - new XDR.NonVisualPictureProperties( - new XDR.NonVisualDrawingProperties { Id = picId, Name = $"Picture {picId}", Description = alt }, - new XDR.NonVisualPictureDrawingProperties(new Drawing.PictureLocks { NoChangeAspect = true }) - ), - new XDR.BlipFill( - new Drawing.Blip { Embed = imgRelId }, - new Drawing.Stretch(new Drawing.FillRectangle()) - ), - new XDR.ShapeProperties( - new Drawing.Transform2D( - new Drawing.Offset { X = 0, Y = 0 }, - new Drawing.Extents { Cx = 0, Cy = 0 } + // CONSISTENCY(picture-emu): split EMU extent into whole-cell + // count + sub-cell offset, matching the OLE anchor path. + long picWholeCols = pwEmu / EmuPerColApprox; + long picRemCols = pwEmu % EmuPerColApprox; + long picWholeRows = phEmu / EmuPerRowApprox; + long picRemRows = phEmu % EmuPerRowApprox; + + // DEFERRED(xlsx/picture-anchor-mode) P12: honor `anchorMode=` + // oneCell|absolute|twoCell. Default remains twoCell for back-compat. + // oneCell → with from + ext; picture auto-scales + // if the column/row containing "from" is resized. + // absolute → with pos (x/y EMU) + ext; picture + // does not move or resize with cells. + // twoCell → with from + to markers (default). + // + // CONSISTENCY(ole-width-units): `anchor=B2:E6` (cell-range) is + // parsed here the same way as the OLE and shape branches; it + // implies anchorMode=twoCell. `anchor=oneCell|twoCell|absolute` + // is still honored as the mode for back-compat. Explicit + // `anchorMode=` always wins. When both `anchor=` and + // `x/y/width/height` are supplied, anchor wins with a warning + // (same convention as the shape/OLE branches). + var picAnchorRaw = properties.GetValueOrDefault("anchor"); + var picAnchorModeExplicit = properties.GetValueOrDefault("anchorMode"); + bool picHasRange = false; + int picRangeFromCol = 0, picRangeFromRow = 0, picRangeToCol = -1, picRangeToRow = -1; + // `anchor=` is either a cell-range ("B2" / "B2:E6") or an + // anchorMode token ("oneCell"/"twoCell"/"absolute"). Prefer the + // cell-range interpretation; fall back to mode-token only when + // the value is a recognized token. Explicit `anchorMode=` wins + // the mode selection regardless. + if (!string.IsNullOrWhiteSpace(picAnchorRaw) && !IsAnchorModeToken(picAnchorRaw)) + { + if (!TryParseCellRangeAnchor(picAnchorRaw, out picRangeFromCol, out picRangeFromRow, out picRangeToCol, out picRangeToRow)) + throw new ArgumentException($"Invalid anchor: '{picAnchorRaw}'. Expected e.g. 'B2', 'B2:E6', or one of 'oneCell'/'twoCell'/'absolute'."); + picHasRange = true; + if (properties.ContainsKey("width") || properties.ContainsKey("height") + || properties.ContainsKey("x") || properties.ContainsKey("y")) + Console.Error.WriteLine( + "Warning: 'x'/'y'/'width'/'height' are ignored when 'anchor' is a cell range (anchor defines the full rectangle)."); + } + var picAnchorMode = (picAnchorModeExplicit + ?? (picHasRange ? "twoCell" : picAnchorRaw) + ?? "twoCell").Trim().ToLowerInvariant(); + + var picShape = BuildPictureElementWithTransform(picId, alt ?? "", imgRelId, xlSvgRelId, properties); + + // For oneCell / absolute anchors the size is carried by an + // element instead of a To marker, so we must also stamp the extent + // onto the picture's Transform2D so rotation / flip metadata plus + // the rendered size stay in sync. + if (picAnchorMode is "onecell" or "absolute") + { + var picXfrm = picShape.Descendants().FirstOrDefault(); + if (picXfrm != null) + { + var ext2d = picXfrm.Extents ?? new Drawing.Extents(); + ext2d.Cx = pwEmu; + ext2d.Cy = phEmu; + picXfrm.Extents = ext2d; + } + } + + OpenXmlElement anchor; + switch (picAnchorMode) + { + case "onecell": + { + int oneFromCol = picHasRange ? picRangeFromCol : px; + int oneFromRow = picHasRange ? picRangeFromRow : py; + var oneAnchor = new XDR.OneCellAnchor( + new XDR.FromMarker( + new XDR.ColumnId(oneFromCol.ToString()), + new XDR.ColumnOffset("0"), + new XDR.RowId(oneFromRow.ToString()), + new XDR.RowOffset("0") ), - new Drawing.PresetGeometry(new Drawing.AdjustValueList()) { Preset = Drawing.ShapeTypeValues.Rectangle } - ) - ), - new XDR.ClientData() - ); + new XDR.Extent { Cx = pwEmu, Cy = phEmu }, + picShape, + new XDR.ClientData() + ); + anchor = oneAnchor; + break; + } + case "absolute": + { + // Absolute anchor pos: accept `x=`/`y=` in the same unit + // syntax as width/height (bare EMU, or "1in", "2cm"). + long absX = 0, absY = 0; + if (properties.TryGetValue("x", out var absXs)) + absX = OfficeCli.Core.EmuConverter.ParseEmu(absXs); + if (properties.TryGetValue("y", out var absYs)) + absY = OfficeCli.Core.EmuConverter.ParseEmu(absYs); + var absAnchor = new XDR.AbsoluteAnchor( + new XDR.Position { X = absX, Y = absY }, + new XDR.Extent { Cx = pwEmu, Cy = phEmu }, + picShape, + new XDR.ClientData() + ); + anchor = absAnchor; + break; + } + default: + { + int twoFromCol, twoFromRow, twoToCol, twoToRow; + long twoToColOff, twoToRowOff; + if (picHasRange) + { + twoFromCol = picRangeFromCol; + twoFromRow = picRangeFromRow; + if (picRangeToCol >= 0) + { + twoToCol = picRangeToCol; + twoToRow = picRangeToRow; + twoToColOff = 0; + twoToRowOff = 0; + } + else + { + // Single-cell range in twoCell mode: fall back to width/height extent. + twoToCol = twoFromCol + (int)picWholeCols; + twoToRow = twoFromRow + (int)picWholeRows; + twoToColOff = picRemCols; + twoToRowOff = picRemRows; + } + } + else + { + twoFromCol = px; + twoFromRow = py; + twoToCol = px + (int)picWholeCols; + twoToRow = py + (int)picWholeRows; + twoToColOff = picRemCols; + twoToRowOff = picRemRows; + } + anchor = new XDR.TwoCellAnchor( + new XDR.FromMarker( + new XDR.ColumnId(twoFromCol.ToString()), + new XDR.ColumnOffset("0"), + new XDR.RowId(twoFromRow.ToString()), + new XDR.RowOffset("0") + ), + new XDR.ToMarker( + new XDR.ColumnId(twoToCol.ToString()), + new XDR.ColumnOffset(twoToColOff.ToString()), + new XDR.RowId(twoToRow.ToString()), + new XDR.RowOffset(twoToRowOff.ToString()) + ), + picShape, + new XDR.ClientData() + ); + break; + } + } picDrawingsPart.WorksheetDrawing.AppendChild(anchor); + + // P10: picture decorative=true — emit + // under . + // Requires declaring xmlns:a16 on the drawing root; mirrors the + // sparkline pattern of adding namespaces idempotently. + if (properties.TryGetValue("decorative", out var picDec) && IsTruthy(picDec)) + { + var picCNvPrDec = anchor.Descendants().FirstOrDefault(); + if (picCNvPrDec != null) + { + const string a16Ns = "http://schemas.microsoft.com/office/drawing/2014/main"; + var wsDrawingRoot = picDrawingsPart.WorksheetDrawing; + if (wsDrawingRoot.LookupNamespace("a16") == null) + wsDrawingRoot.AddNamespaceDeclaration("a16", a16Ns); + var decInner = new OpenXmlUnknownElement("a16", "decorative", a16Ns); + decInner.SetAttribute(new OpenXmlAttribute("", "val", "", "1")); + var ext = new Drawing.Extension { Uri = "{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}" }; + ext.Append(decInner); + var extLst = picCNvPrDec.GetFirstChild() + ?? picCNvPrDec.AppendChild(new Drawing.ExtensionList()); + extLst.Append(ext); + } + } + + // P8: picture-level hyperlink — under . + // External URL → add rel on DrawingsPart, reference its rId. + // Internal (starts with '#') → no rel, use Location attribute. + // CONSISTENCY(xlsx-hyperlink): mirrors cell link handling in + // commit 60e1455. + var picHlink = properties.GetValueOrDefault("hyperlink") + ?? properties.GetValueOrDefault("link"); + if (!string.IsNullOrWhiteSpace(picHlink)) + { + var picCNvPr = anchor.Descendants().FirstOrDefault(); + if (picCNvPr != null) + { + Drawing.HyperlinkOnClick hlClick; + if (picHlink.StartsWith("#")) + { + // No rel, no @r:id — pure in-document jump via @location. + hlClick = new Drawing.HyperlinkOnClick { Id = "" }; + hlClick.SetAttribute(new OpenXmlAttribute( + "", "location", "", picHlink.Substring(1))); + } + else + { + var hlUri = new Uri(picHlink, UriKind.RelativeOrAbsolute); + var hlRel = picDrawingsPart.AddHyperlinkRelationship(hlUri, isExternal: true); + hlClick = new Drawing.HyperlinkOnClick { Id = hlRel.Id }; + } + picCNvPr.AppendChild(hlClick); + } + } + picDrawingsPart.WorksheetDrawing.Save(); - var picAnchors = picDrawingsPart.WorksheetDrawing.Elements() - .Where(a => a.Descendants().Any()).ToList(); + // DEFERRED(xlsx/picture-anchor-mode) P12: enumerate all anchor + // kinds (twoCell / oneCell / absolute) when counting picture slots. + var picAnchors = picDrawingsPart.WorksheetDrawing + .Elements() + .Where(a => (a is XDR.TwoCellAnchor || a is XDR.OneCellAnchor || a is XDR.AbsoluteAnchor) + && a.Descendants().Any()) + .ToList(); var picIdx = picAnchors.IndexOf(anchor) + 1; return $"/{picSheetName}/picture[{picIdx}]"; @@ -928,7 +1971,52 @@ public string Add(string parentPath, string type, int? index, Dictionary` maps to single-cell + // anchor `:`, matching cell/comment/table which + // accept `ref=` as the placement address. Explicit `anchor=` + // wins if both are given. + if (!properties.ContainsKey("anchor") + && properties.TryGetValue("ref", out var shpRefProp) + && !string.IsNullOrWhiteSpace(shpRefProp)) + { + var refTrim = shpRefProp.Trim(); + if (!refTrim.Contains(':')) + { + // Single-cell ref (e.g. "B2"): expand to a 1x1 cell + // rectangle (B2:C3) so the shape has a visible extent. + // Using identical from/to markers produces a + // zero-width/height invisible shape in Excel. + if (TryParseCellRangeAnchor(refTrim, out var rc, out var rr, out _, out _)) + refTrim = $"{refTrim}:{IndexToColumnName(rc + 2)}{rr + 2}"; + else + refTrim = $"{refTrim}:{refTrim}"; + } + properties["anchor"] = refTrim; + } + int sx, sy, sw, sh; + if (properties.TryGetValue("anchor", out var shpAnchorStr) && !string.IsNullOrWhiteSpace(shpAnchorStr)) + { + if (properties.ContainsKey("width") || properties.ContainsKey("height") + || properties.ContainsKey("x") || properties.ContainsKey("y")) + Console.Error.WriteLine( + "Warning: 'x'/'y'/'width'/'height' are ignored when 'anchor' is provided (anchor defines the full rectangle)."); + if (!TryParseCellRangeAnchor(shpAnchorStr, out var sxFrom, out var syFrom, out var sxTo, out var syTo)) + throw new ArgumentException($"Invalid anchor: '{shpAnchorStr}'. Expected e.g. 'B2' or 'B2:F7'."); + sx = sxFrom; + sy = syFrom; + if (sxTo < 0) { sxTo = sx + 4; syTo = sy + 2; } + sw = sxTo - sx; + sh = syTo - sy; + } + else + { + (sx, sy, sw, sh) = ParseAnchorBounds(properties, "1", "1", "5", "3"); + } var shpText = properties.GetValueOrDefault("text", "") ?? ""; var shpName = properties.GetValueOrDefault("name", ""); @@ -952,17 +2040,35 @@ public string Add(string parentPath, string type, int? index, Dictionary (uint?)p.Id?.Value ?? 0u).DefaultIfEmpty(0u).Max() + 1; if (string.IsNullOrEmpty(shpName)) shpName = $"Shape {shpId}"; + // CONSISTENCY(shape-preset): map `preset=` to a:prstGeom prst value + // using the same token set PowerPointHandler.ParsePresetShape accepts. + // textbox ignores preset (always "rect"). Default for shape: "rect". + var shpPreset = Drawing.ShapeTypeValues.Rectangle; + if (string.Equals(type, "shape", StringComparison.OrdinalIgnoreCase) + && properties.TryGetValue("preset", out var shpPresetRaw) + && !string.IsNullOrWhiteSpace(shpPresetRaw)) + shpPreset = ParseExcelShapePreset(shpPresetRaw); + // Build ShapeProperties + var shpXfrm = new Drawing.Transform2D( + new Drawing.Offset { X = 0, Y = 0 }, + new Drawing.Extents { Cx = 0, Cy = 0 } + ); + ApplyTransform2DRotationFlip(shpXfrm, properties); var spPr = new XDR.ShapeProperties( - new Drawing.Transform2D( - new Drawing.Offset { X = 0, Y = 0 }, - new Drawing.Extents { Cx = 0, Cy = 0 } - ), - new Drawing.PresetGeometry(new Drawing.AdjustValueList()) { Preset = Drawing.ShapeTypeValues.Rectangle } + shpXfrm, + new Drawing.PresetGeometry(new Drawing.AdjustValueList()) { Preset = shpPreset } ); - // Fill - if (properties.TryGetValue("fill", out var shpFill)) + // Fill — single-color `fill=` OR gradient `gradientFill=C1-C2[-C3][:angle]`. + // SH6/shape-gradient-fill: keep `fill=` strictly single-color; gradient has its own prop + // to avoid ambiguity (FF0000-0000FF could otherwise collide with single ARGB literals). + if (properties.TryGetValue("gradientFill", out var shpGradFill) + && !string.IsNullOrWhiteSpace(shpGradFill)) + { + spPr.AppendChild(BuildShapeGradientFill(shpGradFill)); + } + else if (properties.TryGetValue("fill", out var shpFill)) { if (shpFill.Equals("none", StringComparison.OrdinalIgnoreCase)) spPr.AppendChild(new Drawing.NoFill()); @@ -987,38 +2093,43 @@ public string Add(string parentPath, string type, int? index, Dictionary Drawing.TextUnderlineValues.Single, + "double" or "dbl" => Drawing.TextUnderlineValues.Double, + "none" or "false" => Drawing.TextUnderlineValues.None, + _ => Drawing.TextUnderlineValues.Single + }; + } + // Fill (color) before fonts - if (properties.TryGetValue("color", out var shpColor)) + string? rawColor = properties.GetValueOrDefault("color") + ?? properties.GetValueOrDefault("font.color"); + if (rawColor != null) { - var (cRgb, _) = ParseHelpers.SanitizeColorForOoxml(shpColor); + var (cRgb, _) = ParseHelpers.SanitizeColorForOoxml(rawColor); rPr.AppendChild(new Drawing.SolidFill(new Drawing.RgbColorModelHex { Val = cRgb })); } @@ -1054,30 +2191,37 @@ public string Add(string parentPath, string type, int? index, Dictionary wp.TableDefinitionParts) .Select(tdp => tdp.Table?.Id?.Value ?? 0); var tableId = existingTableIds.Any() ? existingTableIds.Max() + 1 : 1; - var tableName = properties.GetValueOrDefault("name", $"Table{tableId}"); - var displayName = properties.GetValueOrDefault("displayName", tableName); + var userProvidedName = properties.ContainsKey("name"); + var tableName = SanitizeTableIdentifier( + properties.GetValueOrDefault("name", $"Table{tableId}"), + userProvided: userProvidedName); + // displayName defaults to the (already-sanitized) tableName; if + // name was user-provided it flows through verbatim so Excel + // shows the same identifier the user asked for. + var userProvidedDisplay = properties.ContainsKey("displayName"); + var displayName = SanitizeTableIdentifier( + properties.GetValueOrDefault("displayName", tableName), + userProvided: userProvidedDisplay || userProvidedName); var styleName = properties.GetValueOrDefault("style", "TableStyleMedium2"); - var hasHeader = !properties.TryGetValue("headerRow", out var hrVal) || IsTruthy(hrVal); - var hasTotalRow = properties.TryGetValue("totalRow", out var trVal) && IsTruthy(trVal); + // T6 — validate style name against the built-in whitelist + + // any workbook-level customStyles. Unknown names silently + // fell through to Excel which would either ignore or + // reject the file; prefer an explicit ArgumentException. + ValidateTableStyleName(styleName); + // T1 — accept `showHeader=false` alias alongside `headerRow=false`. + var hasHeader = !(properties.TryGetValue("headerRow", out var hrVal) && !IsTruthy(hrVal)) + && !(properties.TryGetValue("showHeader", out var shVal) && !IsTruthy(shVal)); + // CONSISTENCY(table-totalrow): accept `showTotals=true` alias + // alongside `totalRow=true` (mirrors the `showHeader` alias + // pattern above for users coming from Office API vocabulary). + var hasTotalRow = (properties.TryGetValue("totalRow", out var trVal) && IsTruthy(trVal)) + || (properties.TryGetValue("showTotals", out var stVal) && IsTruthy(stVal)); var rangeParts = rangeRef.Split(':'); var (startCol, startRow) = ParseCellReference(rangeParts[0]); @@ -1161,6 +2336,56 @@ public string Add(string parentPath, string type, int? index, Dictionary(); + if (sheetDataForProbe != null) + { + int probeRow = endRow + 1; + while (true) + { + var probe = sheetDataForProbe.Elements() + .FirstOrDefault(r => r.RowIndex?.Value == (uint)probeRow); + if (probe == null) break; + // non-empty = at least one cell in the column + // span carries a CellValue or InlineString. + bool anyNonEmpty = false; + for (int ci = 0; ci < colCount; ci++) + { + var cLetter = IndexToColumnName(startColIdx + ci); + var cRef = $"{cLetter}{probeRow}"; + var probeCell = probe.Elements() + .FirstOrDefault(c => c.CellReference?.Value == cRef); + if (probeCell == null) continue; + if (probeCell.CellValue != null || probeCell.InlineString != null) + { + anyNonEmpty = true; + break; + } + } + if (!anyNonEmpty) break; + endRow = probeRow; + probeRow++; + } + rangeRef = $"{startCol}{startRow}:{endCol}{endRow}"; + } + } + + // CONSISTENCY(table-totalrow): a:totalsRowShown MUST point at a row + // OUTSIDE the data area. Previously we reused endRow as the totals + // row, which overwrote whatever data lived on that last row. Expand + // the ref by one row so the totals row is appended below the data + // instead of stamping over it. + if (hasTotalRow) + { + endRow += 1; + rangeRef = $"{startCol}{startRow}:{endCol}{endRow}"; + } + string[] colNames; if (properties.TryGetValue("columns", out var tblColsStr)) { @@ -1185,6 +2410,18 @@ public string Add(string parentPath, string type, int? index, Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < colCount; i++) + { + var baseName = colNames[i]; + var cn = baseName; + var dedupIdx = 2; + while (!usedColNames.Add(cn)) + cn = $"{baseName}{dedupIdx++}"; + colNames[i] = cn; + } + var tableColumns = new TableColumns { Count = (uint)colCount }; for (int i = 0; i < colCount; i++) tableColumns.AppendChild(new TableColumn { Id = (uint)(i + 1), Name = colNames[i] }); table.AppendChild(tableColumns); + // T-ext: detect uniform formula pattern per column and emit + // so Excel auto-fills the formula + // into new rows appended to the table. Heuristic: if every data + // row in a column carries a CellFormula whose relative form + // (row numbers stripped) is identical, treat it as a calc'd + // column and store the first row's formula. + { + var ccfSheetData = GetSheet(tblWorksheet).GetFirstChild(); + var dataStart = hasHeader ? startRow + 1 : startRow; + var dataEnd = hasTotalRow ? endRow - 1 : endRow; + if (ccfSheetData != null && dataEnd >= dataStart) + { + var tblColElems = tableColumns.Elements().ToList(); + for (int ci = 0; ci < colCount; ci++) + { + var colLetter = IndexToColumnName(startColIdx + ci); + string? firstFormula = null; + string? pattern = null; + bool uniform = true; + for (int r = dataStart; r <= dataEnd; r++) + { + var row = ccfSheetData.Elements() + .FirstOrDefault(rr => rr.RowIndex?.Value == (uint)r); + if (row == null) { uniform = false; break; } + var cellRefS = $"{colLetter}{r}"; + var c = row.Elements() + .FirstOrDefault(x => x.CellReference?.Value == cellRefS); + var f = c?.CellFormula?.Text; + if (string.IsNullOrEmpty(f)) { uniform = false; break; } + // Strip row numbers so =J2*K2 and =J3*K3 collapse to =J*K + var relF = System.Text.RegularExpressions.Regex.Replace( + f, @"\$?\d+", ""); + if (pattern == null) { pattern = relF; firstFormula = f; } + else if (relF != pattern) { uniform = false; break; } + } + if (uniform && firstFormula != null) + { + tblColElems[ci].CalculatedColumnFormula = + new CalculatedColumnFormula(firstFormula); + } + } + } + } + + // T7-ext: `columns.N.dxfId=` stamps dataDxfId on the + // target tableColumn (N is 1-based). The id must reference + // an existing workbook differentialFormats entry; we do not + // synthesize new dxfs here — users who want inline style + // values should register a dxf first via `add dxf` (or the + // underlying APIs) and then reference it. + var tblColList = tableColumns.Elements().ToList(); + foreach (var (rawKey, rawVal) in properties) + { + var m = Regex.Match(rawKey, @"^columns?\.(\d+)\.dxfId$", + RegexOptions.IgnoreCase); + if (!m.Success) continue; + var n = int.Parse(m.Groups[1].Value); + if (n < 1 || n > tblColList.Count) continue; + if (!uint.TryParse(rawVal, out var dxfId)) + throw new ArgumentException( + $"columns.{n}.dxfId requires a numeric dxf id, got: '{rawVal}'"); + tblColList[n - 1].DataFormatId = dxfId; + } + + // T2 — wire the banded rows/columns + first/last column + // flags onto the TableStyleInfo. Each accepts `showX` or + // its alias; default matches the old hard-coded values so + // omitting them is identical to previous behavior. table.AppendChild(new TableStyleInfo { Name = styleName, - ShowFirstColumn = false, - ShowLastColumn = false, - ShowRowStripes = true, - ShowColumnStripes = false + ShowFirstColumn = properties.TryGetValue("showFirstColumn", out var sfc) + ? IsTruthy(sfc) : false, + ShowLastColumn = properties.TryGetValue("showLastColumn", out var slc) + ? IsTruthy(slc) : false, + ShowRowStripes = properties.TryGetValue("showBandedRows", out var sbr) + ? IsTruthy(sbr) : true, + ShowColumnStripes = properties.TryGetValue("showBandedColumns", out var sbc) + ? IsTruthy(sbc) : false }); // Generate total row content in SheetData when totalRow is enabled @@ -1244,6 +2567,13 @@ public string Add(string parentPath, string type, int? index, Dictionary().ToList(); + // Per-column totalsRowFunction tokens: "none,sum,average" + // → first col = label/none, rest = sum, average. If the + // user didn't pass it, default to "none" on col0 + "sum" + // on the rest (legacy behavior). + string[] trfTokens = properties.TryGetValue("totalsRowFunction", out var trfRaw) + ? trfRaw.Split(',').Select(s => s.Trim()).ToArray() + : Array.Empty(); for (int ci = 0; ci < tblCols.Count; ci++) { var colLetter = IndexToColumnName(startColIdx + ci); @@ -1256,25 +2586,71 @@ public string Add(string parentPath, string type, int? index, Dictionary(CellValues.String); } + else if (trfEnum == TotalsRowFunctionValues.None) + { + // Skip — leave cell empty, no function set. + } else { - // Other columns: SUBTOTAL(109, range) formula for SUM - tblCols[ci].TotalsRowFunction = TotalsRowFunctionValues.Sum; + // Default non-first column (no explicit token) = SUM + if (ci > 0 && tokRaw == "") + { + trfEnum = TotalsRowFunctionValues.Sum; + subtotalCode = 109; + } + tblCols[ci].TotalsRowFunction = trfEnum; var dataStartRow = hasHeader ? startRow + 1 : startRow; var dataEndRow = (int)totalRowIdx - 1; var formulaRange = $"{colLetter}{dataStartRow}:{colLetter}{dataEndRow}"; - existingCell.CellFormula = new CellFormula($"SUBTOTAL(109,{formulaRange})"); + existingCell.CellFormula = new CellFormula($"SUBTOTAL({subtotalCode},{formulaRange})"); } } + + // T10: per-column custom totalsFormula override. Syntax: + // columns.N.totalsFormula="=SUM(Table1[Sales])/2" + // where N is 1-based. This sets the column's + // totalsRowFunction to "custom" + writes , + // and replaces the SUBTOTAL cell formula with the user's. + foreach (var (rawKey, rawVal) in properties) + { + var m = Regex.Match(rawKey, @"^columns?\.(\d+)\.totalsFormula$", + RegexOptions.IgnoreCase); + if (!m.Success) continue; + var n = int.Parse(m.Groups[1].Value); + if (n < 1 || n > tblCols.Count) continue; + var ci = n - 1; + var colLetter = IndexToColumnName(startColIdx + ci); + var cellRefStr = $"{colLetter}{totalRowIdx}"; + var existingCell = totalRow.Elements() + .FirstOrDefault(c => c.CellReference?.Value == cellRefStr) + ?? totalRow.AppendChild(new Cell { CellReference = cellRefStr }); + + var customFormula = rawVal.TrimStart('='); + tblCols[ci].TotalsRowFunction = TotalsRowFunctionValues.Custom; + tblCols[ci].TotalsRowLabel = null; + tblCols[ci].TotalsRowFormula = new TotalsRowFormula(customFormula); + existingCell.CellFormula = new CellFormula(customFormula); + existingCell.CellValue = null; + existingCell.DataType = null; + } } + // CONSISTENCY(xlsx/table-autoexpand): persist the opt-in flag as + // a custom-namespace attribute on so eager auto-grow + // survives reopen. Real Excel ignores unknown-namespace attrs. + if (properties.TryGetValue("autoExpand", out var aeRaw) && IsTruthy(aeRaw)) + SetTableAutoExpandMarker(table, true); + tableDefPart.Table = table; tableDefPart.Table.Save(); @@ -1287,7 +2663,7 @@ public string Add(string parentPath, string type, int? index, Dictionary().Count(); - tblWs.Save(); + SaveWorksheet(tblWorksheet); var tblIdx = tblWorksheet.TableDefinitionParts.ToList().IndexOf(tableDefPart) + 1; return $"/{tblSheetName}/table[{tblIdx}]"; @@ -1347,10 +2723,32 @@ public string Add(string parentPath, string type, int? index, Dictionary` and `x/y/width/height` are supplied, anchor + // wins with a warning — matches shape/picture/OLE convention. + int fromCol, fromRow, toCol, toRow; + if (properties.TryGetValue("anchor", out var chartAnchorStr) && !string.IsNullOrWhiteSpace(chartAnchorStr)) + { + if (properties.ContainsKey("width") || properties.ContainsKey("height") + || properties.ContainsKey("x") || properties.ContainsKey("y")) + Console.Error.WriteLine( + "Warning: 'x'/'y'/'width'/'height' are ignored when 'anchor' is provided (anchor defines the full rectangle)."); + if (!TryParseCellRangeAnchor(chartAnchorStr, out var cxFrom, out var cyFrom, out var cxTo, out var cyTo)) + throw new ArgumentException($"Invalid anchor: '{chartAnchorStr}'. Expected e.g. 'D2' or 'D2:J18'."); + fromCol = cxFrom; + fromRow = cyFrom; + if (cxTo < 0) { cxTo = fromCol + 8; cyTo = fromRow + 15; } + toCol = cxTo; + toRow = cyTo; + } + else + { + fromCol = properties.TryGetValue("x", out var xStr) ? ParseHelpers.SafeParseInt(xStr, "x") : 0; + fromRow = properties.TryGetValue("y", out var yStr) ? ParseHelpers.SafeParseInt(yStr, "y") : 0; + toCol = properties.TryGetValue("width", out var wStr) ? fromCol + ParseHelpers.SafeParseInt(wStr, "width") : fromCol + 8; + toRow = properties.TryGetValue("height", out var hStr) ? fromRow + ParseHelpers.SafeParseInt(hStr, "height") : fromRow + 15; + } // Extended chart types (cx:chart) — funnel, treemap, sunburst, boxWhisker, histogram if (ChartExBuilder.IsExtendedChartType(chartType)) @@ -1361,6 +2759,31 @@ public string Add(string parentPath, string type, int? index, Dictionary(); + using (var styleStream = ChartExStyleBuilder.BuildChartStyleXml(chartType, styleVariant)) + stylePart.FeedData(styleStream); + var colorStylePart = extChartPart.AddNewPart(); + using (var colorStream = LoadChartExResource("chartex-colors.xml")) + colorStylePart.FeedData(colorStream); + var cxRelId = drawingsPart.GetIdOfPart(extChartPart); var cxAnchor = new XDR.TwoCellAnchor(); cxAnchor.Append(new XDR.FromMarker( @@ -1384,7 +2807,10 @@ public string Add(string parentPath, string type, int? index, Dictionary ChartHelper.DeferredAddKeys.Contains(kv.Key)) + .Where(kv => ChartHelper.IsDeferredKey(kv.Key)) .ToDictionary(kv => kv.Key, kv => kv.Value); if (deferredProps.Count > 0) ChartHelper.SetChartProperties(chartPart, deferredProps); @@ -1448,7 +2874,10 @@ public string Add(string parentPath, string type, int? index, Dictionary ExcelMaxCol || minEndRow > ExcelMaxRow) + { + throw new ArgumentException( + $"pivot at {ptPosition} does not fit: computed end col={minEndColIdx} row={minEndRow} exceeds sheet dimensions (max XFD1048576)"); + } + } } var ptIdx = PivotTableHelper.CreatePivotTable( _doc.WorkbookPart!, ptWorksheet, sourceWorksheet, - sourceSheetName, sourceRef, position, properties); + sourceSheetName, sourceRef, ptPosition, properties); return $"/{ptSheetName}/pivottable[{ptIdx}]"; } + case "slicer": + { + return AddSlicer(parentPath, properties); + } + case "col" or "column": { var colSegments = parentPath.TrimStart('/').Split('/', 2); @@ -1574,11 +3056,15 @@ public string Add(string parentPath, string type, int? index, Dictionary= insertColIdx; })); if (colNeedsShift) + { ShiftColumnsRight(colWorksheet, insertColIdx); + DeleteCalcChainIfPresent(); + } - // Optionally set column width - if (properties.TryGetValue("width", out var widthStr) && double.TryParse(widthStr, out var width)) + // Optionally set column width (accepts bare char units or unit-qualified) + if (properties.TryGetValue("width", out var widthStr) && !string.IsNullOrWhiteSpace(widthStr)) { + var width = ParseColWidthChars(widthStr); var ws = GetSheet(colWorksheet); var columns = ws.GetFirstChild() ?? ws.PrependChild(new Columns()); columns.AppendChild(new Column @@ -1598,8 +3084,8 @@ public string Add(string parentPath, string type, int? index, Dictionary "today", @@ -1881,10 +3404,122 @@ public string Add(string parentPath, string type, int? index, Dictionary0")); + break; + } + case "containserrors": + { + cfNewRule = new ConditionalFormattingRule + { + Type = ConditionalFormatValues.ContainsErrors, + Priority = cfNewPriority + }; + var fc2 = cfNewSqref.Split(':')[0].TrimStart('$'); + cfNewRule.AppendChild(new Formula($"ISERROR({fc2})")); + break; + } + case "notcontainserrors": + { + cfNewRule = new ConditionalFormattingRule + { + Type = ConditionalFormatValues.NotContainsErrors, + Priority = cfNewPriority + }; + var fc3 = cfNewSqref.Split(':')[0].TrimStart('$'); + cfNewRule.AppendChild(new Formula($"NOT(ISERROR({fc3}))")); + break; + } + case "contains": + { + var ctext = properties.GetValueOrDefault("text", ""); + cfNewRule = new ConditionalFormattingRule + { + Type = ConditionalFormatValues.ContainsText, + Priority = cfNewPriority, + Text = ctext, + Operator = ConditionalFormattingOperatorValues.ContainsText + }; + var fc4 = cfNewSqref.Split(':')[0].TrimStart('$'); + cfNewRule.AppendChild(new Formula($"NOT(ISERROR(SEARCH(\"{ctext}\",{fc4})))")); + break; + } + case "notcontains": + { + var nctext = properties.GetValueOrDefault("text", ""); + cfNewRule = new ConditionalFormattingRule + { + Type = ConditionalFormatValues.NotContainsText, + Priority = cfNewPriority, + Text = nctext, + Operator = ConditionalFormattingOperatorValues.NotContains + }; + var fc5 = cfNewSqref.Split(':')[0].TrimStart('$'); + cfNewRule.AppendChild(new Formula($"ISERROR(SEARCH(\"{nctext}\",{fc5}))")); + break; + } + case "beginswith": + { + var btext = properties.GetValueOrDefault("text", ""); + cfNewRule = new ConditionalFormattingRule + { + Type = ConditionalFormatValues.BeginsWith, + Priority = cfNewPriority, + Text = btext, + Operator = ConditionalFormattingOperatorValues.BeginsWith + }; + var fc6 = cfNewSqref.Split(':')[0].TrimStart('$'); + cfNewRule.AppendChild(new Formula($"LEFT({fc6},{btext.Length})=\"{btext}\"")); + break; + } + case "endswith": + { + var etext = properties.GetValueOrDefault("text", ""); + cfNewRule = new ConditionalFormattingRule + { + Type = ConditionalFormatValues.EndsWith, + Priority = cfNewPriority, + Text = etext, + Operator = ConditionalFormattingOperatorValues.EndsWith + }; + var fc7 = cfNewSqref.Split(':')[0].TrimStart('$'); + cfNewRule.AppendChild(new Formula($"RIGHT({fc7},{etext.Length})=\"{etext}\"")); + break; + } default: throw new ArgumentException($"Unsupported CF type: {typeLower}"); } + ApplyStopIfTrue(cfNewRule, properties); + // Build DXF formatting if fill/font properties are provided var cfNewDxf = new DifferentialFormat(); bool cfNewHasDxf = false; @@ -1928,7 +3563,7 @@ public string Add(string parentPath, string type, int? index, Dictionary().Count(); - cfNewStylesheet.Save(); + _dirtyStylesheet = true; cfNewRule.FormatId = cfNewDxfs.Count!.Value - 1; } @@ -1964,7 +3599,7 @@ public string Add(string parentPath, string type, int? index, Dictionary X14.SparklineTypeValues.Column, - "stacked" => X14.SparklineTypeValues.Stacked, + "stacked" or "winloss" or "win-loss" => X14.SparklineTypeValues.Stacked, _ => X14.SparklineTypeValues.Line }; @@ -2048,6 +3683,24 @@ public string Add(string parentPath, string type, int? index, Dictionary() ?? throw new InvalidOperationException("Workbook has no sheets element"); @@ -2108,13 +3764,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 +3925,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) @@ -2263,23 +3956,53 @@ public string CopyFrom(string sourcePath, string targetParentPath, int? index) ?? throw new ArgumentException($"Row {rowIdx} not found"); var clone = (Row)row.CloneNode(true); + // R8-1: CloneNode preserves the source row's RowIndex and every + // cell's CellReference (e.g. "A1","B1"). Without rewriting these, + // the new row collides with the source (Excel shows one row at + // rowIdx, A2 appears empty) or is silently ignored. Compute the + // new rowIndex from the target sheet and rewrite all cell refs. + uint newRowIndex; if (index.HasValue) { var rows = targetSheetData.Elements().ToList(); if (index.Value >= 0 && index.Value < rows.Count) - rows[index.Value].InsertBeforeSelf(clone); + { + newRowIndex = rows[index.Value].RowIndex?.Value ?? (uint)(index.Value + 1); + // Shift existing rows at/after this position down by 1 + ShiftRowsDown(tgtWorksheet, (int)newRowIndex); + // Re-fetch sheetData (ShiftRowsDown may reorder) + targetSheetData = GetSheet(tgtWorksheet).GetFirstChild()!; + var afterRow = targetSheetData.Elements() + .LastOrDefault(r => (r.RowIndex?.Value ?? 0) < newRowIndex); + if (afterRow != null) afterRow.InsertAfterSelf(clone); + else targetSheetData.InsertAt(clone, 0); + } else + { + newRowIndex = (targetSheetData.Elements() + .LastOrDefault()?.RowIndex?.Value ?? 0u) + 1; targetSheetData.AppendChild(clone); + } } else { + newRowIndex = (targetSheetData.Elements() + .LastOrDefault()?.RowIndex?.Value ?? 0u) + 1; targetSheetData.AppendChild(clone); } + clone.RowIndex = newRowIndex; + foreach (var c in clone.Elements()) + { + var oldRef = c.CellReference?.Value; + if (string.IsNullOrEmpty(oldRef)) continue; + var m = Regex.Match(oldRef, @"^([A-Z]+)\d+$", RegexOptions.IgnoreCase); + if (m.Success) + c.CellReference = $"{m.Groups[1].Value.ToUpperInvariant()}{newRowIndex}"; + } + SaveWorksheet(tgtWorksheet); - var newRows = targetSheetData.Elements().ToList(); - var newIdx = newRows.IndexOf(clone) + 1; - return $"{targetParentPath}/row[{newIdx}]"; + return $"{targetParentPath}/row[{newRowIndex}]"; } throw new ArgumentException($"Copy not supported for: {elementRef}. Supported: row[N]"); diff --git a/src/officecli/Handlers/Excel/ExcelHandler.CheckOverflow.cs b/src/officecli/Handlers/Excel/ExcelHandler.CheckOverflow.cs new file mode 100644 index 000000000..f4b3aa098 --- /dev/null +++ b/src/officecli/Handlers/Excel/ExcelHandler.CheckOverflow.cs @@ -0,0 +1,244 @@ +// Copyright 2025 OfficeCli (officecli.ai) +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.RegularExpressions; +using DocumentFormat.OpenXml.Spreadsheet; +using OfficeCli.Core; + +namespace OfficeCli.Handlers; + +public partial class ExcelHandler +{ + // CONSISTENCY(text-overflow-check): mirrors PowerPointHandler.CheckShapeTextOverflow. + // Narrow scope vs PPT: only flags wrapText cells where row height is fixed too small + // (merged cells, or non-merged cells with explicit customHeight). Skips overflow-right + // on non-wrapText cells — that is Excel's normal rendering, not a bug. + + /// + /// Scan every sheet for cells whose wrapped text cannot fit inside the visible + /// row-height budget. Returns (path, message) pairs suitable for the `check` + /// command output. Mirrors PowerPointHandler's CheckShapeTextOverflow pattern. + /// + public List<(string Path, string Message)> CheckAllCellOverflow() + { + var issues = new List<(string, string)>(); + var stylesheet = _doc.WorkbookPart?.WorkbookStylesPart?.Stylesheet; + + foreach (var (sheetName, part) in GetWorksheets(_doc)) + { + var ws = part.Worksheet; + if (ws == null) continue; + var sheetData = ws.GetFirstChild(); + if (sheetData == null) continue; + + var ctx = BuildOverflowContext(ws, sheetData); + foreach (var row in sheetData.Elements()) + { + foreach (var cell in row.Elements()) + { + var cellRef = cell.CellReference?.Value; + if (string.IsNullOrEmpty(cellRef)) continue; + var msg = EvaluateCellOverflow(cell, cellRef, stylesheet, ctx); + if (msg != null) issues.Add(($"/{sheetName}/{cellRef}", msg)); + } + } + } + return issues; + } + + /// + /// Check overflow on a single cell identified by a DOM path like "/SheetName/A16" + /// or Excel notation "SheetName!A16". Returns warning or null. + /// Used by `add`/`set` command dispatchers to warn inline after edits. + /// + public string? CheckCellOverflow(string path) + { + if (string.IsNullOrEmpty(path)) return null; + + // Accept "/Sheet/A1", "Sheet!A1", or bare "A1" (falls back to first sheet). + string? sheetName = null; + string cellRef = path; + var slashIdx = -1; + if (path.StartsWith('/')) + { + slashIdx = path.IndexOf('/', 1); + if (slashIdx > 0) + { + sheetName = path[1..slashIdx]; + cellRef = path[(slashIdx + 1)..]; + } + } + else + { + var excl = path.IndexOf('!'); + if (excl > 0) + { + sheetName = path[..excl]; + cellRef = path[(excl + 1)..]; + } + } + + // Bail if the remainder isn't a plain cell ref (e.g. "A16" — reject "row[1]" etc.) + if (!Regex.IsMatch(cellRef, @"^[A-Za-z]+\d+$")) return null; + cellRef = cellRef.ToUpperInvariant(); + + var worksheets = GetWorksheets(_doc); + if (worksheets.Count == 0) return null; + var resolved = sheetName != null + ? worksheets.FirstOrDefault(w => w.Name.Equals(ResolveSheetName(sheetName!), StringComparison.OrdinalIgnoreCase)) + : worksheets[0]; + if (resolved.Part == null) return null; + + var ws = resolved.Part.Worksheet; + var sheetData = ws?.GetFirstChild(); + if (ws == null || sheetData == null) return null; + + var (startCol, startRow) = ParseCellReference(cellRef); + var cell = sheetData.Elements() + .FirstOrDefault(r => (int)(r.RowIndex?.Value ?? 0) == startRow) + ?.Elements() + .FirstOrDefault(c => string.Equals(c.CellReference?.Value, cellRef, StringComparison.OrdinalIgnoreCase)); + if (cell == null) return null; + + var stylesheet = _doc.WorkbookPart?.WorkbookStylesPart?.Stylesheet; + var ctx = BuildOverflowContext(ws, sheetData); + return EvaluateCellOverflow(cell, cellRef, stylesheet, ctx); + } + + private record OverflowContext( + Dictionary MergeMap, + Dictionary ColWidths, + Dictionary RowHeights, + double DefaultRowHeightPt, + double DefaultColWidthPt); + + private OverflowContext BuildOverflowContext(Worksheet ws, SheetData sheetData) + { + var rowHeights = new Dictionary(); + foreach (var row in sheetData.Elements()) + { + int rIdx = (int)(row.RowIndex?.Value ?? 0); + if (rIdx == 0 || row.Height?.Value == null) continue; + rowHeights[rIdx] = (row.Height.Value, row.CustomHeight?.Value == true); + } + var sheetFmtPr = ws.GetFirstChild(); + double defaultRowHeightPt = sheetFmtPr?.DefaultRowHeight?.Value ?? 15.0; + double defaultColWidthPt = sheetFmtPr?.DefaultColumnWidth?.Value != null + ? sheetFmtPr.DefaultColumnWidth.Value * 7.0017 * 0.75 + : 8.43 * 7.0017 * 0.75; + return new OverflowContext(BuildMergeMap(ws), GetColumnWidths(ws), rowHeights, + defaultRowHeightPt, defaultColWidthPt); + } + + private string? EvaluateCellOverflow(Cell cell, string cellRef, Stylesheet? stylesheet, OverflowContext ctx) + { + bool isMerged = ctx.MergeMap.TryGetValue(cellRef, out var mInfo); + if (isMerged && !mInfo.IsAnchor) return null; + + if (!TryGetCellAlignmentAndFont(cell, stylesheet, out var wrapText, out var fontSizePt)) + return null; + if (!wrapText) return null; + + var text = GetCellDisplayValue(cell); + if (string.IsNullOrEmpty(text)) return null; + + var (startCol, startRow) = ParseCellReference(cellRef); + int startColIdx = ColumnNameToIndex(startCol); + int rowSpan = isMerged ? mInfo.RowSpan : 1; + int colSpan = isMerged ? mInfo.ColSpan : 1; + + // Non-merged cells with wrapText default to auto-fit — only flag when someone + // explicitly pinned the row height (customHeight="1"). + if (!isMerged) + { + if (!ctx.RowHeights.TryGetValue(startRow, out var rh) || !rh.Custom) + return null; + } + + double usableWidth = 0; + for (int c = startColIdx; c < startColIdx + colSpan; c++) + usableWidth += ctx.ColWidths.TryGetValue(c, out var w) ? w : ctx.DefaultColWidthPt; + usableWidth -= 6; // ~3pt side padding total + + double usableHeight = 0; + for (int r = startRow; r < startRow + rowSpan; r++) + usableHeight += ctx.RowHeights.TryGetValue(r, out var rh2) ? rh2.Height : ctx.DefaultRowHeightPt; + usableHeight -= 4; // ~2pt top/bottom padding total + + if (usableWidth <= 0 || usableHeight <= 0) return null; + + double lineHeight = fontSizePt * 1.2; + int totalLines = CountWrappedLines(text, fontSizePt, usableWidth); + double needed = totalLines * lineHeight; + // Require at least ~30% of one line to be clipped. 1-2pt differences are + // rendering-metric noise and would drown real issues in false positives. + if (needed - usableHeight < lineHeight * 0.3) return null; + + string mergeNote = isMerged + ? $" (merged {cellRef}:{IndexToColumnName(startColIdx + colSpan - 1)}{startRow + rowSpan - 1})" + : ""; + string suggest; + if (isMerged) + { + double perRowPt = Math.Ceiling((needed + 4) / rowSpan / 5.0) * 5.0; + suggest = $"suggest.rowHeight={perRowPt:F0}pt per row (Excel does not auto-fit merged rows)"; + } + else + { + suggest = "suggest: clear customHeight to let Excel auto-fit"; + } + return $"text overflow{mergeNote}: {totalLines} lines at {fontSizePt:F1}pt need {needed:F0}pt, usable {usableHeight:F0}pt. {suggest}"; + } + + private static int CountWrappedLines(string text, double fontSizePt, double usableWidthPt) + { + // Newline handling mirrors PowerPointHandler.CheckTextOverflow: both literal + // and escaped "\n" split into separate paragraphs. + var paragraphs = text.Replace("\\n", "\n").Split('\n'); + int total = 0; + foreach (var segment in paragraphs) + { + if (segment.Length == 0) { total++; continue; } + int lines = 1; + double w = 0; + foreach (char ch in segment) + { + double cw = ParseHelpers.IsCjkOrFullWidth(ch) ? fontSizePt : fontSizePt * 0.55; + if (w + cw > usableWidthPt && w > 0) { lines++; w = cw; } + else { w += cw; } + } + total += lines; + } + return total; + } + + private static bool TryGetCellAlignmentAndFont( + Cell cell, Stylesheet? stylesheet, out bool wrapText, out double fontSizePt) + { + wrapText = false; + fontSizePt = 11.0; // Excel default body font + if (stylesheet == null) return true; + + var styleIndex = (int)(cell.StyleIndex?.Value ?? 0); + var cellFormats = stylesheet.CellFormats; + if (cellFormats == null) return true; + var xfList = cellFormats.Elements().ToList(); + if (styleIndex >= xfList.Count) return true; + var xf = xfList[styleIndex]; + + wrapText = xf.Alignment?.WrapText?.Value == true; + + var fonts = stylesheet.Fonts; + if (fonts != null) + { + var fontId = (int)(xf.FontId?.Value ?? 0); + var fontList = fonts.Elements().ToList(); + if (fontId < fontList.Count) + { + var sz = fontList[fontId].FontSize?.Val?.Value; + if (sz.HasValue) fontSizePt = sz.Value; + } + } + return true; + } +} diff --git a/src/officecli/Handlers/Excel/ExcelHandler.Helpers.cs b/src/officecli/Handlers/Excel/ExcelHandler.Helpers.cs index 16249a28f..777bd1fac 100644 --- a/src/officecli/Handlers/Excel/ExcelHandler.Helpers.cs +++ b/src/officecli/Handlers/Excel/ExcelHandler.Helpers.cs @@ -1,6 +1,7 @@ // Copyright 2025 OfficeCli (officecli.ai) // SPDX-License-Identifier: Apache-2.0 +using System.Reflection; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Spreadsheet; @@ -14,6 +15,485 @@ namespace OfficeCli.Handlers; public partial class ExcelHandler { + /// + /// Build an XDR BlipFill with an optional asvg:svgBlip extension when + /// the caller wires in an SVG image part. Keeps Add/Set picture paths + /// free of inline extension boilerplate. + /// + private static XDR.BlipFill BuildPictureBlipFill(string pngRelId, string? svgRelId) + => BuildPictureBlipFill(pngRelId, svgRelId, null); + + private static XDR.BlipFill BuildPictureBlipFill( + string pngRelId, string? svgRelId, Dictionary? properties) + { + var blip = new Drawing.Blip { Embed = pngRelId }; + // P6: opacity → (0..100000 scale). + // Accept percent (50, "50%") or fraction (0.5). 100/100%/1.0 → opaque (no node). + if (properties != null + && properties.TryGetValue("opacity", out var opRaw) + && !string.IsNullOrWhiteSpace(opRaw)) + { + var amt = ParseOpacityAmt(opRaw); + if (amt.HasValue && amt.Value < 100000) + blip.AppendChild(new Drawing.AlphaModulationFixed { Amount = amt.Value }); + } + if (!string.IsNullOrEmpty(svgRelId)) + OfficeCli.Core.SvgImageHelper.AppendSvgExtension(blip, svgRelId); + var blipFill = new XDR.BlipFill(blip); + // P7: crop.l/r/t/b or srcRect=l=..,r=..,t=..,b=.. → + // Values are percent (10 → 10000 in 1/1000 pct units). Emitted before . + var srcRect = ParseSrcRect(properties); + if (srcRect != null) + blipFill.AppendChild(srcRect); + blipFill.AppendChild(new Drawing.Stretch(new Drawing.FillRectangle())); + return blipFill; + } + + // Parse crop.l/r/t/b (percent, 10 → 10000) and compound srcRect="l=10,r=10,..." + // alias. Returns null when no crop props are present. + internal static Drawing.SourceRectangle? ParseSrcRect(Dictionary? properties) + { + if (properties == null) return null; + int? l = null, r = null, t = null, b = null; + if (properties.TryGetValue("srcRect", out var compound) && !string.IsNullOrWhiteSpace(compound)) + { + foreach (var piece in compound.Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + var kv = piece.Split('=', 2); + if (kv.Length != 2) continue; + var key = kv[0].Trim().ToLowerInvariant(); + var val = ParseCropPercent(kv[1]); + if (!val.HasValue) continue; + switch (key) { case "l": l = val; break; case "r": r = val; break; case "t": t = val; break; case "b": b = val; break; } + } + } + foreach (var (key, fld) in new[] { ("crop.l", "l"), ("crop.r", "r"), ("crop.t", "t"), ("crop.b", "b") }) + { + if (properties.TryGetValue(key, out var vs) && !string.IsNullOrWhiteSpace(vs)) + { + var v = ParseCropPercent(vs); + if (!v.HasValue) continue; + switch (fld) { case "l": l = v; break; case "r": r = v; break; case "t": t = v; break; case "b": b = v; break; } + } + } + // CONSISTENCY(picture-crop): Office-API-style `cropLeft`/`cropRight` + // /`cropTop`/`cropBottom` aliases. Accept fraction (<=1 → *100%) or + // percent (>1 → as-is); e.g. `cropLeft=0.1` and `cropLeft=10` both + // mean 10% crop from left. + foreach (var (key, fld) in new[] { ("cropLeft", "l"), ("cropRight", "r"), ("cropTop", "t"), ("cropBottom", "b") }) + { + if (properties.TryGetValue(key, out var vs) && !string.IsNullOrWhiteSpace(vs)) + { + var v = ParseCropFractionOrPercent(vs); + if (!v.HasValue) continue; + switch (fld) { case "l": l = v; break; case "r": r = v; break; case "t": t = v; break; case "b": b = v; break; } + } + } + if (l == null && r == null && t == null && b == null) return null; + var sr = new Drawing.SourceRectangle(); + if (l.HasValue) sr.Left = l.Value; + if (r.HasValue) sr.Right = r.Value; + if (t.HasValue) sr.Top = t.Value; + if (b.HasValue) sr.Bottom = b.Value; + return sr; + } + + private static int? ParseCropPercent(string raw) + { + var t = raw.Trim(); + if (t.EndsWith("%")) t = t[..^1].Trim(); + if (!double.TryParse(t, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var v)) + return null; + if (double.IsNaN(v) || double.IsInfinity(v)) return null; + return (int)Math.Round(v * 1000.0); + } + + // CONSISTENCY(picture-crop): For `cropLeft`/`cropRight`/`cropTop`/ + // `cropBottom` keys we treat input ambiguously: <=1 is a fraction + // (0.1 → 10%), >1 is percent (10 → 10%). Trailing `%` is still + // honored explicitly. Returns 1/1000 pct units, same as OOXML. + private static int? ParseCropFractionOrPercent(string raw) + { + var t = raw.Trim(); + bool explicitPct = t.EndsWith("%"); + if (explicitPct) t = t[..^1].Trim(); + if (!double.TryParse(t, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var v)) + return null; + if (double.IsNaN(v) || double.IsInfinity(v)) return null; + double pct = (!explicitPct && v > 0 && v <= 1.0) ? v * 100.0 : v; + return (int)Math.Round(pct * 1000.0); + } + + // Parse opacity percent/fraction to OOXML alphaModFix amt scale (0..100000). + // Returns null if the input is not parseable; 100000 (fully opaque) is returned + // as-is so the caller can decide to omit the node. + internal static int? ParseOpacityAmt(string raw) + { + var t = raw.Trim(); + if (t.EndsWith("%")) t = t[..^1].Trim(); + if (!double.TryParse(t, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var v)) + return null; + if (double.IsNaN(v) || double.IsInfinity(v)) return null; + // Fraction form (0..1) → treat as 0..100%; else percent. + double pct = v <= 1.0 && v > 0 ? v * 100.0 : v; + if (pct < 0) pct = 0; if (pct > 100) pct = 100; + return (int)Math.Round(pct * 1000.0); + } + + // Build an element with an initial Transform2D, applying any + // user-supplied rotation/flip props. Keeps the Add.cs path readable. + // CONSISTENCY(scheme-color): Map a scheme-color name + // ("accent1"-"accent6", "lt1"/"dk1", "lt2"/"dk2", "bg1"/"tx1", "bg2"/"tx2", + // "hlink", "folHlink") to the OOXML theme index used by TabColor.Theme, + // color.Theme on fonts, etc. Returns null for non-scheme inputs — callers + // then fall back to srgbClr (hex) handling. + internal static uint? ExcelSchemeColorNameToThemeIndex(string s) => + s?.Trim().ToLowerInvariant() switch + { + "lt1" or "bg1" => 0u, + "dk1" or "tx1" => 1u, + "lt2" or "bg2" => 2u, + "dk2" or "tx2" => 3u, + "accent1" => 4u, + "accent2" => 5u, + "accent3" => 6u, + "accent4" => 7u, + "accent5" => 8u, + "accent6" => 9u, + "hlink" => 10u, + "folhlink" => 11u, + _ => null + }; + + // CONSISTENCY(rc-units): Row height is in points in OOXML; this helper + // accepts bare numbers (treated as points, backward compat) as well as + // unit-qualified "40pt", "40px", "1cm", "0.5in" and returns points. + internal static double ParseRowHeightPoints(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Row height cannot be empty."); + var trimmed = value.Trim(); + double pts; + // Bare number → points (legacy behavior) + if (double.TryParse(trimmed, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var bare) + && !char.IsLetter(trimmed[^1])) + { + if (double.IsNaN(bare) || double.IsInfinity(bare)) + throw new ArgumentException($"Invalid 'height' value: '{value}'. Expected a finite number (row height in points, e.g. 15.75)."); + pts = bare; + } + else + { + // Unit-qualified: convert via EMU then back to points. + try + { + var emu = OfficeCli.Core.EmuConverter.ParseEmu(trimmed); + pts = emu / 12700.0; + } + catch (Exception ex) + { + throw new ArgumentException($"Invalid 'height' value: '{value}'. Expected a finite number or unit-qualified value (e.g. 15.75, 40pt, 40px, 1cm, 0.5in).", ex); + } + } + // DEFERRED(xlsx/row-height-validation) RC2: Excel row height is bounded + // [0, 409.5] points. Values outside this range are rejected by Excel at + // open time (file silently repaired), so validate at Set time. + if (pts < 0 || pts > 409.5) + throw new ArgumentException($"Invalid 'height' value: '{value}'. Row height must be between 0 and 409.5 points."); + return pts; + } + + // CONSISTENCY(rc-units): Column width is in "maximum digit width" char + // units (Calibri 11pt ≈ 7px per char). Accepts bare number (char units, + // legacy) or unit-qualified px/cm/in/pt — physical sizes converted via + // the 7-px-per-char approximation Excel uses internally. + internal static double ParseColWidthChars(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Column width cannot be empty."); + var trimmed = value.Trim(); + double chars; + if (double.TryParse(trimmed, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var bare) + && !char.IsLetter(trimmed[^1])) + { + if (double.IsNaN(bare) || double.IsInfinity(bare)) + throw new ArgumentException($"Invalid 'width' value: '{value}'. Expected a finite number (column width in char units, e.g. 8.43)."); + chars = bare; + } + else + { + try + { + var emu = OfficeCli.Core.EmuConverter.ParseEmu(trimmed); + // 9525 EMU = 1 px; 7 px ≈ 1 char unit (Calibri 11pt MDW baseline) + var px = emu / 9525.0; + chars = px / 7.0; + } + catch (Exception ex) + { + throw new ArgumentException($"Invalid 'width' value: '{value}'. Expected a finite number or unit-qualified value (e.g. 8.43, 20px, 2cm, 1in, 60pt).", ex); + } + } + // DEFERRED(xlsx/row-height-validation) RC2: Excel column width is bounded + // [0, 255] character units. Validate at Set time. + if (chars < 0 || chars > 255) + throw new ArgumentException($"Invalid 'width' value: '{value}'. Column width must be between 0 and 255 character units."); + return chars; + } + + internal static XDR.Picture BuildPictureElementWithTransform( + uint picId, string alt, string imgRelId, string? svgRelId, + Dictionary properties) + { + var xfrm = new Drawing.Transform2D( + new Drawing.Offset { X = 0, Y = 0 }, + new Drawing.Extents { Cx = 0, Cy = 0 }); + ApplyTransform2DRotationFlip(xfrm, properties); + // P13: accept user-supplied `name=` to override the auto-generated + // "Picture {id}" label stamped into xdr:cNvPr @name. + // P9: `altText=` alias for `alt=` (Description attribute). + // P11: `title=` populates the OOXML @title attribute (distinct from alt). + var picName = properties.GetValueOrDefault("name"); + if (string.IsNullOrWhiteSpace(picName)) + picName = $"Picture {picId}"; + var picTitle = properties.GetValueOrDefault("title"); + var cNvPr = new XDR.NonVisualDrawingProperties { Id = picId, Name = picName, Description = alt }; + if (!string.IsNullOrWhiteSpace(picTitle)) + cNvPr.Title = picTitle; + return new XDR.Picture( + new XDR.NonVisualPictureProperties( + cNvPr, + new XDR.NonVisualPictureDrawingProperties(new Drawing.PictureLocks { NoChangeAspect = true }) + ), + BuildPictureBlipFill(imgRelId, svgRelId, properties), + new XDR.ShapeProperties( + xfrm, + new Drawing.PresetGeometry(new Drawing.AdjustValueList()) { Preset = Drawing.ShapeTypeValues.Rectangle } + ) + ); + } + + // Map a table-column totals-row function token to its OOXML enum and the + // SUBTOTAL function code Excel uses. Unknown tokens fall back to SUM (109) + // — previously all non-"sum" tokens silently became SUM; this keeps the + // same fallback for unknown tokens but routes known ones to the right + // enum + SUBTOTAL code. + internal static (TotalsRowFunctionValues, int) MapTotalsRowFunction(string tok) => tok switch + { + "sum" => (TotalsRowFunctionValues.Sum, 109), + "average" or "avg" => (TotalsRowFunctionValues.Average, 101), + "count" => (TotalsRowFunctionValues.Count, 103), + "countnums" or "countnumbers" => (TotalsRowFunctionValues.CountNumbers, 102), + "max" or "maximum" => (TotalsRowFunctionValues.Maximum, 104), + "min" or "minimum" => (TotalsRowFunctionValues.Minimum, 105), + "stddev" or "stdev" => (TotalsRowFunctionValues.StandardDeviation, 107), + "var" or "variance" => (TotalsRowFunctionValues.Variance, 110), + "none" or "label" or "" => (TotalsRowFunctionValues.None, 0), + "custom" => (TotalsRowFunctionValues.Custom, 109), + _ => (TotalsRowFunctionValues.Sum, 109) + }; + + // Apply `rotation=` / `flip=h|v|both|hv|vh` from the user properties + // dict to a Drawing.Transform2D node. Silently no-op on missing props. + // Mirrors PowerPointHandler's shape rotation semantics: angles are in + // degrees (positive = clockwise), OOXML stores them as 60000ths of a + // degree in the `rot` attribute. Values are normalized modulo 360. + internal static void ApplyTransform2DRotationFlip( + Drawing.Transform2D xfrm, Dictionary properties) + { + if (xfrm == null) return; + if (properties.TryGetValue("rotation", out var rotStr) && !string.IsNullOrWhiteSpace(rotStr)) + { + if (double.TryParse(rotStr, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var deg)) + { + var normalized = ((deg % 360) + 360) % 360; + xfrm.Rotation = (int)Math.Round(normalized * 60000); + } + } + if (properties.TryGetValue("flip", out var flipStr) && !string.IsNullOrWhiteSpace(flipStr)) + { + var f = flipStr.Trim().ToLowerInvariant(); + bool flipH = f == "h" || f == "horizontal" || f == "both" || f == "hv" || f == "vh"; + bool flipV = f == "v" || f == "vertical" || f == "both" || f == "hv" || f == "vh"; + if (flipH) xfrm.HorizontalFlip = true; + if (flipV) xfrm.VerticalFlip = true; + } + // CONSISTENCY(shape-flip): accept Office-API-style `flipH=true`, + // `flipV=true`, `flipBoth=true` aliases in addition to the compact + // `flip=h|v|both`. Boolean semantics follow IsTruthy (true/1/yes). + if (properties.TryGetValue("flipH", out var flipHStr) && IsTruthy(flipHStr)) + xfrm.HorizontalFlip = true; + if (properties.TryGetValue("flipV", out var flipVStr) && IsTruthy(flipVStr)) + xfrm.VerticalFlip = true; + if (properties.TryGetValue("flipBoth", out var flipBothStr) && IsTruthy(flipBothStr)) + { + xfrm.HorizontalFlip = true; + xfrm.VerticalFlip = true; + } + } + + // SH6 — build a two/three-stop linear gradient fill for shape/textbox from + // a "C1-C2[-C3][:angle]" spec. Mirrors the chart gradient parser used by + // Core/Chart/ChartHelper.Builder.cs:BuildFillElement so chart and shape + // gradient syntax stay consistent. + internal static Drawing.GradientFill BuildShapeGradientFill(string spec) + { + var colonIdx = spec.LastIndexOf(':'); + var anglePart = 0; + string colorsPart; + if (colonIdx > 6 && int.TryParse(spec[(colonIdx + 1)..], + System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var ang)) + { + anglePart = ang; + colorsPart = spec[..colonIdx]; + } + else + { + colorsPart = spec; + } + var colors = colorsPart.Split('-').Select(c => c.Trim()).Where(c => c.Length > 0).ToArray(); + if (colors.Length < 2) + throw new ArgumentException( + $"gradientFill requires at least two '-' separated colors; got '{spec}'."); + var gradFill = new Drawing.GradientFill { RotateWithShape = true }; + var gsLst = new Drawing.GradientStopList(); + for (int i = 0; i < colors.Length; i++) + { + var pos = (int)(i * 100000.0 / (colors.Length - 1)); + var (rgb, _) = ParseHelpers.SanitizeColorForOoxml(colors[i]); + var gs = new Drawing.GradientStop { Position = pos }; + gs.AppendChild(new Drawing.RgbColorModelHex { Val = rgb }); + gsLst.AppendChild(gs); + } + gradFill.AppendChild(gsLst); + gradFill.AppendChild(new Drawing.LinearGradientFill + { + Angle = anglePart * 60000, + Scaled = true + }); + return gradFill; + } + + // Normalize user-supplied data-validation formula values so Excel accepts + // them. `type=list` auto-quotes bare lists. `type=time` accepts HH:MM / + // HH:MM:SS and converts to the Excel time serial fraction. `type=date` + // accepts YYYY-MM-DD and converts to the Excel date serial. `type=custom` + // strips a leading '=' since OOXML `` expects the formula body + // without one. + internal static string NormalizeValidationFormula(string value, DataValidationValues? type) + { + if (string.IsNullOrEmpty(value)) return value; + if (type == DataValidationValues.List) + { + // list: wrap bare "a,b,c" in quotes; leave cell/range refs and + // already-quoted literals alone. V1: a leading `=` signals a + // formula-ref (e.g. `=VOpts`, `=$Z$1:$Z$5`) — strip the `=` + // (OOXML `` expects the body without one) and + // pass through without quoting. + if (value.StartsWith("=")) + return value.Substring(1); + if (value.StartsWith("\"") || value.Contains("!") || value.Contains(":")) + return value; + if (value.Contains(',')) + return $"\"{value}\""; + return value; + } + if (type == DataValidationValues.Time) + { + var m = System.Text.RegularExpressions.Regex.Match(value.Trim(), @"^(\d{1,2}):(\d{2})(?::(\d{2}))?$"); + if (m.Success) + { + var h = int.Parse(m.Groups[1].Value); + var mn = int.Parse(m.Groups[2].Value); + var s = m.Groups[3].Success ? int.Parse(m.Groups[3].Value) : 0; + var frac = (h * 3600 + mn * 60 + s) / 86400.0; + return frac.ToString("0.###############", System.Globalization.CultureInfo.InvariantCulture); + } + } + if (type == DataValidationValues.Date) + { + if (System.DateTime.TryParseExact(value.Trim(), "yyyy-MM-dd", + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, out var dt)) + { + // Excel date serial: days since 1899-12-30 (accounts for the + // 1900 leap bug baseline). + var epoch = new System.DateTime(1899, 12, 30); + return ((int)(dt - epoch).TotalDays).ToString(System.Globalization.CultureInfo.InvariantCulture); + } + } + if (type == DataValidationValues.Custom) + { + if (value.StartsWith("=")) + return value.Substring(1); + } + return value; + } + + // Returns true if `s` would parse as a valid cell reference (e.g. A1, + // TBL1, XFD1048576). Excel refuses to open files whose table names match + // this pattern — the name is ambiguous with a cell address. + internal static bool LooksLikeCellReference(string? s) + { + if (string.IsNullOrEmpty(s)) return false; + var m = System.Text.RegularExpressions.Regex.Match(s, @"^\$?([A-Za-z]{1,3})\$?([0-9]+)$"); + if (!m.Success) return false; + var col = m.Groups[1].Value.ToUpperInvariant(); + var colIdx = 0; + foreach (var ch in col) colIdx = colIdx * 26 + (ch - 'A' + 1); + if (colIdx < 1 || colIdx > 16384) return false; + if (!long.TryParse(m.Groups[2].Value, out var row) || row < 1 || row > 1048576) return false; + return true; + } + + // R7-3: heuristic — is `s` a formula body (SUM(...), A1+B1, IF(...)), + // as opposed to a pure range-ref body (Sheet1!$A$1:$A$5, A1:A5, A1)? + // Used to decide whether to flip so Excel + // evaluates the defined name on first open. Range-only bodies don't + // need forced recalc; function calls and operator expressions do. + internal static bool LooksLikeFormulaBody(string? s) + { + if (string.IsNullOrEmpty(s)) return false; + var t = s.Trim(); + if (t.Length == 0) return false; + // A function call or arithmetic expression contains '(' or an + // operator outside a sheet-qualified range. + if (t.Contains('(')) return true; + if (t.IndexOfAny(new[] { '+', '-', '*', '/', '^', '&', '<', '>', '=', '%' }) >= 0) + return true; + return false; + } + + // Make a string safe to use as an Excel table name, displayName, or + // tableColumn name. Excel refuses to open files where these identifiers + // look like a cell reference ("tbl1" → column TBL row 1) or are purely + // numeric ("30"). + // + // When `userProvided` is true (user explicitly passed --prop name=T1), + // honor the name verbatim — callers who type `name=T1` expect a table + // named `T1`, not `T1_`. Excel itself accepts these table identifiers + // (the cell-reference ambiguity rule applies to defined names, not to + // tables), so silently rewriting loses fidelity with no gain. + // + // When `userProvided` is false (auto-derived default such as + // `Table{id}`, or tableColumn name read from a header cell) we suffix + // "_" on cell-reference-shaped names to keep defaults safe. + internal static string SanitizeTableIdentifier(string? name, bool userProvided = false) + { + if (string.IsNullOrEmpty(name)) return "_"; + if (userProvided) return name; + var looksLikeRef = LooksLikeCellReference(name) + || System.Text.RegularExpressions.Regex.IsMatch(name, @"^[0-9]+$"); + return looksLikeRef ? name + "_" : name; + } + // ==================== Path Normalization ==================== /// @@ -107,14 +587,137 @@ private static int NextCfPriority(Worksheet ws) return max + 1; } + // T6 — built-in Excel table style names. Unknown names are rejected at + // Add time rather than silently passed through to Excel. + private static readonly HashSet _builtInTableStyles = BuildBuiltInTableStyles(); + private static HashSet BuildBuiltInTableStyles() + { + var set = new HashSet(StringComparer.Ordinal); + foreach (var tier in new[] { "Light", "Medium", "Dark" }) + for (int i = 1; i <= 28; i++) + set.Add($"TableStyle{tier}{i}"); + // Pivot styles — users may apply a pivot style to a plain table. + foreach (var tier in new[] { "Light", "Medium", "Dark" }) + for (int i = 1; i <= 28; i++) + set.Add($"PivotStyle{tier}{i}"); + set.Add("TableStyleNone"); + return set; + } + + internal void ValidateTableStyleName(string? styleName) + { + if (string.IsNullOrEmpty(styleName)) return; + if (_builtInTableStyles.Contains(styleName)) return; + // Workbook-level customStyles live under on the stylesheet. + var styles = _doc.WorkbookPart?.WorkbookStylesPart?.Stylesheet; + var tableStyles = styles?.GetFirstChild(); + if (tableStyles != null) + { + foreach (var ts in tableStyles.Elements()) + if (ts.Name?.Value == styleName) return; + } + throw new ArgumentException( + $"Unknown table style: '{styleName}'. Use a built-in name like " + + $"'TableStyleMedium2', or register a custom style on the workbook first."); + } + + /// + /// CF2: stamp the stopIfTrue attribute onto a CF rule when the user + /// passed `stopIfTrue=true`. Centralized so every `add cf` branch + /// (databar / colorscale / iconset / formulacf / cellIs / topN / ...) + /// honors the same flag. + /// + internal static void ApplyStopIfTrue(ConditionalFormattingRule rule, Dictionary properties) + { + if (properties.TryGetValue("stopIfTrue", out var v) && ParseHelpers.IsTruthy(v)) + rule.StopIfTrue = true; + } + /// - /// Save worksheet with automatic schema-order reorder. - /// Must be used instead of ws.Save() to prevent element ordering violations. + /// Ensure the worksheet root declares `xmlns:x14` + `mc:Ignorable="x14"`. + /// Without both, Excel silently drops the x14 extension block where + /// sparklines, dataBar 2010+ extensions, and other Office2010 features + /// live. CONSISTENCY(x14-ignorable): same pattern the sparkline branch + /// uses inline. /// - private static void SaveWorksheet(WorksheetPart part) + internal static void EnsureWorksheetX14Ignorable(Worksheet ws) { - ReorderWorksheetChildren(GetSheet(part)); - GetSheet(part).Save(); + const string mcNs = "http://schemas.openxmlformats.org/markup-compatibility/2006"; + const string x14Ns = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"; + if (ws.LookupNamespace("mc") == null) + ws.AddNamespaceDeclaration("mc", mcNs); + if (ws.LookupNamespace("x14") == null) + ws.AddNamespaceDeclaration("x14", x14Ns); + var ignorable = ws.MCAttributes?.Ignorable?.Value ?? ""; + if (!ignorable.Split(' ').Contains("x14")) + { + ws.MCAttributes ??= new MarkupCompatibilityAttributes(); + ws.MCAttributes.Ignorable = string.IsNullOrEmpty(ignorable) ? "x14" : $"{ignorable} x14"; + } + } + + /// + /// Append an x14:conditionalFormatting block to the worksheet's extLst under + /// ext URI `{78C0D931-6437-407d-A8EE-F0AAD7539E65}`. Creates the extension + /// on first call, appends to the existing x14:conditionalFormattings + /// container on subsequent calls. Also ensures mc:Ignorable="x14" is set. + /// + internal static void EnsureWorksheetX14ConditionalFormatting(Worksheet ws, X14.ConditionalFormatting x14Cf) + { + const string cfExtUri = "{78C0D931-6437-407d-A8EE-F0AAD7539E65}"; + const string x14Ns = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"; + + EnsureWorksheetX14Ignorable(ws); + + var extList = ws.GetFirstChild() ?? ws.AppendChild(new WorksheetExtensionList()); + var ext = extList.Elements().FirstOrDefault(e => e.Uri == cfExtUri); + X14.ConditionalFormattings cfContainer; + if (ext != null) + { + cfContainer = ext.GetFirstChild() + ?? ext.AppendChild(new X14.ConditionalFormattings()); + } + else + { + ext = new WorksheetExtension { Uri = cfExtUri }; + ext.AddNamespaceDeclaration("x14", x14Ns); + cfContainer = new X14.ConditionalFormattings(); + ext.Append(cfContainer); + extList.Append(ext); + } + cfContainer.Append(x14Cf); + } + + /// + /// Mark a worksheet as dirty. The actual save (with schema-order reorder) is + /// deferred to which runs in Dispose(). + /// This replaces per-mutation Save() calls — batch operations over many cells + /// previously triggered one disk write per cell (O(n) saves); now they all + /// flush in a single pass at the end. + /// + private void SaveWorksheet(WorksheetPart part) + { + _dirtyWorksheets.Add(part); + } + + /// + /// Flush all pending worksheet and stylesheet saves. Called from Dispose(). + /// Each dirty WorksheetPart is reordered and saved exactly once regardless + /// of how many mutations targeted it. + /// + private void FlushDirtyParts() + { + foreach (var part in _dirtyWorksheets) + { + ReorderWorksheetChildren(GetSheet(part)); + GetSheet(part).Save(); + } + _dirtyWorksheets.Clear(); + if (_dirtyStylesheet) + { + _doc.WorkbookPart?.WorkbookStylesPart?.Stylesheet?.Save(); + _dirtyStylesheet = false; + } } /// @@ -262,10 +865,12 @@ private static void ReorderWorksheetChildren(Worksheet ws) private Workbook GetWorkbook() => _doc.WorkbookPart?.Workbook ?? throw new InvalidOperationException("Corrupt file: workbook missing"); - private List<(string Name, WorksheetPart Part)> GetWorksheets() + private List<(string Name, WorksheetPart Part)> GetWorksheets() => GetWorksheets(_doc); + + private static List<(string Name, WorksheetPart Part)> GetWorksheets(SpreadsheetDocument doc) { var result = new List<(string, WorksheetPart)>(); - var workbook = _doc.WorkbookPart?.Workbook; + var workbook = doc.WorkbookPart?.Workbook; if (workbook == null) return result; var sheets = workbook.GetFirstChild(); @@ -276,7 +881,7 @@ private Workbook GetWorkbook() => var name = sheet.Name?.Value ?? "?"; var id = sheet.Id?.Value; if (id == null) continue; - var part = (WorksheetPart)_doc.WorkbookPart!.GetPartById(id); + var part = (WorksheetPart)doc.WorkbookPart!.GetPartById(id); result.Add((name, part)); } @@ -324,7 +929,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 +949,31 @@ 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) - return "=" + cell.CellFormula.Text; + { + if (evaluator != null) + { + var evalResult = evaluator.TryEvaluateFull(cell.CellFormula.Text); + if (evalResult != null && !evalResult.IsError) + return evalResult.ToCellValueText(); + } + return "=" + Core.ModernFunctionQualifier.Unqualify(cell.CellFormula.Text); + } + + // Apply number format to numeric cells (dates, percentages, etc.) + // Mirrors POI DataFormatter: raw double + format code → display string + if (cell.DataType == null && double.TryParse(value, + System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var numVal)) + { + var (numFmtId, formatCode) = ExcelDataFormatter.GetCellFormat(cell, _doc.WorkbookPart); + if (numFmtId > 0) + { + var formatted = ExcelDataFormatter.TryFormat(numVal, numFmtId, formatCode); + if (formatted != null) return formatted; + } + } return value; } @@ -354,8 +981,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 +1009,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,17 +1034,51 @@ 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; + var formula = cell.CellFormula?.Text is { } fText + ? Core.ModernFunctionQualifier.Unqualify(fText) + : null; string type; if (cell.DataType?.HasValue != true) - type = "Number"; + { + // R12-F2: a formula whose cached value is a non-numeric string + // should report type=String, not the Number default. Excel itself + // writes t="str" on such cells; external tools or our own writer + // occasionally leave the attribute off, so infer from the cached + // value content. + var raw = cell.CellValue?.Text; + if (formula != null + && !string.IsNullOrEmpty(raw) + && !double.TryParse(raw, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out _)) + type = "String"; + else + type = "Number"; + } else if (cell.DataType.Value == CellValues.String) type = "String"; else if (cell.DataType.Value == CellValues.SharedString) @@ -423,10 +1094,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 +1116,21 @@ 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("=") && + !FormulaReferencesMissingSheet(formula)) + { + // R9-1: do NOT fall back to an evaluated cachedValue when the + // formula references a sheet that no longer exists in the + // workbook. Otherwise cross-sheet refs whose target sheet + // was removed silently evaluate to "0" (see + // FormulaEvaluator.ResolveSheetCellResult), reporting a + // stale/fake cached value where Excel would show #REF!. + node.Format["cachedValue"] = displayText; + } } // Array formula readback — keys match Set input if (cell.CellFormula?.FormulaType?.Value == CellFormulaValues.Array) @@ -454,7 +1139,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) @@ -495,10 +1180,10 @@ private DocumentNode CellToNode(string sheetName, Cell cell, WorksheetPart? part if (fonts != null && fontId < (uint)fonts.Elements().Count()) { var font = fonts.Elements().ElementAt((int)fontId); - if (font.Bold != null) { node.Format["bold"] = true; } + if (font.Bold != null) { node.Format["font.bold"] = true; } if (font.Italic != null) { - node.Format["italic"] = true; + node.Format["font.italic"] = true; } if (font.Strike != null) node.Format["font.strike"] = true; if (font.Underline != null) @@ -621,6 +1306,17 @@ private DocumentNode CellToNode(string sheetName, Cell cell, WorksheetPart? part node.Format["alignment.indent"] = alignment.Indent.Value.ToString(); if (alignment.ShrinkToFit?.Value == true) node.Format["alignment.shrinkToFit"] = true; + // DEFERRED(xlsx/cell-reading-order) CE10 — canonical + // readback as string form (context/ltr/rtl). + if (alignment.ReadingOrder?.HasValue == true && alignment.ReadingOrder.Value != 0) + { + node.Format["alignment.readingOrder"] = alignment.ReadingOrder.Value switch + { + 1u => "ltr", + 2u => "rtl", + _ => "context" + }; + } } // Number format readback @@ -765,6 +1461,34 @@ private static bool IsCellInMergeRange(string cellRef, string? rangeRef) && cellColIdx >= ColumnNameToIndex(startCol) && cellColIdx <= ColumnNameToIndex(endCol); } + // T4 — rectangle intersection over A1:B2 style ranges (case-insensitive). + // Returns true if two inclusive cell ranges share at least one cell. + private static bool RangesOverlap(string rangeA, string rangeB) + { + if (string.IsNullOrEmpty(rangeA) || string.IsNullOrEmpty(rangeB)) return false; + var (a1, a2) = SplitRange(rangeA); + var (b1, b2) = SplitRange(rangeB); + var (aSc, aSr) = ParseCellReference(a1); + var (aEc, aEr) = ParseCellReference(a2); + var (bSc, bSr) = ParseCellReference(b1); + var (bEc, bEr) = ParseCellReference(b2); + int aSci = ColumnNameToIndex(aSc), aEci = ColumnNameToIndex(aEc); + int bSci = ColumnNameToIndex(bSc), bEci = ColumnNameToIndex(bEc); + // Normalize (callers may pass B2:A1 theoretically) + if (aSci > aEci) (aSci, aEci) = (aEci, aSci); + if (bSci > bEci) (bSci, bEci) = (bEci, bSci); + if (aSr > aEr) (aSr, aEr) = (aEr, aSr); + if (bSr > bEr) (bSr, bEr) = (bEr, bSr); + return aSci <= bEci && bSci <= aEci && aSr <= bEr && bSr <= aEr; + } + + private static (string, string) SplitRange(string range) + { + if (!range.Contains(':')) return (range, range); + var p = range.Split(':'); + return (p[0], p[1]); + } + private DocumentNode GetCellRange(string sheetName, SheetData sheetData, string range, int depth, WorksheetPart? part = null) { var parts = range.Split(':'); @@ -798,13 +1522,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 { @@ -829,9 +1554,22 @@ private DocumentNode GetCellRange(string sheetName, SheetData sheetData, string private static (int Rank, double NumVal, string StrVal) ParseSortValue(string value) { if (string.IsNullOrEmpty(value)) return (2, 0.0, ""); + // Excel treats NaN / Infinity / -Infinity as text, not numbers. double.TryParse + // happily accepts them though, which would make sort order dependent on whether + // the exact casing matched double.TryParse's spec vs not — classify explicitly. + if (value.Equals("NaN", StringComparison.Ordinal) + || value.Equals("Infinity", StringComparison.Ordinal) + || value.Equals("-Infinity", StringComparison.Ordinal) + || value.Equals("+Infinity", StringComparison.Ordinal)) + return (1, 0.0, value); if (double.TryParse(value, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var num)) + { + // Defensive: even non-literal inputs can produce non-finite doubles + // (e.g. "1e999" overflows to +Infinity). Keep those in the string bucket. + if (!double.IsFinite(num)) return (1, 0.0, value); return (0, num, ""); + } return (1, 0.0, value); } @@ -848,24 +1586,63 @@ private static (int Rank, double NumVal, string StrVal) ParseSortValue(string va return null; } - private static Cell FindOrCreateCell(SheetData sheetData, string cellRef) + /// + /// Find or create the Row for the given 1-based row index, using the per-SheetData + /// row index cache to avoid O(n) linear scans. New rows are inserted in sorted order + /// via binary search on the cache (O(log n)). + /// + private Row FindOrCreateRow(SheetData sheetData, uint rowIdx) { - var (colName, rowIdx) = ParseCellReference(cellRef); + _rowIndex ??= new(); + if (!_rowIndex.TryGetValue(sheetData, out var rowMap)) + { + rowMap = new SortedList(); + foreach (var existingRow in sheetData.Elements()) + if (existingRow.RowIndex?.HasValue == true) + rowMap[existingRow.RowIndex.Value] = existingRow; + _rowIndex[sheetData] = rowMap; + } + + if (rowMap.TryGetValue(rowIdx, out var row)) + return row; - // Find or create row - var row = sheetData.Elements().FirstOrDefault(r => r.RowIndex?.Value == rowIdx); - if (row == null) + row = new Row { RowIndex = rowIdx }; + // Binary search for predecessor in O(log n) + var keys = rowMap.Keys; + int lo = 0, hi = keys.Count - 1, predPos = -1; + while (lo <= hi) { - row = new Row { RowIndex = (uint)rowIdx }; - // Insert in order - var after = sheetData.Elements().LastOrDefault(r => (r.RowIndex?.Value ?? 0) < rowIdx); - if (after != null) - after.InsertAfterSelf(row); - else - sheetData.InsertAt(row, 0); + int mid = (lo + hi) / 2; + if (keys[mid] < rowIdx) { predPos = mid; lo = mid + 1; } + else hi = mid - 1; } + if (predPos >= 0) + rowMap.Values[predPos].InsertAfterSelf(row); + else + sheetData.InsertAt(row, 0); + rowMap[rowIdx] = row; + return row; + } + + /// + /// Invalidate the row index cache for a specific SheetData (or all sheets if null). + /// Must be called whenever rows are structurally modified (removed, shifted). + /// + private void InvalidateRowIndex(SheetData? sheetData = null) + { + if (sheetData != null) + _rowIndex?.Remove(sheetData); + else + _rowIndex = null; + } + + private Cell FindOrCreateCell(SheetData sheetData, string cellRef) + { + var (colName, rowIdx) = ParseCellReference(cellRef); + + var row = FindOrCreateRow(sheetData, (uint)rowIdx); - // Find or create cell + // Cell lookup within row — O(m) where m = cols per row (typically small) var cell = row.Elements().FirstOrDefault(c => c.CellReference?.Value?.Equals(cellRef, StringComparison.OrdinalIgnoreCase) == true); if (cell == null) @@ -891,6 +1668,54 @@ private static Cell FindOrCreateCell(SheetData sheetData, string cellRef) private static bool IsTruthy(string? value) => ParseHelpers.IsTruthy(value); + // CONSISTENCY(xlsx/comment-font): C8 — build the for comment runs. + // When no font.* properties are supplied, keep the legacy Tahoma 9 / + // indexed-81 default for back-compat. When any font.* is present, honor + // them and fall back to the defaults only for unspecified facets. + // Input vocabulary mirrors the cell-level font handling: font.bold, + // font.italic, font.underline (single|double), font.size (pt-qualified + // or bare), font.color (#FF0000 / FF0000 / rgb() / named), font.name. + internal static RunProperties BuildCommentRunProperties(Dictionary properties) + { + bool hasAnyFont = properties.Keys.Any(k => + k.StartsWith("font.", StringComparison.OrdinalIgnoreCase)); + if (!hasAnyFont) + { + return new RunProperties( + new FontSize { Val = 9 }, + new Color { Indexed = 81 }, + new RunFont { Val = "Tahoma" }); + } + + var rPr = new RunProperties(); + if (properties.TryGetValue("font.bold", out var fb) && IsTruthy(fb)) + rPr.AppendChild(new Bold()); + if (properties.TryGetValue("font.italic", out var fi) && IsTruthy(fi)) + rPr.AppendChild(new Italic()); + if (properties.TryGetValue("font.underline", out var fu) && !string.IsNullOrEmpty(fu) + && !string.Equals(fu, "none", StringComparison.OrdinalIgnoreCase) + && !string.Equals(fu, "false", StringComparison.OrdinalIgnoreCase)) + { + var uVal = string.Equals(fu, "double", StringComparison.OrdinalIgnoreCase) + ? UnderlineValues.Double : UnderlineValues.Single; + rPr.AppendChild(new Underline { Val = uVal }); + } + // Size default 9pt + var sizePt = properties.TryGetValue("font.size", out var fs) + ? ParseHelpers.ParseFontSize(fs) : 9.0; + rPr.AppendChild(new FontSize { Val = sizePt }); + // Color: explicit overrides default indexed=81 + if (properties.TryGetValue("font.color", out var fc) && !string.IsNullOrWhiteSpace(fc)) + rPr.AppendChild(new Color { Rgb = ParseHelpers.NormalizeArgbColor(fc) }); + else + rPr.AppendChild(new Color { Indexed = 81 }); + // Name default Tahoma + var fontName = properties.TryGetValue("font.name", out var fn) && !string.IsNullOrWhiteSpace(fn) + ? fn : "Tahoma"; + rPr.AppendChild(new RunFont { Val = fontName }); + return rPr; + } + private static bool IsValidBooleanString(string? value) => ParseHelpers.IsValidBooleanString(value); @@ -997,6 +1822,29 @@ private DocumentNode CommentToNode(string sheetName, Comment comment, Comments c node.Format["author"] = authorName; node.Format["anchoredTo"] = $"/{sheetName}/{reference}"; + // CONSISTENCY(xlsx/comment-font): C8 — surface font.* from first run's + // rPr so Query/Get round-trips the Add-time formatting. Only report + // non-default facets so Tahoma-9-indexed-81 comments stay unadorned. + var firstRun = comment.CommentText?.Elements().FirstOrDefault(); + var rProps = firstRun?.RunProperties; + if (rProps != null) + { + if (rProps.Elements().Any()) node.Format["font.bold"] = true; + if (rProps.Elements().Any()) node.Format["font.italic"] = true; + var u = rProps.Elements().FirstOrDefault(); + if (u != null) + node.Format["font.underline"] = u.Val?.InnerText == "double" ? "double" : "single"; + var clr = rProps.Elements().FirstOrDefault(); + if (clr?.Rgb?.HasValue == true) + node.Format["font.color"] = ParseHelpers.FormatHexColor(clr.Rgb.Value!); + var sz = rProps.Elements().FirstOrDefault(); + if (sz?.Val?.HasValue == true && sz.Val.Value != 9.0) + node.Format["font.size"] = $"{sz.Val.Value:0.##}pt"; + var rf = rProps.Elements().FirstOrDefault(); + if (rf?.Val?.HasValue == true && rf.Val.Value != "Tahoma") + node.Format["font.name"] = rf.Val.Value; + } + return node; } @@ -1020,10 +1868,11 @@ private static DocumentNode DataValidationToNode(string sheetName, DataValidatio if (dv.Formula1 != null) { - var f1 = dv.Formula1.Text ?? ""; - if (f1.StartsWith("\"") && f1.EndsWith("\"")) - f1 = f1[1..^1]; - node.Format["formula1"] = f1; + // Preserve formula1 exactly as stored in XML so query→set round-trips: + // list-type validations wrap literal options in "..." at Add time, and + // stripping those quotes here made set(formula1=) treat the + // whole list as a single item. See DEFERRED(xlsx/validation-list-formula-roundtrip). + node.Format["formula1"] = dv.Formula1.Text ?? ""; } if (dv.Formula2 != null) @@ -1110,10 +1959,17 @@ private static DocumentNode DataValidationToNode(string sheetName, DataValidatio if (nvProps?.Name?.Value != null) node.Format["name"] = nvProps.Name.Value; - // Text + // Text — shape TextBody has one per paragraph, each with + // zero-or-more / runs. Concatenate runs within a + // paragraph, then join paragraphs with '\n' so multi-line shape + // text round-trips through Get. + var paragraphs = shape.TextBody?.Elements().ToList(); + if (paragraphs != null && paragraphs.Count > 0) + { + node.Text = string.Join("\n", paragraphs.Select(p => + string.Join("", p.Elements().Select(r => r.Text?.Text ?? "")))); + } var textRuns = shape.TextBody?.Descendants().ToList(); - if (textRuns != null && textRuns.Count > 0) - node.Text = string.Join("", textRuns.Select(r => r.Text?.Text ?? "")); // Position/size ReadAnchorPosition(anchor, node); @@ -1145,6 +2001,26 @@ private static DocumentNode DataValidationToNode(string sheetName, DataValidatio node.Format["font"] = latin.Typeface.Value; } + // Rotation / flip readback from + var xfrm = shape.ShapeProperties?.Transform2D; + if (xfrm != null) + { + if (xfrm.Rotation?.HasValue == true && xfrm.Rotation.Value != 0) + { + // OOXML stores rotation in 60000ths of a degree; Add normalizes + // into [0,360). Round-trip the same canonical form. + var deg = xfrm.Rotation.Value / 60000.0; + node.Format["rotation"] = Math.Round(deg, 2); + } + if (xfrm.HorizontalFlip?.HasValue == true && xfrm.VerticalFlip?.HasValue == true + && xfrm.HorizontalFlip.Value && xfrm.VerticalFlip.Value) + node.Format["flip"] = "both"; + else if (xfrm.HorizontalFlip?.HasValue == true && xfrm.HorizontalFlip.Value) + node.Format["flip"] = "h"; + else if (xfrm.VerticalFlip?.HasValue == true && xfrm.VerticalFlip.Value) + node.Format["flip"] = "v"; + } + // Fill var spPr = shape.ShapeProperties; if (spPr?.GetFirstChild() != null) @@ -1280,6 +2156,87 @@ private static bool TrySetRotation(XDR.ShapeProperties? spPr, string key, string return true; } + /// + /// Set horizontal / vertical flip on a shape's Transform2D. Accepts "h", "v", "both", + /// or "none" to clear both. Returns true if the key was handled. + /// + private static bool TrySetShapeFlip(XDR.ShapeProperties? spPr, string key, string value) + { + if (key != "flip") return false; + if (spPr == null) return true; + var xfrm = spPr.GetFirstChild(); + if (xfrm == null) + { + xfrm = new Drawing.Transform2D( + new Drawing.Offset { X = 0, Y = 0 }, + new Drawing.Extents { Cx = 0, Cy = 0 }); + spPr.InsertAt(xfrm, 0); + } + var f = value.Trim().ToLowerInvariant(); + bool none = f is "none" or "false" or ""; + bool flipH = !none && (f is "h" or "horizontal" or "both" or "hv" or "vh"); + bool flipV = !none && (f is "v" or "vertical" or "both" or "hv" or "vh"); + xfrm.HorizontalFlip = flipH ? true : (bool?)null; + xfrm.VerticalFlip = flipV ? true : (bool?)null; + return true; + } + + /// + /// Apply a dotted-form font property (`font.bold`, `font.italic`, `font.color`, + /// `font.size`, `font.name`, `font.underline`) to every run in the shape's text body. + /// Returns true if the key was handled. + /// + private static bool TrySetShapeFontProp(XDR.Shape shape, string key, string value) + { + if (!key.StartsWith("font.", StringComparison.Ordinal)) return false; + var sub = key.Substring(5); + foreach (var run in shape.Descendants()) + { + var rPr = run.RunProperties ?? (run.RunProperties = new Drawing.RunProperties()); + switch (sub) + { + case "bold": + rPr.Bold = IsTruthy(value); + break; + case "italic": + rPr.Italic = IsTruthy(value); + break; + case "size": + rPr.FontSize = (int)Math.Round(ParseHelpers.ParseFontSize(value) * 100); + break; + case "name": + rPr.RemoveAllChildren(); + rPr.RemoveAllChildren(); + rPr.AppendChild(new Drawing.LatinFont { Typeface = value }); + rPr.AppendChild(new Drawing.EastAsianFont { Typeface = value }); + break; + case "color": + { + rPr.RemoveAllChildren(); + var (cRgb, _) = ParseHelpers.SanitizeColorForOoxml(value); + OfficeCli.Core.DrawingEffectsHelper.InsertFillInRunProperties(rPr, + new Drawing.SolidFill(new Drawing.RgbColorModelHex { Val = cRgb })); + break; + } + case "underline": + { + var uv = value.ToLowerInvariant(); + rPr.Underline = uv switch + { + "true" or "single" or "sng" => Drawing.TextUnderlineValues.Single, + "double" or "dbl" => Drawing.TextUnderlineValues.Double, + "none" or "false" => Drawing.TextUnderlineValues.None, + _ => Drawing.TextUnderlineValues.Single + }; + break; + } + default: + return false; + } + } + return true; + } + /// /// Apply shape-level effects (shadow, glow, reflection, softedge) on a ShapeProperties element. /// Returns true if the key was handled. @@ -1311,40 +2268,403 @@ private static bool TrySetShapeEffect(XDR.ShapeProperties? spPr, string key, str else { if (effectList == null) { effectList = new Drawing.EffectList(); spPr.AppendChild(effectList); } + // CONSISTENCY(effect-list-schema-order): CT_EffectList order is + // blur → fillOverlay → glow → innerShdw → outerShdw → prstShdw → reflection → softEdge. + // Excel (and PPT) silently drops out-of-order children, so we must + // InsertBefore the next-in-order sibling rather than AppendChild. + OpenXmlElement newEffect; switch (key) { case "shadow": effectList.RemoveAllChildren(); - effectList.AppendChild(OfficeCli.Core.DrawingEffectsHelper.BuildOuterShadow(normalizedVal, OfficeCli.Core.DrawingEffectsHelper.BuildRgbColor)); + newEffect = OfficeCli.Core.DrawingEffectsHelper.BuildOuterShadow(normalizedVal, OfficeCli.Core.DrawingEffectsHelper.BuildRgbColor); break; case "glow": effectList.RemoveAllChildren(); - effectList.AppendChild(OfficeCli.Core.DrawingEffectsHelper.BuildGlow(normalizedVal, OfficeCli.Core.DrawingEffectsHelper.BuildRgbColor)); + newEffect = OfficeCli.Core.DrawingEffectsHelper.BuildGlow(normalizedVal, OfficeCli.Core.DrawingEffectsHelper.BuildRgbColor); break; case "reflection": effectList.RemoveAllChildren(); - effectList.AppendChild(OfficeCli.Core.DrawingEffectsHelper.BuildReflection(normalizedVal)); + newEffect = OfficeCli.Core.DrawingEffectsHelper.BuildReflection(normalizedVal); break; case "softedge": effectList.RemoveAllChildren(); - effectList.AppendChild(OfficeCli.Core.DrawingEffectsHelper.BuildSoftEdge(normalizedVal)); + newEffect = OfficeCli.Core.DrawingEffectsHelper.BuildSoftEdge(normalizedVal); break; + default: return true; } + InsertEffectInSchemaOrder(effectList, newEffect); } return true; } + /// + /// Insert an effectLst child at the correct DrawingML CT_EffectList schema position: + /// blur → fillOverlay → glow → innerShdw → outerShdw → prstShdw → reflection → softEdge. + /// + private static void InsertEffectInSchemaOrder(Drawing.EffectList effectList, OpenXmlElement newEffect) + { + // Determine all types that must come AFTER newEffect per schema order. + OpenXmlElement? insertBefore = newEffect switch + { + Drawing.Blur => (OpenXmlElement?)effectList.GetFirstChild() + ?? effectList.GetFirstChild() + ?? effectList.GetFirstChild() + ?? effectList.GetFirstChild() + ?? effectList.GetFirstChild() + ?? (OpenXmlElement?)effectList.GetFirstChild() + ?? effectList.GetFirstChild(), + Drawing.FillOverlay => (OpenXmlElement?)effectList.GetFirstChild() + ?? effectList.GetFirstChild() + ?? effectList.GetFirstChild() + ?? effectList.GetFirstChild() + ?? (OpenXmlElement?)effectList.GetFirstChild() + ?? effectList.GetFirstChild(), + Drawing.Glow => (OpenXmlElement?)effectList.GetFirstChild() + ?? effectList.GetFirstChild() + ?? effectList.GetFirstChild() + ?? (OpenXmlElement?)effectList.GetFirstChild() + ?? effectList.GetFirstChild(), + Drawing.InnerShadow => (OpenXmlElement?)effectList.GetFirstChild() + ?? effectList.GetFirstChild() + ?? (OpenXmlElement?)effectList.GetFirstChild() + ?? effectList.GetFirstChild(), + Drawing.OuterShadow => (OpenXmlElement?)effectList.GetFirstChild() + ?? (OpenXmlElement?)effectList.GetFirstChild() + ?? effectList.GetFirstChild(), + Drawing.PresetShadow => (OpenXmlElement?)effectList.GetFirstChild() + ?? effectList.GetFirstChild(), + Drawing.Reflection => (OpenXmlElement?)effectList.GetFirstChild(), + _ => null, + }; + if (insertBefore != null) effectList.InsertBefore(newEffect, insertBefore); + else effectList.AppendChild(newEffect); + } + /// /// Parse x, y, width, height from properties with given defaults. Used by both picture Add and shape Add. /// + // CONSISTENCY(shape-preset): mirror PowerPointHandler.ParsePresetShape token + // set so Excel `add shape preset=X` accepts the same vocabulary as PPT. + // + // Exhaustive map covering every OOXML preset token. Built once via + // reflection over `Drawing.ShapeTypeValues` static properties — each + // property's default `ToString()` (== OpenXml IEnumValue.Value) is the + // OOXML token such as "smileyFace", "flowChartProcess", "lightningBolt". + // We then overlay friendly aliases (oval, cylinder, rarrow, …). + private static readonly Dictionary _shapePresetMap = + BuildShapePresetMap(); + + private static Dictionary BuildShapePresetMap() + { + var map = new Dictionary(StringComparer.Ordinal); + foreach (var p in typeof(Drawing.ShapeTypeValues) + .GetProperties(BindingFlags.Public | BindingFlags.Static) + .Where(p => p.PropertyType == typeof(Drawing.ShapeTypeValues))) + { + if (p.GetValue(null) is not Drawing.ShapeTypeValues val) continue; + // IEnumValue.Value is the OOXML token, e.g. "smileyFace". Do not + // use ToString() — on OpenXml SDK 3.x record-struct wrappers it + // returns "ShapeTypeValues { }" instead of the token. + var token = (val as IEnumValue)?.Value; + if (string.IsNullOrEmpty(token)) continue; + map[token.ToLowerInvariant()] = val; + } + + // Friendly aliases layered on top (key must be lowercase). + void Alias(string alias, Drawing.ShapeTypeValues v) => map[alias] = v; + Alias("rectangle", Drawing.ShapeTypeValues.Rectangle); + Alias("roundedrectangle", Drawing.ShapeTypeValues.RoundRectangle); + Alias("oval", Drawing.ShapeTypeValues.Ellipse); + Alias("righttriangle", Drawing.ShapeTypeValues.RightTriangle); + Alias("rtriangle", Drawing.ShapeTypeValues.RightTriangle); + Alias("rarrow", Drawing.ShapeTypeValues.RightArrow); + Alias("larrow", Drawing.ShapeTypeValues.LeftArrow); + Alias("cross", Drawing.ShapeTypeValues.Plus); + Alias("cylinder", Drawing.ShapeTypeValues.Can); + return map; + } + + private static Drawing.ShapeTypeValues ParseExcelShapePreset(string name) + { + var key = (name ?? string.Empty).Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(key)) + return Drawing.ShapeTypeValues.Rectangle; + if (_shapePresetMap.TryGetValue(key, out var val)) + return val; + // Unknown preset: fall back to rectangle (legacy behavior — no throw, + // keeps Add lenient). Callers that care can compare with the returned + // value. + return Drawing.ShapeTypeValues.Rectangle; + } + private static (int x, int y, int width, int height) ParseAnchorBounds( Dictionary properties, string defX, string defY, string defW, string defH) { return ( ParseHelpers.SafeParseInt(properties.GetValueOrDefault("x", defX) ?? defX, "x"), ParseHelpers.SafeParseInt(properties.GetValueOrDefault("y", defY) ?? defY, "y"), - ParseHelpers.SafeParseInt(properties.GetValueOrDefault("width", defW) ?? defW, "width"), - ParseHelpers.SafeParseInt(properties.GetValueOrDefault("height", defH) ?? defH, "height") + ParseAnchorDimension(properties.GetValueOrDefault("width", defW) ?? defW, "width"), + ParseAnchorDimension(properties.GetValueOrDefault("height", defH) ?? defH, "height") + ); + } + + /// + /// Parse a width/height anchor value that is either a plain integer + /// cell-count ("3", "5") or a unit-qualified size ("6cm", "2in", "72pt"). + /// Unit-qualified values are converted to an approximate cell count using + /// Excel's default ~64px (~0.66cm) column width and ~15pt row height. + /// CONSISTENCY(ole-width-units): Picture/Drawing elsewhere accept ParseEmu; + /// anchor.x/y stay as cell coordinates, but width/height tolerate EMU units. + /// + private static int ParseAnchorDimension(string value, string name) + { + if (int.TryParse(value, out var plainInt)) + return plainInt; + + // Not a plain integer — treat as EMU-convertible size string. + long emu; + try + { + emu = OfficeCli.Core.EmuConverter.ParseEmu(value); + } + catch + { + throw new ArgumentException($"Expected an integer cell count or a unit-qualified size (e.g. '6cm', '2in') for {name}, got '{value}'."); + } + + // Rough conversion: 1 default Excel column ≈ 64px ≈ 0.677cm ≈ 609600 EMU. + // 1 default Excel row ≈ 15pt ≈ 0.529cm ≈ 190500 EMU. + // For width/height passed as a unit, choose the larger of the two + // converters so "6cm" yields a sensible ~9 columns result either axis. + const long emuPerColApprox = 609600; + const long emuPerRowApprox = 190500; + if (name == "height") + return Math.Max(1, (int)(emu / emuPerRowApprox)); + return Math.Max(1, (int)(emu / emuPerColApprox)); + } + + // CONSISTENCY(ole-width-units): OLE round-trip preserves sub-cell precision + // by storing the full EMU extent in ObjectAnchor's From/To ColumnOffset and + // RowOffset, instead of rounding to whole cells like ParseAnchorDimension. + // Picture/shape branches keep the integer behavior for now. + private const long EmuPerColApprox = 609600; + private const long EmuPerRowApprox = 190500; + + /// + /// Parse a width/height anchor value into EMU. Plain integers are treated + /// as cell counts and multiplied by the default column/row EMU width. + /// Unit-qualified values (e.g. "6cm", "2in") are parsed via EmuConverter. + /// + private static long ParseAnchorDimensionEmu(string value, string name) + { + if (long.TryParse(value, out var plainInt)) + { + // Bare integers are interpreted as cell counts (original grammar), + // but values that exceed the sheet's column/row max are obviously + // meant as EMU — the cell-count interpretation would overflow the + // ToMarker coordinate and make Excel reject the file. Excel's hard + // limits: 16384 columns, 1048576 rows. Anything bigger is EMU. + const int MaxCols = 16384; + const int MaxRows = 1048576; + long boundary = (name == "height") ? MaxRows : MaxCols; + if (plainInt >= boundary) + return Math.Max(0, plainInt); + long perCell = (name == "height") ? EmuPerRowApprox : EmuPerColApprox; + return Math.Max(0, plainInt) * perCell; + } + + try + { + return OfficeCli.Core.EmuConverter.ParseEmu(value); + } + catch + { + throw new ArgumentException($"Expected an integer cell count or a unit-qualified size (e.g. '6cm', '2in') for {name}, got '{value}'."); + } + } + + /// + /// Parse an anchor= prop value as a cell-reference or cell-range + /// (e.g. "B2" or "B2:F7") into 0-based XDR column/row + /// coordinates. Returns false for anchor-mode strings like + /// oneCell/twoCell/absolute, which the caller should + /// route to the anchorMode path instead. Throws + /// for syntactically invalid range strings. + /// + /// When only a single cell is supplied, toCol/toRow are set + /// to -1 so callers can fall back to a size-derived extent (e.g. + /// width/height × EMU-per-cell). The regex mirrors the OLE branch grammar. + /// + /// CONSISTENCY(xdr-coords): XDR ColumnId/RowId are 0-based; ColumnNameToIndex + /// returns 1-based, so this helper subtracts 1 on the way out. + /// + internal static bool TryParseCellRangeAnchor( + string? value, out int fromCol, out int fromRow, out int toCol, out int toRow) + { + fromCol = fromRow = 0; + toCol = toRow = -1; + if (string.IsNullOrWhiteSpace(value)) return false; + var m = System.Text.RegularExpressions.Regex.Match( + value, @"^([A-Z]+)(\d+)(?::([A-Z]+)(\d+))?$", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (!m.Success) return false; + fromCol = ColumnNameToIndex(m.Groups[1].Value) - 1; + fromRow = int.Parse(m.Groups[2].Value) - 1; + if (m.Groups[3].Success) + { + toCol = ColumnNameToIndex(m.Groups[3].Value) - 1; + toRow = int.Parse(m.Groups[4].Value) - 1; + } + return true; + } + + /// + /// Return true if the given anchor= value is one of the recognized + /// anchorMode tokens (oneCell/twoCell/absolute). Used by the picture + /// branch to disambiguate mode-strings from cell-range strings. + /// + internal static bool IsAnchorModeToken(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + var v = value.Trim().ToLowerInvariant(); + return v is "onecell" or "twocell" or "absolute"; + } + + /// + /// Apply `x` / `y` / `width` / `height` to the N-th chart's + /// in a drawings part. Accepts the same + /// value grammar as OLE objects and chart Add: integer cell counts, or + /// unit-qualified EMU strings ("6cm", "2in", "720pt", raw EMU). + /// + /// Returns any keys from the input dict that couldn't be applied (parse + /// failures, missing anchor, ...). Keys present but successfully applied + /// are NOT returned — the caller is expected to strip them before + /// forwarding to the chart content setter. + /// + /// CONSISTENCY(chart-position-set): mirrors the PPTX + /// PowerPointHandler.Set.cs chart path — same vocabulary, same units — + /// so one prop grammar covers chart position across all three document + /// types. The mutation mechanic differs because Excel charts are pinned + /// to cells via TwoCellAnchor. + /// + private static List ApplyChartPositionSet( + DrawingsPart drawingsPart, int chartIdx, Dictionary properties) + { + var unsupported = new List(); + if (drawingsPart.WorksheetDrawing == null) return unsupported; + + // Find the N-th chart frame (same order as GetExcelCharts). + var chartFrames = drawingsPart.WorksheetDrawing + .Descendants() + .Where(gf => gf.Descendants().Any() || IsExtendedChartFrame(gf)) + .ToList(); + if (chartIdx < 1 || chartIdx > chartFrames.Count) return unsupported; + var gf = chartFrames[chartIdx - 1]; + var anchor = gf.Parent as XDR.TwoCellAnchor; + if (anchor?.FromMarker == null || anchor.ToMarker == null) + { + foreach (var k in new[] { "x", "y", "width", "height" }) + if (properties.ContainsKey(k)) unsupported.Add(k); + return unsupported; + } + + var fromM = anchor.FromMarker; + var toM = anchor.ToMarker; + + // ---- Position (x, y) → FromMarker cell indices ---- + // `x` = column index (0-based), `y` = row index (0-based). Integer + // only — sub-cell offset is not supported here (matches chart Add). + if (properties.TryGetValue("x", out var xStr)) + { + if (int.TryParse(xStr, out var newFromCol) && newFromCol >= 0) + { + var fromColChild = fromM.GetFirstChild(); + var oldFromCol = int.TryParse(fromColChild?.Text ?? "0", out var ofc) ? ofc : 0; + if (fromColChild != null) fromColChild.Text = newFromCol.ToString(); + // Shift ToMarker column by the same delta to preserve width. + var toColChild = toM.GetFirstChild(); + if (toColChild != null && int.TryParse(toColChild.Text ?? "0", out var oldToCol)) + toColChild.Text = (oldToCol + (newFromCol - oldFromCol)).ToString(); + // Reset fromCol offset to 0 (align to cell boundary). + var fromColOffChild = fromM.GetFirstChild(); + if (fromColOffChild != null) fromColOffChild.Text = "0"; + } + else unsupported.Add("x"); + } + + if (properties.TryGetValue("y", out var yStr)) + { + if (int.TryParse(yStr, out var newFromRow) && newFromRow >= 0) + { + var fromRowChild = fromM.GetFirstChild(); + var oldFromRow = int.TryParse(fromRowChild?.Text ?? "0", out var ofr) ? ofr : 0; + if (fromRowChild != null) fromRowChild.Text = newFromRow.ToString(); + var toRowChild = toM.GetFirstChild(); + if (toRowChild != null && int.TryParse(toRowChild.Text ?? "0", out var oldToRow)) + toRowChild.Text = (oldToRow + (newFromRow - oldFromRow)).ToString(); + var fromRowOffChild = fromM.GetFirstChild(); + if (fromRowOffChild != null) fromRowOffChild.Text = "0"; + } + else unsupported.Add("y"); + } + + // ---- Dimensions (width, height) → rebuild ToMarker from FromMarker ---- + // Reuses the OLE-object path's EMU math (EmuPerColApprox / EmuPerRowApprox + // approximation, sub-cell offset preserves precision). + if (properties.TryGetValue("width", out var wStr)) + { + long emuTotal; + try { emuTotal = ParseAnchorDimensionEmu(wStr, "width"); } + catch { unsupported.Add("width"); emuTotal = -1; } + if (emuTotal >= 0) + { + int.TryParse(fromM.GetFirstChild()?.Text ?? "0", out var fromCol); + long.TryParse(fromM.GetFirstChild()?.Text ?? "0", out var fromColOff); + long wholeCols = emuTotal / EmuPerColApprox; + long remCols = emuTotal % EmuPerColApprox; + var toColChild = toM.GetFirstChild(); + if (toColChild != null) toColChild.Text = (fromCol + (int)wholeCols).ToString(); + var toColOffChild = toM.GetFirstChild(); + if (toColOffChild != null) toColOffChild.Text = (fromColOff + remCols).ToString(); + } + } + + if (properties.TryGetValue("height", out var hStr)) + { + long emuTotal; + try { emuTotal = ParseAnchorDimensionEmu(hStr, "height"); } + catch { unsupported.Add("height"); emuTotal = -1; } + if (emuTotal >= 0) + { + int.TryParse(fromM.GetFirstChild()?.Text ?? "0", out var fromRow); + long.TryParse(fromM.GetFirstChild()?.Text ?? "0", out var fromRowOff); + long wholeRows = emuTotal / EmuPerRowApprox; + long remRows = emuTotal % EmuPerRowApprox; + var toRowChild = toM.GetFirstChild(); + if (toRowChild != null) toRowChild.Text = (fromRow + (int)wholeRows).ToString(); + var toRowOffChild = toM.GetFirstChild(); + if (toRowOffChild != null) toRowOffChild.Text = (fromRowOff + remRows).ToString(); + } + } + + drawingsPart.WorksheetDrawing.Save(); + return unsupported; + } + + /// + /// Parse x, y (cell indices) + width, height (EMU) for OLE anchors that + /// need sub-cell precision. See ParseAnchorDimensionEmu for width/height + /// semantics. + /// + private static (int x, int y, long widthEmu, long heightEmu) ParseAnchorBoundsEmu( + Dictionary properties, string defX, string defY, string defW, string defH) + { + return ( + ParseHelpers.SafeParseInt(properties.GetValueOrDefault("x", defX) ?? defX, "x"), + ParseHelpers.SafeParseInt(properties.GetValueOrDefault("y", defY) ?? defY, "y"), + ParseAnchorDimensionEmu(properties.GetValueOrDefault("width", defW) ?? defW, "width"), + ParseAnchorDimensionEmu(properties.GetValueOrDefault("height", defH) ?? defH, "height") ); } @@ -1385,6 +2705,25 @@ private static void ReorderRunProperties(RunProperties rpr) private const string ExcelChartExUri = "http://schemas.microsoft.com/office/drawing/2014/chartex"; + /// + /// Load a chartEx sidecar resource (style / colors XML) bundled as an + /// embedded resource. Files are copied verbatim from an Excel reference + /// treemap and reused for every chartEx type — they carry default + /// style/palette content that has no dependency on chart layout or data. + /// See the chartex-sidecars CONSISTENCY note in ExcelHandler.Add.cs for + /// why these sidecars are load-bearing (Excel deletes the whole drawing + /// if they are missing from the relationships). + /// + private static Stream LoadChartExResource(string fileName) + { + var assembly = typeof(ExcelHandler).Assembly; + var resourceName = $"OfficeCli.Resources.{fileName}"; + var stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException( + $"Embedded resource not found: {resourceName}. Ensure it is declared in officecli.csproj."); + return stream; + } + /// /// Check if an XDR.GraphicFrame contains an extended chart (cx:chart). /// @@ -1598,7 +2937,7 @@ private int FindAndReplace(string find, string replace, WorksheetPart? targetShe } } - wsPart.Worksheet!.Save(); + SaveWorksheet(wsPart); } return totalCount; @@ -1710,4 +3049,491 @@ private static int CountOccurrences(string text, string find) return (seriesData, categories.ToArray()); } + + // ==================== Binary Extraction ==================== + // + // Support for `officecli get --save `. Parses the path to find + // the owning worksheet and queries the node's relId. Both DrawingsPart + // (pictures) and WorksheetPart (embedded ole/package) are consulted + // because pictures live on DrawingsPart while OLE payloads live on + // WorksheetPart directly. + public bool TryExtractBinary(string path, string destPath, out string? contentType, out long byteCount) + { + contentType = null; + byteCount = 0; + var node = Get(path, 0); + if (node == null) return false; + if (!node.Format.TryGetValue("relId", out var relObj) || relObj is not string relId + || string.IsNullOrEmpty(relId)) + return false; + + // Path looks like /SheetName/... — find the worksheet. + var normalized = NormalizeExcelPath(path); + normalized = ResolveSheetIndexInPath(normalized); + var segments = normalized.TrimStart('/').Split('/', 2); + var sheetName = segments[0]; + var worksheetPart = FindWorksheet(sheetName); + if (worksheetPart == null) return false; + + DocumentFormat.OpenXml.Packaging.OpenXmlPart? part = null; + try { part = worksheetPart.GetPartById(relId); } catch { /* try drawing */ } + if (part == null && worksheetPart.DrawingsPart != null) + { + try { part = worksheetPart.DrawingsPart.GetPartById(relId); } catch { /* fall through */ } + } + if (part == null) return false; + + // BUG-R10-04: create the destination directory if missing so + // `get --save ./outdir/file.bin` works when outdir doesn't exist. + var destDir = Path.GetDirectoryName(destPath); + if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir)) + Directory.CreateDirectory(destDir); + + // CONSISTENCY(ole-cfb-wrap): non-Office OLE payloads are stored as + // CFB containers with \x01Ole10Native; unwrap on read so the caller + // gets back the bytes they fed in via `add ole src=...`. + byte[] rawBytes; + using (var src = part.GetStream()) + using (var ms = new MemoryStream()) + { + src.CopyTo(ms); + rawBytes = ms.ToArray(); + } + var payload = OfficeCli.Core.OleHelper.UnwrapOle10NativeIfCfb(rawBytes); + File.WriteAllBytes(destPath, payload); + byteCount = payload.Length; + contentType = part.ContentType; + return true; + } + + // ==================== OLE Object Writing Helpers ==================== + + /// + /// Ensure the given VmlDrawingPart contains a minimal v:shape with the + /// specified shapeId so the schema-required oleObject/@shapeId + /// attribute has a valid target. Modern Excel (2010+) renders OLE from + /// the companion objectPr/anchor, but the shape itself still + /// has to exist for a round-trip — otherwise opening the workbook in + /// older Excel versions tends to drop the object silently. + /// + internal static void EnsureExcelVmlShapeForOle(VmlDrawingPart vmlPart, uint shapeId, + int fromCol, int fromRow, int toCol, int toRow) + { + // Load the existing VML (may be absent on a freshly-created part). + string existing; + try + { + using var readStream = vmlPart.GetStream(FileMode.OpenOrCreate, FileAccess.Read); + using var reader = new StreamReader(readStream); + existing = reader.ReadToEnd(); + } + catch + { + existing = string.Empty; + } + + // VML clientData carries the anchor (16 coordinates: from/to col/row + offsets). + // Coordinates are in the legacy "left, top, right, bottom" pixel order. + var anchorValue = $"{fromCol}, 0, {fromRow}, 0, {toCol}, 0, {toRow}, 0"; + var newShape = $""" + +"""; + + string merged; + if (string.IsNullOrWhiteSpace(existing)) + { + // Build a minimal xml with shapetype + our shape. + merged = $""" + + + + + + + + + + + + + + + + + + + + + +{newShape} + +"""; + } + else + { + // Append our shape before the closing tag. + var closeIdx = existing.LastIndexOf("", StringComparison.OrdinalIgnoreCase); + if (closeIdx < 0) closeIdx = existing.Length; + merged = existing.Substring(0, closeIdx) + newShape + "\n"; + } + + using var writeStream = vmlPart.GetStream(FileMode.Create, FileAccess.Write); + using var writer = new StreamWriter(writeStream); + writer.Write(merged); + } + + // ==================== OLE Object Reading ==================== + // + // Enumerate all OLE objects attached to a worksheet. Excel stores these + // as inside the worksheet (each has + // progId + shapeId + r:id), plus matching EmbeddedObjectPart / + // EmbeddedPackagePart parts joined by rel id. + // + // CONSISTENCY(ole-orphan-indexing): orphan embedded parts (backing parts + // with no matching x:oleObject XML element) are intentionally NOT + // surfaced under the ole[N] index. Set/Remove dispatch on + // ws.Descendants() which only yields schema-typed elements; + // indexing orphans here would cause Get to return nodes that Set/Remove + // cannot address. Orphans can still be audited via Validate() or raw + // package inspection. + internal List CollectOleNodesForSheet(string sheetName, WorksheetPart worksheetPart) + { + var nodes = new List(); + + // Walk schema-typed elements (may live inside + // , directly under , or wrapped in an + // ...). + // Descendants picks all of those up. + var oleElements = GetSheet(worksheetPart).Descendants().ToList(); + for (int i = 0; i < oleElements.Count; i++) + { + var ole = oleElements[i]; + var node = new DocumentNode + { + Path = $"/{sheetName}/ole[{i + 1}]", + Type = "ole", + Text = ole.ProgId?.Value ?? "", + }; + node.Format["objectType"] = "ole"; + // CONSISTENCY(ole-display): PPT and Word OLE Get both expose + // Format["display"]. Excel worksheet OLE objects have no + // DrawAspect concept — they always render as icons — so emit + // a fixed "icon" value for schema symmetry. + node.Format["display"] = "icon"; + if (ole.ProgId?.Value != null) node.Format["progId"] = ole.ProgId.Value; + if (ole.ShapeId?.Value != null) node.Format["shapeId"] = (long)ole.ShapeId.Value; + if (ole.Link?.Value != null) node.Format["link"] = ole.Link.Value; + + var relId = ole.Id?.Value; + if (!string.IsNullOrEmpty(relId)) + { + node.Format["relId"] = relId; + try + { + var part = worksheetPart.GetPartById(relId); + if (part != null) + OfficeCli.Core.OleHelper.PopulateFromPart(node, part, ole.ProgId?.Value); + } + catch + { + // Relationship may be missing; leave part-sourced fields absent. + } + } + + // Expose anchor rectangle as unit-qualified width/height (cm). + // CONSISTENCY(ole-width-units): mirrors PPTX/Word OLE which emit + // EmuConverter.FormatEmu strings. Internally the anchor stores + // only cell markers (col/row), so convert via the same rough + // default-column/row → EMU constants used by ParseAnchorDimension + // (Add-side). Known limitation: Excel's actual column widths are + // ignored; this is a symmetric round-trip of the Add inputs. + var objectPr = ole.GetFirstChild(); + var objAnchor = objectPr?.GetFirstChild(); + if (objAnchor != null) + { + var fromM = objAnchor.GetFirstChild(); + var toM = objAnchor.GetFirstChild(); + if (fromM != null && toM != null) + { + int fromCol = 0, fromRow = 0, toCol = 0, toRow = 0; + long fromColOff = 0, fromRowOff = 0, toColOff = 0, toRowOff = 0; + int.TryParse(fromM.GetFirstChild()?.Text ?? "0", out fromCol); + int.TryParse(fromM.GetFirstChild()?.Text ?? "0", out fromRow); + int.TryParse(toM.GetFirstChild()?.Text ?? "0", out toCol); + int.TryParse(toM.GetFirstChild()?.Text ?? "0", out toRow); + long.TryParse(fromM.GetFirstChild()?.Text ?? "0", out fromColOff); + long.TryParse(fromM.GetFirstChild()?.Text ?? "0", out fromRowOff); + long.TryParse(toM.GetFirstChild()?.Text ?? "0", out toColOff); + long.TryParse(toM.GetFirstChild()?.Text ?? "0", out toRowOff); + // CONSISTENCY(ole-width-units): rebuild EMU extent from + // (cell-count * approx-per-cell) + (to-offset - from-offset) + // so sub-cell precision set on Add survives Get. + long widthEmu = Math.Max(0, (long)(toCol - fromCol)) * EmuPerColApprox + + (toColOff - fromColOff); + long heightEmu = Math.Max(0, (long)(toRow - fromRow)) * EmuPerRowApprox + + (toRowOff - fromRowOff); + if (widthEmu < 0) widthEmu = 0; + if (heightEmu < 0) heightEmu = 0; + node.Format["width"] = OfficeCli.Core.EmuConverter.FormatEmu(widthEmu); + node.Format["height"] = OfficeCli.Core.EmuConverter.FormatEmu(heightEmu); + // CONSISTENCY(ole-anchor-roundtrip): expose the cell-range + // form so `add ... anchor=B2:D4` survives Get/Query. XDR + // markers are 0-based; A1-style needs +1 on both axes. + node.Format["anchor"] = + $"{IndexToColumnName(fromCol + 1)}{fromRow + 1}:{IndexToColumnName(toCol + 1)}{toRow + 1}"; + } + } + + nodes.Add(node); + } + + return nodes; + } + + // CONSISTENCY(xlsx/table-autoexpand): custom namespace marker stored on + // the root so `autoExpand=true` survives open/close cycles. + // Real Excel ignores unknown-namespace attributes, so the file is still + // opened cleanly on Windows — the flag only affects officecli's own + // cell-write auto-grow behavior. + private const string AutoExpandNamespaceUri = "https://officecli.ai/2025/autoexpand"; + private const string AutoExpandNamespacePrefix = "ae"; + private const string AutoExpandAttrName = "autoExpand"; + + private static void SetTableAutoExpandMarker(Table table, bool enabled) + { + if (enabled) + { + table.AddNamespaceDeclaration(AutoExpandNamespacePrefix, AutoExpandNamespaceUri); + table.SetAttribute(new OpenXmlAttribute( + AutoExpandNamespacePrefix, AutoExpandAttrName, AutoExpandNamespaceUri, "1")); + } + } + + private static bool TableHasAutoExpand(Table? table) + { + if (table == null) return false; + foreach (var attr in table.GetAttributes()) + { + if (attr.NamespaceUri == AutoExpandNamespaceUri + && attr.LocalName == AutoExpandAttrName + && (attr.Value == "1" || string.Equals(attr.Value, "true", StringComparison.OrdinalIgnoreCase))) + return true; + } + return false; + } + + // Eager auto-grow on cell Add/Set. Called after writing `cellRef` on + // `worksheet`. For each table on the sheet flagged with autoExpand: + // - if cell is in the row immediately below the table AND its column + // is within the table's column span → grow endRow by 1. + // - else if cell is in the column immediately right of the table AND + // its row is within the table's row span → grow endCol by 1 and + // append a blank tableColumn. + // Both extensions are never applied at once (conservative). + private void MaybeExpandTablesForCell(WorksheetPart worksheet, string cellRef) + { + var (cellCol, cellRow) = ParseCellReference(cellRef.ToUpperInvariant()); + var cellColIdx = ColumnNameToIndex(cellCol); + + foreach (var tdp in worksheet.TableDefinitionParts.ToList()) + { + var table = tdp.Table; + if (table == null) continue; + if (!TableHasAutoExpand(table)) continue; + if (table.Reference?.Value is not string rangeRef) continue; + if (!rangeRef.Contains(':')) continue; + + var parts = rangeRef.Split(':'); + var (startColName, startRow) = ParseCellReference(parts[0]); + var (endColName, endRow) = ParseCellReference(parts[1]); + var startColIdx = ColumnNameToIndex(startColName); + var endColIdx = ColumnNameToIndex(endColName); + + // Row below? (cell row == endRow + 1, within column span). + if (cellRow == endRow + 1 && cellColIdx >= startColIdx && cellColIdx <= endColIdx) + { + endRow += 1; + var newRef = $"{startColName}{startRow}:{endColName}{endRow}"; + table.Reference = newRef; + var af = table.GetFirstChild(); + if (af != null) af.Reference = newRef; + table.Save(); + continue; + } + + // Column right? (cell col == endCol + 1, within row span). + if (cellColIdx == endColIdx + 1 && cellRow >= startRow && cellRow <= endRow) + { + endColIdx += 1; + var newEndColName = IndexToColumnName(endColIdx); + var newRef = $"{startColName}{startRow}:{newEndColName}{endRow}"; + table.Reference = newRef; + var af = table.GetFirstChild(); + if (af != null) af.Reference = newRef; + + var tableColumns = table.GetFirstChild(); + if (tableColumns != null) + { + var existing = tableColumns.Elements().ToList(); + var nextId = existing.Count == 0 + ? 1u + : existing.Max(tc => tc.Id?.Value ?? 0u) + 1u; + var used = new HashSet( + existing.Select(tc => tc.Name?.Value ?? "") + .Where(n => !string.IsNullOrEmpty(n)), + StringComparer.OrdinalIgnoreCase); + var baseName = $"Column{existing.Count + 1}"; + var colName = baseName; + int dedupeIdx = 2; + while (!used.Add(colName)) + colName = $"{baseName}{dedupeIdx++}"; + tableColumns.AppendChild(new TableColumn + { + Id = nextId, + Name = colName + }); + tableColumns.Count = (uint)tableColumns.Elements().Count(); + } + + table.Save(); + } + } + } + + /// + /// R9-1: scan a formula body for Sheet-qualified refs (bare `Sheet1!A1` + /// or quoted `'My Data'!A1`) and return true if any referenced sheet + /// name does not exist in the current workbook. Used to suppress the + /// evaluator-based cachedValue fallback when cross-sheet refs point at + /// a removed sheet — Real Excel shows `#REF!` there; we should not + /// invent a "0". + /// + private bool FormulaReferencesMissingSheet(string formula) + { + if (string.IsNullOrEmpty(formula)) return false; + var wb = _doc.WorkbookPart?.Workbook; + if (wb == null) return false; + var names = new HashSet( + wb.Descendants().Select(s => s.Name?.Value ?? "").Where(n => n.Length > 0), + StringComparer.OrdinalIgnoreCase); + + // Quoted form: '...'! — inner single quotes escaped as '' + foreach (System.Text.RegularExpressions.Match m in + System.Text.RegularExpressions.Regex.Matches(formula, @"'((?:[^']|'')+)'!")) + { + var name = m.Groups[1].Value.Replace("''", "'"); + if (!names.Contains(name)) return true; + } + // Bare form: Name! — letters/digits/underscore/period (Excel allows these unquoted) + foreach (System.Text.RegularExpressions.Match m in + System.Text.RegularExpressions.Regex.Matches(formula, @"(? MaxCellTextLength) + { + var where = string.IsNullOrEmpty(cellRef) ? "" : $" at {cellRef}"; + throw new ArgumentException( + $"Cell value{where} exceeds Excel's {MaxCellTextLength}-character limit (got {value.Length})"); + } + } + + // R13-2: central ISO date parser accepting date-only, date+time, and the + // common `T`-separator variants. Used by Add/Set cell value paths so + // `2024-03-15T10:30:00` is converted to an OADate serial instead of being + // written as a literal string (which Excel renders as text, not a date). + internal static readonly string[] IsoDateFormats = + { + "yyyy-MM-dd", + "yyyy/MM/dd", + "yyyy-MM-dd HH:mm", + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-ddTHH:mm", + "yyyy-MM-ddTHH:mm:ss", + "yyyy-MM-ddTHH:mm:ssZ", + "yyyy-MM-ddTHH:mm:ss.fff", + "yyyy-MM-ddTHH:mm:ss.fffZ", + }; + + internal static bool TryParseIsoDateFlexible(string value, out System.DateTime result) + => System.DateTime.TryParseExact( + value, + IsoDateFormats, + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, + out result); + + /// + /// Build a child for a dxf (differentialFormat) from font.* sub-props. + /// Supports bold, italic, underline (single/double), strike, size, name, color. + /// Returns null if no font sub-props were supplied. + /// + internal static Font? BuildFormulaCfFont(Dictionary properties) + { + bool any = false; + var font = new Font(); + if (properties.TryGetValue("font.bold", out var fBold) && ParseHelpers.IsTruthy(fBold)) + { font.Append(new Bold()); any = true; } + if (properties.TryGetValue("font.italic", out var fItalic) && ParseHelpers.IsTruthy(fItalic)) + { font.Append(new Italic()); any = true; } + if (properties.TryGetValue("font.strike", out var fStrike) && ParseHelpers.IsTruthy(fStrike)) + { font.Append(new Strike()); any = true; } + if (properties.TryGetValue("font.underline", out var fUnder)) + { + var ul = new Underline(); + var lv = fUnder.Trim().ToLowerInvariant(); + ul.Val = lv switch + { + "double" or "dbl" => UnderlineValues.Double, + "singleaccounting" or "singleacct" => UnderlineValues.SingleAccounting, + "doubleaccounting" or "doubleacct" => UnderlineValues.DoubleAccounting, + "none" or "false" => UnderlineValues.None, + _ => UnderlineValues.Single + }; + font.Append(ul); + any = true; + } + if (properties.TryGetValue("font.size", out var fSize)) + { + // Accept "12", "12pt", "10.5pt" — strip trailing "pt" if present. + var cleaned = fSize.Trim().TrimEnd('p', 't', 'P', 'T', ' '); + if (double.TryParse(cleaned, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var sz)) + { + font.Append(new FontSize { Val = sz }); + any = true; + } + } + if (properties.TryGetValue("font.name", out var fName) && !string.IsNullOrWhiteSpace(fName)) + { + font.Append(new FontName { Val = fName }); + any = true; + } + if (properties.TryGetValue("font.color", out var fColor)) + { + var norm = ParseHelpers.NormalizeArgbColor(fColor); + font.Append(new DocumentFormat.OpenXml.Spreadsheet.Color { Rgb = norm }); + any = true; + } + return any ? font : null; + } } diff --git a/src/officecli/Handlers/Excel/ExcelHandler.HtmlPreview.Charts.cs b/src/officecli/Handlers/Excel/ExcelHandler.HtmlPreview.Charts.cs index 4626057a3..897488114 100644 --- a/src/officecli/Handlers/Excel/ExcelHandler.HtmlPreview.Charts.cs +++ b/src/officecli/Handlers/Excel/ExcelHandler.HtmlPreview.Charts.cs @@ -21,69 +21,69 @@ 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, string sheetName = "") + { + 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()) + .Where(gf => gf.Descendants().Any() || IsExtendedChartFrame(gf)) .ToList(); - if (chartFrames.Count == 0) return; + if (chartFrames.Count == 0) return result; + + // Build GF → 1-based chart index map (document order, same as GetExcelCharts) + var gfIndexMap = new Dictionary(); + for (int i = 0; i < chartFrames.Count; i++) gfIndexMap[chartFrames[i]] = i + 1; - // 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>(); - int currentRowEnd = -1; - List? currentRow = null; - foreach (var (gf, fromRow, toRow, _) in chartAnchors) + // Each chart gets its own overlay (no flex grouping) so drag-to-move works independently + foreach (var (gf, fromRow, toRow, fromCol, toCol) in chartAnchors) { - if (currentRow == null || fromRow >= currentRowEnd) - { - currentRow = new List(); - rows.Add(currentRow); - currentRowEnd = toRow; - } - else - { - currentRowEnd = Math.Max(currentRowEnd, toRow); - } - currentRow.Add(gf); + var chartSb = new StringBuilder(); + RenderExcelChart(chartSb, gf, drawingsPart, worksheetPart, sheetName, gfIndexMap.GetValueOrDefault(gf)); + result.Add((fromRow, toRow, fromCol, toCol, chartSb.ToString())); } - foreach (var row in rows) - { - if (row.Count > 1) - { - sb.AppendLine("
    "); - foreach (var gf in row) - RenderExcelChart(sb, gf, drawingsPart, worksheetPart); - sb.AppendLine("
    "); - } - else - { - RenderExcelChart(sb, row[0], drawingsPart, worksheetPart); - } - } + return result; } private void RenderExcelChart(StringBuilder sb, XDR.GraphicFrame gf, - DrawingsPart drawingsPart, WorksheetPart worksheetPart) + DrawingsPart drawingsPart, WorksheetPart worksheetPart, + string sheetName = "", int chartIdx = 0) { + // cx:chart (extended) path — histogram / funnel / treemap / sunburst / + // boxWhisker. Delegate to the cx-aware extractor and shared renderer. + if (IsExtendedChartFrame(gf)) + { + RenderExcelCxChart(sb, gf, drawingsPart, worksheetPart, sheetName, chartIdx); + return; + } + // 1. Get chart reference and load ChartPart var chartRef = gf.Descendants().FirstOrDefault(); if (chartRef?.Id?.Value == null) return; @@ -136,241 +136,82 @@ private void RenderExcelChart(StringBuilder sb, XDR.GraphicFrame gf, if (seriesList.All(s => s.values.Length == 0)) return; } - // 3. Read series colors (and per-point colors for pie/doughnut) - var seriesColors = new List(); - var serElements = plotArea.Descendants() - .Where(e => e.LocalName == "ser").ToList(); - var isPieType = chartType.Contains("pie") || chartType.Contains("doughnut"); - - if (isPieType && serElements.Count > 0) - { - // Pie/doughnut: colors are per data point (dPt), not per series - var ser = serElements[0]; - var dPts = ser.Elements().Where(e => e.LocalName == "dPt").ToList(); - var catCount = seriesList.FirstOrDefault().values?.Length ?? 0; - for (int i = 0; i < catCount; i++) - { - var dPt = dPts.FirstOrDefault(d => - { - var idxEl = d.Elements().FirstOrDefault(e => e.LocalName == "idx"); - if (idxEl == null) return false; - var valAttr = idxEl.GetAttributes().FirstOrDefault(a => a.LocalName == "val"); - return valAttr.Value == i.ToString(); - }); - var spPr = dPt?.GetFirstChild(); - var fill = spPr?.GetFirstChild(); - var rgb = fill?.GetFirstChild()?.Val?.Value; - seriesColors.Add(rgb != null ? $"#{rgb}" : ChartSvgRenderer.DefaultColors[i % ChartSvgRenderer.DefaultColors.Length]); - } - } - else - { - // Determine which series belong to line/scatter charts (stroke color from a:ln) - var lineSerIndices = new HashSet(); - foreach (var chartEl in plotArea.Elements() - .Where(e => e.LocalName is "lineChart" or "scatterChart")) - { - foreach (var s in chartEl.Elements().Where(e => e.LocalName == "ser")) - { - var idx = s.GetFirstChild()?.Val?.Value; - if (idx.HasValue) lineSerIndices.Add((int)idx.Value); - } - } - - for (int i = 0; i < seriesList.Count; i++) - { - var serEl = i < serElements.Count ? serElements[i] : null; - var spPr = serEl?.GetFirstChild(); - - // For line/scatter series, prefer line stroke color (a:ln > a:solidFill) - string? rgb = null; - var serIdx = serEl?.GetFirstChild()?.Val?.Value; - if (serIdx.HasValue && lineSerIndices.Contains((int)serIdx.Value)) - { - var ln = spPr?.GetFirstChild(); - rgb = ln?.GetFirstChild()?.GetFirstChild()?.Val?.Value; - } - // Fallback to solidFill (works for bar/area/pie) - rgb ??= spPr?.GetFirstChild()?.GetFirstChild()?.Val?.Value; - seriesColors.Add(rgb != null ? $"#{rgb}" : ChartSvgRenderer.DefaultColors[i % ChartSvgRenderer.DefaultColors.Length]); - } - } - - // 4. Estimate chart dimensions from TwoCellAnchor - var (widthPt, heightPt) = EstimateChartSize(gf); - - // 5. Read chart metadata - var chartTitle = chart?.GetFirstChild(); - var titleText = chartTitle?.Descendants().FirstOrDefault()?.Text ?? ""; - var titleFontSize = chartTitle?.Descendants().FirstOrDefault()?.FontSize; - var titleSizeCss = titleFontSize?.HasValue == true ? $"{titleFontSize.Value / 100.0:0.##}pt" : "10pt"; - - var dataLabels = plotArea.Descendants().FirstOrDefault(); - var showValues = dataLabels?.GetFirstChild()?.Val?.Value == true - || dataLabels?.GetFirstChild()?.Val?.Value == true - || dataLabels?.GetFirstChild()?.Val?.Value == true; - - var plotFillColor = plotArea.GetFirstChild() - ?.GetFirstChild()?.GetFirstChild()?.Val?.Value; - var chartFillColor = chart?.Parent?.GetFirstChild() - ?.GetFirstChild()?.GetFirstChild()?.Val?.Value; - - // Axis info - var valAxis = plotArea.GetFirstChild(); - var valAxisTitle = valAxis?.GetFirstChild()?.Descendants().FirstOrDefault()?.Text; - var catAxis = plotArea.GetFirstChild(); - var catAxisTitle = catAxis?.GetFirstChild()?.Descendants().FirstOrDefault()?.Text; - - var valScaling = valAxis?.GetFirstChild(); - double? ooxmlAxisMax = null, ooxmlAxisMin = null, ooxmlMajorUnit = null; - if (valScaling?.GetFirstChild()?.Val?.HasValue == true) - ooxmlAxisMax = valScaling.GetFirstChild()!.Val!.Value; - if (valScaling?.GetFirstChild()?.Val?.HasValue == true) - ooxmlAxisMin = valScaling.GetFirstChild()!.Val!.Value; - if (valAxis?.GetFirstChild()?.Val?.HasValue == true) - ooxmlMajorUnit = valAxis.GetFirstChild()!.Val!.Value; - - var gapWidthEl = plotArea.Descendants().FirstOrDefault(); - int? ooxmlGapWidth = gapWidthEl?.Val?.HasValue == true ? (int)gapWidthEl.Val.Value : null; - - var valAxisFontSize = valAxis?.Descendants().FirstOrDefault()?.FontSize; - var catAxisFontSize = catAxis?.Descendants().FirstOrDefault()?.FontSize; - int valLabelPx = valAxisFontSize?.HasValue == true ? (int)(valAxisFontSize.Value / 100.0 * 96 / 72) : 9; - int catLabelPx = catAxisFontSize?.HasValue == true ? (int)(catAxisFontSize.Value / 100.0 * 96 / 72) : 9; - - // Legend - var legendEl = chart?.GetFirstChild(); - var isPieOrDoughnut = chartType.Contains("pie") || chartType.Contains("doughnut"); - bool hasLegend; - if (legendEl != null) - { - var deleteEl = legendEl.GetFirstChild(); - hasLegend = deleteEl?.Val?.Value != true; - } - else hasLegend = seriesList.Count > 1 || isPieOrDoughnut; - - // 6. Create renderer with Excel-appropriate colors (light background) + // 3. Extract all chart metadata via shared helper + var info = ChartSvgRenderer.ExtractChartInfo(plotArea, chart); + // Override with locally-resolved data (Excel cell resolution may have updated categories/series). + // NOTE: seriesList here comes from Excel-specific extraction that may still include + // reference-line overlay series — re-apply the shared filter so they are not drawn + // as an extra bar/column segment on top of the real data. + info.ChartType = chartType; + info.Categories = categories; + info.Series = ChartSvgRenderer.FilterReferenceLineSeries(plotArea, seriesList); + 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.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 using actual column widths + var colWidths = GetColumnWidths(GetSheet(worksheetPart)); + var (widthPt, heightPt) = EstimateChartSize(gf, colWidths); + + // 5. Create renderer — colors from OOXML with Excel-appropriate fallbacks var renderer = new ChartSvgRenderer { - ValueColor = "#333", - CatColor = "#555", - AxisColor = "#666", - GridColor = "#ddd", - AxisLineColor = "#999", - ValFontPx = valLabelPx, - CatFontPx = catLabelPx + 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 }; - // 7. Build SVG + // 6. Build SVG var svgW = Math.Max(widthPt, 225); var svgH = Math.Max(heightPt, 150); - var titleH = string.IsNullOrEmpty(titleText) ? 0 : 30; - var legendH = 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; - int marginTop = 10, marginRight = 15, marginBottom = 30, marginLeft = 45; - var plotW = svgW - marginLeft - marginRight; - var plotH = chartSvgH - marginTop - marginBottom; - if (plotW < 50 || plotH < 50) return; - - var bgStyle = chartFillColor != null ? $"background:#{chartFillColor};" : ""; - sb.AppendLine($"
    "); + var bgStyle = info.ChartFillColor != null ? $"background:#{info.ChartFillColor};" : ""; + // Use estimated width as max-width, but allow stretching to fill parent (e.g. colspan td) + var chartDataPath = chartIdx > 0 && !string.IsNullOrEmpty(sheetName) ? $" data-path=\"/{HtmlEncode(sheetName)}/chart[{chartIdx}]\"" : ""; + sb.AppendLine($"
    "); - // Title - if (!string.IsNullOrEmpty(titleText)) - sb.AppendLine($"
    {HtmlEncode(titleText)}
    "); + var titleColor = info.TitleFontColor ?? "#333"; + if (!string.IsNullOrEmpty(info.Title)) + sb.AppendLine($"
    {HtmlEncode(info.Title)}
    "); sb.AppendLine($" "); - // Plot area background - if (plotFillColor != null) - sb.AppendLine($" "); - - // Route to correct chart renderer - var is3D = chartType.Contains("3d"); - if (chartType.Contains("pie") || chartType.Contains("doughnut")) - { - var isDoughnut = chartType.Contains("doughnut"); - var holeSize = 0.0; - if (isDoughnut) - { - var holeSizeEl = plotArea.Descendants().FirstOrDefault(); - holeSize = (holeSizeEl?.Val?.Value ?? 50) / 100.0; - } - renderer.RenderPieChartSvg(sb, seriesList, categories, seriesColors, svgW, chartSvgH, holeSize, showValues); - } - else if (chartType.Contains("area")) - { - var areaStacked = chartType.Contains("stacked") || chartType.Contains("Stacked"); - renderer.RenderAreaChartSvg(sb, seriesList, categories, seriesColors, marginLeft, marginTop, plotW, plotH, areaStacked); - } - else if (chartType == "combo") - { - renderer.RenderComboChartSvg(sb, plotArea, seriesList, categories, seriesColors, marginLeft, marginTop, plotW, plotH); - } - else if (chartType.Contains("radar")) - { - renderer.RenderRadarChartSvg(sb, seriesList, categories, seriesColors, svgW, chartSvgH, catLabelPx); - } - else if (chartType == "bubble") - { - renderer.RenderBubbleChartSvg(sb, plotArea, seriesList, categories, seriesColors, marginLeft, marginTop, plotW, plotH); - } - else if (chartType == "stock") - { - renderer.RenderStockChartSvg(sb, plotArea, seriesList, categories, seriesColors, marginLeft, marginTop, plotW, plotH); - } - else if (chartType.Contains("line") || chartType == "scatter") - { - renderer.RenderLineChartSvg(sb, seriesList, categories, seriesColors, marginLeft, marginTop, plotW, plotH, showValues); - } - else - { - // Column/bar variants - var isHorizontal = chartType.Contains("bar") && !chartType.Contains("column"); - var isStacked = chartType.Contains("stacked") || chartType.Contains("Stacked"); - var isPercent = chartType.Contains("percent") || chartType.Contains("Percent"); - renderer.RenderBarChartSvg(sb, seriesList, categories, seriesColors, marginLeft, marginTop, plotW, plotH, - isHorizontal, isStacked, isPercent, ooxmlAxisMax, ooxmlAxisMin, ooxmlMajorUnit, ooxmlGapWidth, valLabelPx, catLabelPx, showValues); - } - - // Axis titles inside SVG - if (!string.IsNullOrEmpty(valAxisTitle)) - sb.AppendLine($" {HtmlEncode(valAxisTitle)}"); - if (!string.IsNullOrEmpty(catAxisTitle)) - sb.AppendLine($" {HtmlEncode(catAxisTitle)}"); + renderer.RenderChartSvgContent(sb, info, svgW, chartSvgH); sb.AppendLine(" "); - // Legend - if (hasLegend) - { - var legendFontSize = legendEl?.Descendants().FirstOrDefault()?.FontSize; - var legendSizeCss = legendFontSize?.HasValue == true ? $"{legendFontSize.Value / 100.0:0.##}pt" : "8pt"; - sb.Append($"
    "); - if (isPieOrDoughnut && categories.Length > 0) - { - for (int i = 0; i < categories.Length; i++) - { - var color = i < seriesColors.Count ? seriesColors[i] : ChartSvgRenderer.DefaultColors[i % ChartSvgRenderer.DefaultColors.Length]; - sb.Append($"{HtmlEncode(categories[i])}"); - } - } - else - { - for (int i = 0; i < seriesList.Count && i < seriesColors.Count; i++) - sb.Append($"{HtmlEncode(seriesList[i].name)}"); - } - sb.AppendLine("
    "); - } + var legendColor = info.LegendFontColor ?? "#555"; + renderer.RenderLegendHtml(sb, info, legendColor); + + renderer.RenderDataTableHtml(sb, info); 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); @@ -389,8 +230,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)); @@ -485,16 +331,22 @@ private void ResolveChartDataFromCells(C.PlotArea plotArea, double val = 0; if (cell != null) { - var raw = cell.CellValue?.Text; - if (!string.IsNullOrEmpty(raw) && double.TryParse(raw, - System.Globalization.NumberStyles.Any, - System.Globalization.CultureInfo.InvariantCulture, out var v)) + // If the cell has a formula, always evaluate — cached values may be stale + // (e.g. generator tools often write formulas with cachedValue=0 and expect + // Excel to recompute on open). Matches GetFormattedCellValue's policy. + if (cell.CellFormula?.Text != null) { - val = v; + val = evaluator.TryEvaluate(cell.CellFormula.Text) ?? 0; } - else if (cell.CellFormula?.Text != null) + else { - val = evaluator.TryEvaluate(cell.CellFormula.Text) ?? 0; + var raw = cell.CellValue?.Text; + if (!string.IsNullOrEmpty(raw) && double.TryParse(raw, + System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var v)) + { + val = v; + } } } results.Add(val); @@ -525,14 +377,14 @@ private void ResolveChartDataFromCells(C.PlotArea plotArea, var workbookPart = _doc.WorkbookPart; if (workbookPart == null) return (null, 0, 0, 0, 0); - var sheet = workbookPart.Workbook.Descendants() + var sheet = workbookPart.Workbook?.Descendants() .FirstOrDefault(s => s.Name?.Value == sheetName); if (sheet?.Id?.Value == null) return (null, 0, 0, 0, 0); try { var worksheetPart = (WorksheetPart)workbookPart.GetPartById(sheet.Id.Value); - var sheetData = worksheetPart.Worksheet.GetFirstChild(); + var sheetData = worksheetPart.Worksheet?.GetFirstChild(); return (sheetData, startCol, startRow, endCol, endRow); } catch { return (null, 0, 0, 0, 0); } @@ -557,4 +409,68 @@ private static string GetColumnLetter(int colIndex) } return result; } + + /// + /// Render a cx:chart (Office 2016 extended chart) inside a GraphicFrame. + /// Mirrors the regular flow: extract + /// ChartInfo from the cx:chart element, instantiate the shared renderer + /// with theme colors, and emit the SVG + legend inside a chart-container div. + /// + private void RenderExcelCxChart(StringBuilder sb, XDR.GraphicFrame gf, + DrawingsPart drawingsPart, WorksheetPart worksheetPart, + string sheetName = "", int chartIdx = 0) + { + var relId = GetExtendedChartRelId(gf); + if (relId == null) return; + + DocumentFormat.OpenXml.Office2016.Drawing.ChartDrawing.Chart? chart; + try + { + var extPart = (ExtendedChartPart)drawingsPart.GetPartById(relId); + chart = extPart.ChartSpace? + .GetFirstChild(); + if (chart == null) return; + } + catch { return; } + + var info = ChartSvgRenderer.ExtractCxChartInfo(chart); + if (info.Series.Count == 0) return; + + // Dimensions from the TwoCellAnchor, same as regular charts. + var colWidths = GetColumnWidths(GetSheet(worksheetPart)); + var (widthPt, heightPt) = EstimateChartSize(gf, colWidths); + + var renderer = new ChartSvgRenderer + { + 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, + }; + + var svgW = Math.Max(widthPt, 225); + var svgH = Math.Max(heightPt, 150); + 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 chartSvgH = svgH - titleH; + if (chartSvgH < 80) return; + + var cxChartDataPath = chartIdx > 0 && !string.IsNullOrEmpty(sheetName) ? $" data-path=\"/{HtmlEncode(sheetName)}/chart[{chartIdx}]\"" : ""; + sb.AppendLine($"
    "); + + var titleColor = info.TitleFontColor ?? "#333"; + if (!string.IsNullOrEmpty(info.Title)) + sb.AppendLine($"
    {HtmlEncode(info.Title)}
    "); + + sb.AppendLine($" "); + renderer.RenderChartSvgContent(sb, info, svgW, chartSvgH); + sb.AppendLine(" "); + sb.AppendLine("
    "); + } } diff --git a/src/officecli/Handlers/Excel/ExcelHandler.HtmlPreview.Shapes.cs b/src/officecli/Handlers/Excel/ExcelHandler.HtmlPreview.Shapes.cs new file mode 100644 index 000000000..32e513847 --- /dev/null +++ b/src/officecli/Handlers/Excel/ExcelHandler.HtmlPreview.Shapes.cs @@ -0,0 +1,243 @@ +// Copyright 2025 OfficeCli (officecli.ai) +// SPDX-License-Identifier: Apache-2.0 +// +// Render xlsx shapes (xdr:sp) and textboxes as absolutely-positioned SVG/HTML +// overlays on top of the sheet grid, mirroring how CollectSheetCharts handles +// chart anchors. Ports the preset-geometry SVG logic from WordHandler. +// Pictures (xdr:pic) and graphic frames (charts) are handled elsewhere. + +using System.Text; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using Drawing = DocumentFormat.OpenXml.Drawing; +using XDR = DocumentFormat.OpenXml.Drawing.Spreadsheet; + +namespace OfficeCli.Handlers; + +public partial class ExcelHandler +{ + /// + /// Pre-render all xdr:sp shapes / textboxes and return them with their + /// anchor row/col positions (same tuple shape as CollectSheetCharts so the + /// existing overlay positioning code can consume the result). + /// + private List<(int fromRow, int toRow, int fromCol, int toCol, string html)> CollectSheetShapes(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 result; + + foreach (var anchor in drawingsPart.WorksheetDrawing.ChildElements) + { + // Collect shape child (ignore pics, graphicFrames, groupShapes) + var shape = anchor.Elements().FirstOrDefault(); + if (shape == null) continue; + + int fromRow = 0, toRow = 0, fromCol = 0, toCol = 0; + if (anchor is XDR.TwoCellAnchor tca) + { + int.TryParse(tca.FromMarker?.RowId?.Text, out fromRow); + int.TryParse(tca.ToMarker?.RowId?.Text, out toRow); + int.TryParse(tca.FromMarker?.ColumnId?.Text, out fromCol); + int.TryParse(tca.ToMarker?.ColumnId?.Text, out toCol); + } + else if (anchor is XDR.OneCellAnchor oca) + { + int.TryParse(oca.FromMarker?.RowId?.Text, out fromRow); + int.TryParse(oca.FromMarker?.ColumnId?.Text, out fromCol); + // Approximate to-row/col from ext (EMU) — used only for sizing + var cx = oca.Extent?.Cx?.Value ?? 0; + var cy = oca.Extent?.Cy?.Value ?? 0; + toCol = fromCol + Math.Max(1, (int)(cx / 914400.0 * 8)); // rough + toRow = fromRow + Math.Max(1, (int)(cy / 914400.0 * 6)); + } + else + { + // AbsoluteAnchor or unsupported — skip + continue; + } + + var sb = new StringBuilder(); + RenderShape(sb, shape); + result.Add((fromRow, toRow, fromCol, toCol, sb.ToString())); + } + + return result; + } + + /// + /// Render a single xdr:sp element as an SVG (for preset geometry) plus + /// optional text body as an overlaid HTML flex-div. + /// + private static void RenderShape(StringBuilder sb, XDR.Shape shape) + { + var spPr = shape.ShapeProperties; + var prstGeom = spPr?.GetFirstChild(); + // Preset token — Shape.Preset enum value serializes to the OOXML token + // (e.g. "rect", "roundRect", "ellipse"). Fall back to "rect". + var prst = prstGeom?.Preset?.Value.ToString() ?? "rect"; + + // Fill + var fillHex = TryReadSolidFillHex(spPr) ?? "#FFFFFF"; + var hasNoFill = spPr?.GetFirstChild() != null; + if (hasNoFill) fillHex = "transparent"; + + // Line/stroke + var ln = spPr?.GetFirstChild(); + var strokeHex = ln != null ? (TryReadSolidFillHex(ln) ?? "#000000") : "#000000"; + var strokeWidthPx = 1.0; + if (ln?.Width?.Value is int lw) strokeWidthPx = Math.Max(0.5, lw / 12700.0); // EMU→pt≈px + var hasNoLine = ln?.GetFirstChild() != null; + + // Outer div fills the overlay parent. + sb.Append("
    "); + + // Inline SVG overlay for the geometry. + sb.Append(""); + RenderPrstGeomSvgExcel(sb, prst, fillHex, hasNoLine ? "none" : strokeHex, strokeWidthPx); + sb.Append(""); + + // Text body overlay as HTML (positioned above SVG via relative stacking) + var txBody = shape.TextBody; + if (txBody != null) + { + RenderShapeTextBody(sb, txBody); + } + + sb.Append("
    "); + } + + /// + /// Extract the first solidFill's hex color from the given element (or its + /// outline child). Returns #-prefixed hex or null. + /// + private static string? TryReadSolidFillHex(OpenXmlElement? el) + { + if (el == null) return null; + var solid = el.GetFirstChild(); + if (solid == null) return null; + var srgb = solid.GetFirstChild(); + if (srgb?.Val?.Value is string hex && hex.Length >= 6) + { + var v = hex.Length > 6 ? hex[^6..] : hex; + return "#" + v.ToUpperInvariant(); + } + var scheme = solid.GetFirstChild(); + if (scheme?.Val != null) + { + // Leave scheme references unresolved here; callers treat null as fallback. + return null; + } + return null; + } + + /// + /// Render a shape's a:txBody as stacked <div> lines centered in the + /// host container. Honors run-level size/bold/italic/color and paragraph + /// alignment. + /// + private static void RenderShapeTextBody(StringBuilder sb, XDR.TextBody txBody) + { + sb.Append("
    "); + foreach (var para in txBody.Elements()) + { + var pPr = para.GetFirstChild(); + var align = pPr?.Alignment?.Value.ToString() switch + { + "ctr" => "center", + "r" => "right", + "l" => "left", + _ => "center" + }; + sb.Append($"
    "); + foreach (var run in para.Elements()) + { + var rPr = run.RunProperties; + var style = new StringBuilder(); + if (rPr?.FontSize?.Value is int fs) style.Append($"font-size:{fs / 100.0:0.##}pt;"); + if (rPr?.Bold?.Value == true) style.Append("font-weight:bold;"); + if (rPr?.Italic?.Value == true) style.Append("font-style:italic;"); + var colorHex = TryReadSolidFillHex(rPr); + if (colorHex != null) style.Append($"color:{colorHex};"); + var text = run.Text?.Text ?? ""; + sb.Append($"{HtmlEncode(text)}"); + } + sb.Append("
    "); + } + sb.Append("
    "); + } + + /// + /// Emit SVG content for the given preset geometry inside a 0..100 viewBox. + /// Mirrors WordHandler.RenderPrstGeomSvg with the addition of rect / + /// roundRect / ellipse / triangle / diamond / parallelogram that xlsx + /// shapes most commonly use. Unknown presets fall back to a plain rect. + /// + private static void RenderPrstGeomSvgExcel( + StringBuilder sb, string prst, string fill, string stroke, double strokeW) + { + var sw = strokeW.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture); + var strokeAttrs = stroke == "none" + ? "stroke=\"none\"" + : $"stroke=\"{stroke}\" stroke-width=\"{sw}\" vector-effect=\"non-scaling-stroke\""; + switch (prst) + { + case "rect": + sb.Append($""); + break; + case "roundRect": + // Default adjustment ~0.1 of shorter side; viewBox is 100 so rx=10. + sb.Append($""); + break; + case "ellipse": + case "oval": + sb.Append($""); + break; + case "triangle": + sb.Append($""); + break; + case "rtTriangle": + sb.Append($""); + break; + case "diamond": + sb.Append($""); + break; + case "parallelogram": + sb.Append($""); + break; + case "trapezoid": + sb.Append($""); + break; + case "pentagon": + sb.Append($""); + break; + case "hexagon": + sb.Append($""); + break; + case "octagon": + sb.Append($""); + break; + case "line": + case "straightConnector1": + sb.Append($""); + break; + case "rightArrow": + sb.Append($""); + break; + case "leftArrow": + sb.Append($""); + break; + case "upArrow": + sb.Append($""); + break; + case "downArrow": + sb.Append($""); + break; + default: + // Unknown preset — fall back to a plain rect so the shape is at + // least visible at its anchored position (better than blank). + sb.Append($""); + break; + } + } +} diff --git a/src/officecli/Handlers/Excel/ExcelHandler.HtmlPreview.cs b/src/officecli/Handlers/Excel/ExcelHandler.HtmlPreview.cs index dada5976a..655bc0e7b 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, @@ -22,6 +88,42 @@ public string ViewAsHtml() var wbStylesPart = _doc.WorkbookPart?.WorkbookStylesPart; var stylesheet = wbStylesPart?.Stylesheet; + // If any sheet has a pivot table, build an editable in-memory copy so + // we can re-materialize cells from the pivot cache without mutating + // the live _doc. The copy's WorksheetParts replace the originals for + // rendering; styles/theme come from _doc (identical). + // + // CONSISTENCY(pivot-clone-in-memory): we clone _doc directly instead of + // re-opening _filePath from disk. The earlier "read the file back via + // FileStream(FileShare.ReadWrite)" approach races the handler's still- + // held editable handle on macOS and throws IOException despite the + // share-mode hint — the error surfaces as a trailing "process cannot + // access" stderr after every add pivot/slicer command, and worse, on + // every SUBSEQUENT command once the file has a pivot part at all (the + // `sheets.Any(...PivotTableParts...)` branch fires on every ViewAsHtml + // from the NotifyWatch path). SpreadsheetDocument.Clone(Stream, bool) + // serialises the already-loaded package into the MemoryStream without + // touching disk, so there is no second file handle to race. + MemoryStream? pivotMs = null; + SpreadsheetDocument? pivotDoc = null; + List<(string Name, WorksheetPart Part)>? pivotSheets = null; + if (sheets.Any(s => s.Part.PivotTableParts.Any())) + { + pivotMs = new MemoryStream(); + pivotDoc = (SpreadsheetDocument)_doc.Clone(pivotMs, isEditable: true); + pivotSheets = GetWorksheets(pivotDoc); + + foreach (var (_, wsPart) in pivotSheets) + { + if (wsPart.PivotTableParts.Any()) + OfficeCli.Core.PivotTableHelper.RefreshPivotCellsForView(wsPart); + } + + // Use the copy's stylesheet so new indent styles created by the + // pivot refresh are visible to the HTML renderer. + stylesheet = pivotDoc.WorkbookPart?.WorkbookStylesPart?.Stylesheet; + } + sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); @@ -42,14 +144,24 @@ public string ViewAsHtml() for (int sheetIdx = 0; sheetIdx < sheets.Count; sheetIdx++) { var (sheetName, worksheetPart) = sheets[sheetIdx]; + // Use the pivot-refreshed copy's WorksheetPart when available + var renderPart = pivotSheets != null && sheetIdx < pivotSheets.Count + ? pivotSheets[sheetIdx].Part : worksheetPart; var activeClass = sheetIdx == 0 ? " active" : ""; // Check if sheet is RTL - var sheetView = GetSheet(worksheetPart).GetFirstChild()?.GetFirstChild(); + var sheetView = GetSheet(renderPart).GetFirstChild()?.GetFirstChild(); 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, sheetName); + // Shapes and textboxes (xdr:sp). Reuses the chart overlay + // positioning pipeline — same (fromRow,toRow,fromCol,toCol,html) + // tuple is consumed by RenderSheetTable to emit an absolutely- + // positioned overlay over the sheet grid. + var shapes = CollectSheetShapes(worksheetPart); + if (shapes.Count > 0) + charts.AddRange(shapes); + RenderSheetTable(sb, sheetName, renderPart, stylesheet, charts, sheetIdx); sb.AppendLine("
    "); } sb.AppendLine("
    "); @@ -66,7 +178,11 @@ public string ViewAsHtml() { var rgb = tabColorEl.Rgb.Value; if (rgb.Length > 6) rgb = rgb[^6..]; - tabColorStyle = $" style=\"--tab-color:#{rgb}\""; + // Hex-gate before inline style interpolation — unchecked + // raw value would break out of the style attribute. + if (rgb.Length == 6 + && rgb.All(c => (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) + tabColorStyle = $" style=\"--tab-color:#{rgb}\""; } sb.AppendLine($"
    {HtmlEncode(sheets[i].Name)}
    "); } @@ -76,10 +192,22 @@ public string ViewAsHtml() sb.AppendLine(""); + // CONSISTENCY(excel-virt): private virt script injected after standard overlay. + // Open-source GetVirtScript() returns empty; private override loads watch-overlay-virt.js. + var virtScript = GetVirtScript(); + if (virtScript.Length > 0) + { + sb.AppendLine(""); + } sb.AppendLine(""); sb.AppendLine(""); + pivotDoc?.Dispose(); + pivotMs?.Dispose(); + return sb.ToString(); } @@ -100,24 +228,45 @@ 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, int sheetIdx = 0) { var ws = GetSheet(worksheetPart); var sheetData = ws.GetFirstChild(); - if (sheetData == null) + if (sheetData == null && (charts == null || charts.Count == 0)) { - // Don't show "Empty sheet" if there are charts if (worksheetPart.DrawingsPart?.WorksheetDrawing == null) sb.AppendLine("
    Empty sheet
    "); 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); + var evaluator = sheetData != null ? new Core.FormulaEvaluator(sheetData, _doc.WorkbookPart) : null; // Collect merge info var mergeMap = BuildMergeMap(ws); + // Build conditional formatting CSS overrides (skip if no cell data) + var cfMap = sheetData != null ? BuildConditionalFormatMap(ws, stylesheet, sheetData, _doc.WorkbookPart) : new Dictionary(); + var dataBarMap = sheetData != null ? BuildDataBarMap(ws, sheetData) : new Dictionary(); + var iconSetMap = sheetData != null ? BuildIconSetMap(ws, sheetData) : new Dictionary(); + // Collect column widths var colWidths = GetColumnWidths(ws); @@ -133,31 +282,45 @@ 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 - var rows = sheetData.Elements().ToList(); + // Determine grid dimensions. Count all cells that exist in SheetData — + // every Cell element with a CellReference contributes to maxRow/maxCol, + // even if the cell is empty (no value, no formula). Empty cells are + // explicitly created by the user or by Excel; either way they should + // render so the grid matches the actual data range. + var rows = sheetData?.Elements().ToList() ?? new List(); int maxCol = 0; int maxRow = 0; foreach (var row in rows) { var rowIdx = (int)(row.RowIndex?.Value ?? 0); - if (rowIdx > maxRow) maxRow = rowIdx; + bool rowHasCells = 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; + var (colName, _) = ParseCellReference(cellRef); + var colIdx = ColumnNameToIndex(colName); + if (colIdx > maxCol) maxCol = colIdx; + rowHasCells = true; + } + if (rowHasCells && rowIdx > maxRow) maxRow = rowIdx; + } + + // Extend maxRow/maxCol from chart anchors even when no cell data + if (charts != null) + { + foreach (var (fromRow, toRow, fromCol, toCol, _) in charts) + { + if (toRow > maxRow) maxRow = toRow; + if (toCol > maxCol) maxCol = toCol; } } - // Empty sheet (SheetData exists but no rows/cells) + // Empty sheet (no cells and no charts) if (maxRow == 0 || maxCol == 0) { if (worksheetPart.DrawingsPart?.WorksheetDrawing == null) @@ -165,10 +328,20 @@ private void RenderSheetTable(StringBuilder sb, string sheetName, WorksheetPart return; } - // Limit rendering to reasonable size + // 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; + } + + // Column cap: >200 cols is unusable in a browser table regardless of rendering mode. + // Row cap: default 5000; overridable via OnGetHtmlRowCap when the rendering backend + // keeps DOM node count bounded independently of sheet size. var actualRow = maxRow; var actualCol = maxCol; - maxRow = Math.Min(maxRow, 5000); + maxRow = Math.Min(maxRow, GetHtmlRowCap()); maxCol = Math.Min(maxCol, 200); var truncated = actualRow > maxRow || actualCol > maxCol; @@ -201,6 +374,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 +416,62 @@ private void RenderSheetTable(StringBuilder sb, string sheetName, WorksheetPart if (widthPx <= 0) hiddenCols.Add(colIdx); } - // Start table - sb.AppendLine("
    "); - sb.AppendLine(""); + // Auto-fit columns without explicit OOXML widths: scan cell content and + // compute a width from the longest text in each column. Uses a simple + // char-width heuristic (CJK ≈ 1.8 char units, ASCII ≈ 1) converted to + // pt via the same chars × 7.0017 × 0.75 formula as explicit widths. + // Only columns that have NO entry in colWidths are auto-fitted; columns + // with explicit widths (including 0 = hidden) are left as-is. + for (int c = 1; c <= maxCol; c++) + { + if (colWidths.ContainsKey(c)) continue; + double maxChars = 0; + for (int r = 1; r <= maxRow; r++) + { + if (!cellMap.TryGetValue((r, c), out var cell)) continue; + var text = GetCellDisplayValue(cell); + if (string.IsNullOrEmpty(text)) continue; + double chars = 0; + foreach (var ch in text) + chars += ch > 0x2E7F ? 2.2 : 1.0; // CJK / fullwidth → ~2.2 char units + if (chars > maxChars) maxChars = chars; + } + if (maxChars > 0) + { + // Add 2 char padding, cap at 60 chars to avoid extreme widths + maxChars = Math.Min(maxChars + 2, 60); + colWidths[c] = maxChars * 7.0017 * 0.75; + } + } + + // 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 (position:relative for chart overlays) + 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(""); @@ -249,68 +499,199 @@ private void RenderSheetTable(StringBuilder sb, string sheetName, WorksheetPart } else stickyStyle = ""; - sb.Append($""); + sb.Append($""); } sb.AppendLine(""); - // Data rows - sb.AppendLine(""); - for (int r = 1; r <= maxRow; r++) - { - if (hiddenRows.Contains(r)) { sb.AppendLine(""); continue; } - var rowH = rowHeights.TryGetValue(r, out var rh) ? $" style=\"height:{rh:0.##}pt\"" : ""; - sb.Append($""); + // chartAtRow and sideCharts already built above - // Row header - var rowHeaderSticky = frozenCols > 0 ? " style=\"position:sticky;left:0;z-index:2\"" : ""; - sb.Append($""); + // Visible column count for chart colspan + var visibleColCount = Enumerable.Range(1, maxCol).Count(c => !hiddenCols.Contains(c)); - for (int c = 1; c <= maxCol; c++) + // CONSISTENCY(excel-virt): Extension point — private override in + // ExcelHandler.HtmlPreview.Virt.cs replaces the full static tbody with a + // JSON-data tbody + JS virtual renderer. BuildRowInnerHtml is shared for + // cell rendering; open-source RenderTbody emits static elements. + var ctx = new SheetRenderContext(sheetName, sheetIdx, cellMap, maxRow, maxCol, + rowHeights, hiddenRows, hiddenCols, mergeMap, frozenRows, frozenCols, + frozenLeftOffsets, frozenTopOffsets, cfMap, dataBarMap, iconSetMap, + stylesheet, evaluator, defaultColWidthPt, defaultRowHeightPt); + RenderTbody(sb, ctx); + sb.AppendLine("
    {HtmlEncode(sheetName)}
    {colName}{colName}
    {r}
    "); + + // Render charts as absolute-positioned overlays on top of the table grid. + // Position is computed from anchor row/col using column widths and row heights. + if (charts != null) + { + var rowHeaderWidthPt = 30.0; // matches .row-header-col CSS + foreach (var (fromRow, toRow, fromCol, toCol, html) in charts) { - if (hiddenCols.Contains(c)) continue; - // Check if this cell is hidden by a merge (non-anchor cell in merged range) - var cellRef = $"{IndexToColumnName(c)}{r}"; - if (mergeMap.TryGetValue(cellRef, out var mergeInfo)) + // Compute left position: sum of column widths from col 1 to fromCol + row header + double leftPt = rowHeaderWidthPt; + for (int c = 1; c <= fromCol && c <= maxCol; c++) { - 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 value = cell != null ? GetFormattedCellValue(cell, stylesheet, evaluator) : ""; - // Adjust colspan to exclude hidden columns within the merge range - var adjColSpan = mergeInfo.ColSpan; - if (adjColSpan > 1 && hiddenCols.Count > 0) - { - for (int hc = c + 1; hc < c + mergeInfo.ColSpan; hc++) - if (hiddenCols.Contains(hc)) adjColSpan--; - } - var spanAttrs = ""; - if (adjColSpan > 1) spanAttrs += $" colspan=\"{adjColSpan}\""; - if (mergeInfo.RowSpan > 1) spanAttrs += $" rowspan=\"{mergeInfo.RowSpan}\""; - - sb.Append($"{CellHtml(value)}"); + if (hiddenCols.Contains(c)) continue; + leftPt += colWidths.TryGetValue(c, out var cw) ? cw : defaultColWidthPt; } - else + // Compute top position: sum of row heights from row 1 to fromRow + header row (~24px) + double topPt = 24.0 * 0.75; // header row height in pt + for (int r = 1; r <= fromRow && r <= maxRow; r++) + { + if (hiddenRows.Contains(r)) continue; + topPt += rowHeights.TryGetValue(r, out var rh) ? rh : defaultRowHeightPt; + } + // Compute width/height from anchor span + double widthPt = 0; + for (int c = fromCol + 1; c <= toCol && c <= maxCol; c++) + { + if (hiddenCols.Contains(c)) continue; + widthPt += colWidths.TryGetValue(c, out var cw2) ? cw2 : defaultColWidthPt; + } + double heightPt = 0; + for (int r = fromRow + 1; r <= toRow && r <= maxRow; r++) { - var cell = cellMap.TryGetValue((r, c), out var nc) ? nc : null; - var style = GetCellStyleCss(cell, stylesheet, frozenRows, frozenCols, r, c, frozenLeftOffsets); - var value = cell != null ? GetFormattedCellValue(cell, stylesheet, evaluator) : ""; - sb.Append($"{CellHtml(value)}"); + if (hiddenRows.Contains(r)) continue; + heightPt += rowHeights.TryGetValue(r, out var rh2) ? rh2 : defaultRowHeightPt; } + if (widthPt < 100) widthPt = 400; // fallback min size + if (heightPt < 50) heightPt = 250; + + sb.AppendLine($"
    "); + sb.Append(html); + sb.AppendLine("
    "); } - sb.AppendLine(""); } - sb.AppendLine(""); - sb.AppendLine(""); + // Truncation warning if (truncated) sb.AppendLine($"
    Showing {maxRow} of {actualRow} rows, {maxCol} of {actualCol} columns
    "); - sb.AppendLine("
    "); + sb.AppendLine("
    "); // close table-wrapper } // ==================== Merge Map ==================== - private record struct MergeInfo(bool IsAnchor, int RowSpan, int ColSpan); + internal record struct MergeInfo(bool IsAnchor, int RowSpan, int ColSpan); + + // CONSISTENCY(excel-virt): Packages all sheet-level computed data needed to render + // tbody rows. Passed to RenderTbody so the private virt override can serialise all + // cell HTML to JSON without re-running the data-collection logic. + internal record SheetRenderContext( + string SheetName, + int SheetIdx, + Dictionary<(int row, int col), Cell> CellMap, + int MaxRow, int MaxCol, + Dictionary RowHeights, + HashSet HiddenRows, + HashSet HiddenCols, + Dictionary MergeMap, + int FrozenRows, int FrozenCols, + Dictionary FrozenLeftOffsets, + Dictionary FrozenTopOffsets, + Dictionary CfMap, + Dictionary DataBarMap, + Dictionary IconSetMap, + Stylesheet? Stylesheet, + Core.FormulaEvaluator? Evaluator, + double DefaultColWidthPt, + double DefaultRowHeightPt); + + // CONSISTENCY(excel-virt): Private ExcelHandler.HtmlPreview.Virt.cs implements + // OnRenderTbody to emit virtualised rows (JSON data + empty tbody) and sets + // handled=true to skip the default. When no private implementation exists the + // partial call is removed by the compiler and the default static rendering runs. + partial void OnRenderTbody(StringBuilder sb, SheetRenderContext ctx, ref bool handled); + + // CONSISTENCY(excel-virt): default 5000-row cap for HTML preview; backend can + // override via OnGetHtmlRowCap when DOM node count is bounded independently. + partial void OnGetHtmlRowCap(ref int cap); + internal int GetHtmlRowCap() + { + var cap = 5000; + OnGetHtmlRowCap(ref cap); + return cap; + } + + internal void RenderTbody(StringBuilder sb, SheetRenderContext ctx) + { + bool handled = false; + OnRenderTbody(sb, ctx, ref handled); + if (handled) return; + // Default: render all rows as static elements. + sb.AppendLine(""); + for (int r = 1; r <= ctx.MaxRow; r++) + { + if (ctx.HiddenRows.Contains(r)) { sb.AppendLine($""); continue; } + bool isRowFrozen = ctx.FrozenRows > 0 && r <= ctx.FrozenRows; + var rowStyles = new List(); + if (ctx.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($""); + sb.Append(BuildRowInnerHtml(ctx, r, isRowFrozen)); + sb.AppendLine(""); + } + sb.AppendLine(""); + } + + // CONSISTENCY(excel-virt): Shared row-cell renderer used by RenderTbody (open-source + // static rendering) and ExcelHandler.HtmlPreview.Virt.cs (JSON serialisation). + // Returns the inner content: row-header + all cell elements, + // without the wrapper. + internal string BuildRowInnerHtml(SheetRenderContext ctx, int r, bool isRowFrozen) + { + var rowSb = new StringBuilder(); + string rowHeaderStyle; + if (isRowFrozen) + rowHeaderStyle = " style=\"position:sticky;top:0;left:0;z-index:3\""; + else if (ctx.FrozenCols > 0) + rowHeaderStyle = " style=\"position:sticky;left:0;z-index:2\""; + else + rowHeaderStyle = ""; + rowSb.Append($"{r}"); + + for (int c = 1; c <= ctx.MaxCol; c++) + { + if (ctx.HiddenCols.Contains(c)) continue; + var cellRef = $"{IndexToColumnName(c)}{r}"; + if (ctx.MergeMap.TryGetValue(cellRef, out var mergeInfo)) + { + if (!mergeInfo.IsAnchor) continue; + var cell = ctx.CellMap.TryGetValue((r, c), out var mc) ? mc : null; + var style = GetCellStyleCss(cell, ctx.Stylesheet, ctx.FrozenRows, ctx.FrozenCols, r, c, ctx.FrozenLeftOffsets, ctx.FrozenTopOffsets, ctx.CfMap, ctx.DataBarMap, ctx.IconSetMap); + var value = cell != null ? GetFormattedCellValue(cell, ctx.Stylesheet, ctx.Evaluator) : ""; + var adjColSpan = mergeInfo.ColSpan; + if (adjColSpan > 1 && ctx.HiddenCols.Count > 0) + for (int hc = c + 1; hc < c + mergeInfo.ColSpan; hc++) + if (ctx.HiddenCols.Contains(hc)) adjColSpan--; + var spanAttrs = ""; + if (adjColSpan > 1) spanAttrs += $" colspan=\"{adjColSpan}\""; + if (mergeInfo.RowSpan > 1) spanAttrs += $" rowspan=\"{mergeInfo.RowSpan}\""; + rowSb.Append($"{BuildCellContent(cellRef, value, ctx.DataBarMap, ctx.IconSetMap)}"); + } + else + { + var cell = ctx.CellMap.TryGetValue((r, c), out var nc) ? nc : null; + var style = GetCellStyleCss(cell, ctx.Stylesheet, ctx.FrozenRows, ctx.FrozenCols, r, c, ctx.FrozenLeftOffsets, ctx.FrozenTopOffsets, ctx.CfMap, ctx.DataBarMap, ctx.IconSetMap); + var value = cell != null ? GetFormattedCellValue(cell, ctx.Stylesheet, ctx.Evaluator) : ""; + rowSb.Append($"{BuildCellContent(cellRef, value, ctx.DataBarMap, ctx.IconSetMap)}"); + } + } + return rowSb.ToString(); + } + + // CONSISTENCY(excel-virt): Private ExcelHandler.HtmlPreview.Virt.cs implements + // OnGetVirtScript to load watch-overlay-virt.js from embedded resources. + // When no private implementation exists the partial call is removed and result + // stays empty (no virtualisation script injected). + partial void OnGetVirtScript(ref string result); + + internal string GetVirtScript() + { + var result = string.Empty; + OnGetVirtScript(ref result); + return result; + } private Dictionary BuildMergeMap(Worksheet ws) { @@ -362,7 +743,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 +770,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 +1162,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 +1171,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 +1189,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; @@ -444,6 +1238,10 @@ 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 +1300,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 +1316,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 +1337,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 +1352,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 +1404,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 +1423,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 +1432,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,46 +1450,38 @@ 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; } private static string FormatColorForCss(string raw) { - // ARGB "FFFF0000" → "#FF0000", or 6-char hex - if (raw.Length == 8) - return "#" + raw[2..]; - if (raw.Length == 6) - return "#" + raw; - return "#" + raw; + // Reject non-hex raw values before interpolating into inline CSS — + // styles.xml / indexedColors attrs are attacker-controlled, and an + // unvalidated raw flows into `color:#{raw}` / `background:#{raw}` + // as an XSS sink. + static bool isHex(string s) => + s.All(c => (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f')); + if (raw.Length == 8 && isHex(raw)) return "#" + raw[2..]; + if (raw.Length is 6 or 3 && isHex(raw)) return "#" + raw; + return "#000"; } // ==================== Formatted Cell Value ==================== @@ -829,6 +1632,12 @@ private static string ApplyNumberFormat(double value, string fmtCode) var fmt = fmtCode.ToLowerInvariant(); + // Date/time formats may contain quoted literals (e.g. "D"d"D"). + // Skip prefix/suffix extraction for these — the date handler in + // ApplyNumberFormatCore processes quotes via NormalizeDateFormatCase. + if (ContainsDateTokenOutsideQuotes(fmtCode)) + return ApplyNumberFormatCore(value, fmtCode); + // Extract currency/text prefix and suffix (e.g. "$", "€", "¥", or quoted strings like "USD ") var prefix = ""; var suffix = ""; @@ -863,7 +1672,14 @@ private static string ApplyNumberFormat(double value, string fmtCode) else if (cleanFmt.StartsWith('-')) { prefix += "-"; cleanFmt = cleanFmt[1..]; } + // Pure text format (only quoted prefix/suffix, no numeric pattern) + if (string.IsNullOrEmpty(cleanFmt.Trim())) + return prefix + suffix; + 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; } @@ -900,8 +1716,7 @@ private static string ApplyNumberFormatCore(double value, string fmtCode) var dt = DateTime.FromOADate(value); // Context-sensitive m/mm: after h → minute, otherwise → month // Strategy: mark minute 'm' as '\x01' placeholder, then convert remaining m→M - var dotnetFmt = fmtCode - .Replace("AM/PM", "tt").Replace("am/pm", "tt"); + var dotnetFmt = NormalizeDateFormatCase(fmtCode); // Step 1: Replace h:mm and h:m patterns → mark minutes as placeholder dotnetFmt = System.Text.RegularExpressions.Regex.Replace(dotnetFmt, @"([hH]+)([:.])(mm?)", m => m.Groups[1].Value + m.Groups[2].Value + new string('\x01', m.Groups[3].Value.Length)); @@ -914,8 +1729,8 @@ private static string ApplyNumberFormatCore(double value, string fmtCode) // Step 3: Restore minute placeholders dotnetFmt = dotnetFmt.Replace("\x01\x01", "mm").Replace("\x01", "m"); // Step 4: Other conversions - // If AM/PM format (has 'tt'), use h (12h); otherwise use H (24h) - if (!dotnetFmt.Contains("tt")) + // If AM/PM format (has 't' outside quotes), use h (12h); otherwise use H (24h) + if (!ContainsCharOutsideQuotes(dotnetFmt, 't')) dotnetFmt = dotnetFmt.Replace("hh", "HH").Replace("h", "H"); dotnetFmt = dotnetFmt.Replace("dddd", "dddd").Replace("ddd", "ddd").Replace("dd", "dd"); return dt.ToString(dotnetFmt, System.Globalization.CultureInfo.InvariantCulture); @@ -979,9 +1794,88 @@ private static int CountDecimalPlaces(string fmtCode) return count; } + /// + /// Returns true if fmtCode contains date/time tokens (y, m, d, h, s) outside + /// double-quoted strings. Used to route date formats past prefix/suffix extraction. + /// + private static bool ContainsDateTokenOutsideQuotes(string fmtCode) + { + bool inQuote = false; + foreach (var ch in fmtCode) + { + if (ch == '"') { inQuote = !inQuote; continue; } + if (!inQuote) + { + var lower = char.ToLowerInvariant(ch); + if (lower is 'y' or 'm' or 'd' or 'h' or 's') return true; + } + } + return false; + } + + /// + /// Returns true if ch appears outside double-quoted strings in fmtCode. + /// + private static bool ContainsCharOutsideQuotes(string fmtCode, char target) + { + bool inQuote = false; + foreach (var ch in fmtCode) + { + if (ch == '"') { inQuote = !inQuote; continue; } + if (!inQuote && ch == target) return true; + } + return false; + } + + /// + /// Normalize Excel date/time format specifiers to .NET-compatible case + /// and replace AM/PM → tt, A/P → t outside quoted strings. + /// + private static string NormalizeDateFormatCase(string fmtCode) + { + var sb = new StringBuilder(fmtCode.Length); + bool inQuote = false; + for (int i = 0; i < fmtCode.Length; i++) + { + var ch = fmtCode[i]; + if (ch == '"') { inQuote = !inQuote; sb.Append(ch); continue; } + if (inQuote) { sb.Append(ch); continue; } + // AM/PM → tt (check before single-char A/P) + if ((ch == 'A' || ch == 'a') && i + 4 < fmtCode.Length + && (fmtCode[i + 1] == 'M' || fmtCode[i + 1] == 'm') + && fmtCode[i + 2] == '/' + && (fmtCode[i + 3] == 'P' || fmtCode[i + 3] == 'p') + && (fmtCode[i + 4] == 'M' || fmtCode[i + 4] == 'm')) + { + sb.Append("tt"); i += 4; continue; + } + // A/P → t + if ((ch == 'A' || ch == 'a') && i + 2 < fmtCode.Length + && fmtCode[i + 1] == '/' + && (fmtCode[i + 2] == 'P' || fmtCode[i + 2] == 'p')) + { + sb.Append('t'); i += 2; continue; + } + sb.Append(ch switch { 'Y' => 'y', 'D' => 'd', 'S' => 's', 'M' => 'm', 'H' => 'h', _ => ch }); + } + return sb.ToString(); + } + // ==================== 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 = CssSanitize(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 +1942,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; } @@ -1069,6 +1963,7 @@ private static string GenerateExcelCss() => """ z-index: 3; background: #f8f8f8; min-width: 50px; + cursor: s-resize; } .row-header { position: sticky; @@ -1076,9 +1971,22 @@ private static string GenerateExcelCss() => """ z-index: 2; background: #f8f8f8; min-width: 40px; + cursor: e-resize; + /* 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; + /* Default gridlines are painted with inset box-shadow instead of + border, so they do NOT participate in border-collapse tie-breaking. + Explicit OOXML borders (rendered as inline border styles on cells + with an OOXML style) always win at cell boundaries; missing cells + / style-0 cells no longer erase neighbours' black borders via the + CSS position-based tie-break. Right+bottom gridlines are owned by + each cell; first-row top and first-col left gridlines are added + via the :first-child rules below. */ + box-shadow: inset -1px -1px 0 #e0e0e0; padding: 2px 4px; white-space: nowrap; overflow: hidden; @@ -1087,14 +1995,15 @@ private static string GenerateExcelCss() => """ max-width: 500px; word-break: break-all; /* CJK text wrapping support */ } + tbody tr:first-child td { box-shadow: inset -1px -1px 0 #e0e0e0, inset 0 1px 0 #e0e0e0; } + tr td:first-of-type { box-shadow: inset -1px -1px 0 #e0e0e0, inset 1px 0 0 #e0e0e0; } + tbody tr:first-child td:first-of-type { box-shadow: inset -1px -1px 0 #e0e0e0, inset 1px 1px 0 #e0e0e0; } .empty-sheet { padding: 40px; text-align: center; color: #999; font-size: 14px; } - /* Frozen pane visual separator */ - tr:nth-child(1) td { border-top-color: #e0e0e0; } /* Chart containers */ .chart-container { margin: 16px auto; @@ -1126,6 +2035,7 @@ @media print { td { max-width: none !important; white-space: normal !important; overflow: visible !important; } } """; + } // ==================== JavaScript ==================== @@ -1139,6 +2049,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 +2084,48 @@ private static string CellHtml(string text) return encoded.Contains('\n') ? encoded.Replace("\n", "
    ") : encoded; } + /// Get data-formula attribute for cells with formulas (for inline editing). + private static string GetFormulaAttr(Cell? cell) + { + var formula = cell?.CellFormula?.Text; + if (string.IsNullOrEmpty(formula)) return ""; + return $" data-formula=\"={HtmlEncode(formula)}\""; + } + + 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.Import.cs b/src/officecli/Handlers/Excel/ExcelHandler.Import.cs index dd6c823f3..e193da394 100644 --- a/src/officecli/Handlers/Excel/ExcelHandler.Import.cs +++ b/src/officecli/Handlers/Excel/ExcelHandler.Import.cs @@ -40,22 +40,30 @@ public string Import(string parentPath, string csvContent, char delimiter, bool if (rows.Count == 0) return "No data to import"; + // Import writes rows sequentially — bypass FindOrCreateCell (which is O(n) per row, + // causing O(n²) total for large imports) and directly append Row/Cell nodes in order. int maxCols = 0; for (int r = 0; r < rows.Count; r++) { var fields = rows[r]; if (fields.Count > maxCols) maxCols = fields.Count; - var rowIdx = startRow + r; + var rowIdx = (uint)(startRow + r); + + var row = new Row { RowIndex = rowIdx }; + sheetData.Append(row); for (int c = 0; c < fields.Count; c++) { var colIdx = startColIdx + c; var cellRef = $"{IndexToColumnName(colIdx)}{rowIdx}"; - var cell = FindOrCreateCell(sheetData, cellRef); + var cell = new Cell { CellReference = cellRef.ToUpperInvariant() }; + row.Append(cell); SetCellValueWithTypeDetection(cell, fields[c]); } } + InvalidateRowIndex(sheetData); + // --header: set AutoFilter on data range and freeze pane below first row if (hasHeader && rows.Count > 0) { @@ -126,10 +134,15 @@ private static void SetCellValueWithTypeDetection(Cell cell, string value) return; } + // R13-1: enforce Excel's 32767-char per-cell limit at the CSV/TSV + // import path too, so bulk imports fail fast instead of producing a + // file Excel refuses to open. + EnsureCellValueLength(value, cell.CellReference?.Value); + // Formula: starts with = if (value.StartsWith('=')) { - cell.CellFormula = new CellFormula(value[1..]); + cell.CellFormula = new CellFormula(OfficeCli.Core.ModernFunctionQualifier.Qualify(value[1..])); cell.CellValue = null; cell.DataType = null; return; diff --git a/src/officecli/Handlers/Excel/ExcelHandler.Query.cs b/src/officecli/Handlers/Excel/ExcelHandler.Query.cs index fda50984f..5700c6769 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 @@ -153,6 +159,13 @@ public DocumentNode Get(string path, int depth = 1) var tabColor = ws.GetFirstChild()?.GetFirstChild(); if (tabColor?.Rgb?.HasValue == true) sheetNode.Format["tabColor"] = ParseHelpers.FormatHexColor(tabColor.Rgb.Value!); + else if (tabColor?.Theme?.HasValue == true) + { + // CONSISTENCY(scheme-color): echo back the symbolic name + // (e.g. "accent1") instead of the numeric theme index. + var schemeName = ParseHelpers.ExcelThemeIndexToName(tabColor.Theme.Value); + if (schemeName != null) sheetNode.Format["tabColor"] = schemeName; + } // Include autofilter info var autoFilter = ws.GetFirstChild(); @@ -313,6 +326,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 +336,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 +361,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 +490,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,10 +568,34 @@ 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; } + // Slicer path: /Sheet1/slicer[N] + var slicerMatch = Regex.Match(cellRef, @"^slicer\[(\d+)\]$", RegexOptions.IgnoreCase); + if (slicerMatch.Success) + { + var slIdx = int.Parse(slicerMatch.Groups[1].Value); + if (!TryFindSlicerByIndex(worksheet, slIdx, out var slicerElem, out var slicerCache) || slicerElem == null) + throw new ArgumentException($"slicer[{slIdx}] not found on sheet '{sheetNameFromPath}'"); + var slNode = new DocumentNode { Path = path, Type = "slicer" }; + ReadSlicerProperties(slicerElem, slicerCache, slNode); + return slNode; + } + + // OLE object path: /Sheet1/ole[N] + // CONSISTENCY(ole-alias): "oleobject" mirrors Add's case switch + var oleMatch = Regex.Match(cellRef, @"^(?:ole|oleobject|object|embed)\[(\d+)\]$", RegexOptions.IgnoreCase); + if (oleMatch.Success) + { + var oleIdx = int.Parse(oleMatch.Groups[1].Value); + var oleList = CollectOleNodesForSheet(sheetNameFromPath, worksheet); + if (oleIdx < 1 || oleIdx > oleList.Count) + throw new ArgumentException($"OLE object {oleIdx} not found at /{sheetNameFromPath} (available: {oleList.Count})."); + return oleList[oleIdx - 1]; + } + // Comment path: /Sheet1/comment[N] var commentMatch = Regex.Match(cellRef, @"^comment\[(\d+)\]$", RegexOptions.IgnoreCase); if (commentMatch.Success) @@ -692,13 +737,45 @@ public List Query(string selector) if (nativeCellRef.Success) return [Get($"/{nativeCellRef.Groups[1].Value}/{nativeCellRef.Groups[2].Value}")]; + // CONSISTENCY(excel-sheet-separator-warn): Detect the PPT-style `>` + // separator form (e.g. `Sheet1>ole`) that users familiar with the + // PowerPoint query grammar may try against Excel. Excel uses `!` + // (Sheet1!cell[...]) — the legacy spreadsheet separator — so a `>` + // in the sheet-prefix slot will silently fall through to generic + // XML and return an empty result. We emit a single stderr warning + // pointing to the correct `!` form, then let the normal flow run. + // Only fire when the prefix looks like a sheet name (no `/`) and + // the suffix is a known Excel element type we would have handled. + { + var pptStyle = Regex.Match(selector, @"^([^/!>]+)>(\w+)"); + if (pptStyle.Success) + { + var suffixType = pptStyle.Groups[2].Value.ToLowerInvariant(); + if (suffixType is "ole" or "oleobject" or "object" or "embed" or "cell" or "row" + or "chart" or "pivottable" or "pivot" or "slicer" or "shape" + or "picture" or "table" or "listobject" or "comment" or "note" + or "validation" or "namedrange" or "definedname" or "media" + or "image" or "sparkline") + { + Console.Error.WriteLine( + $"Warning: Excel uses '!' not '>' as sheet separator " + + $"(e.g. '{pptStyle.Groups[1].Value}!{suffixType}' not " + + $"'{pptStyle.Groups[1].Value}>{suffixType}')."); + } + } + } + // Check if element type is known (Scheme A) or should fall back to generic XML (Scheme B) // Strip sheet prefix (Sheet1!cell[...]) but not != operator var selectorForType = Regex.Replace(selector, @"^.+?!(?!=)", ""); var elementMatch = Regex.Match(selectorForType, @"^(\w+)"); - var elementName = elementMatch.Success ? elementMatch.Groups[1].Value : ""; + // Lowercase once so all downstream `elementName is "..."` dispatch is + // case-insensitive. CONSISTENCY(query-case-insensitive): matches how + // WordHandler.Query normalizes selector.element to lowercase. + var elementName = elementMatch.Success ? elementMatch.Groups[1].Value.ToLowerInvariant() : ""; bool isKnownType = string.IsNullOrEmpty(elementName) - || elementName is "cell" or "row" or "sheet" or "validation" or "comment" or "note" or "table" or "listobject" or "chart" or "pivottable" or "pivot" or "shape" or "picture" or "sparkline" or "namedrange" or "definedname" or "media" or "image" + // CONSISTENCY(ole-alias): "oleobject" mirrors Add's case switch + || elementName is "cell" or "row" or "sheet" or "validation" or "comment" or "note" or "table" or "listobject" or "chart" or "pivottable" or "pivot" or "slicer" or "shape" or "picture" or "sparkline" or "namedrange" or "definedname" or "media" or "image" or "ole" or "oleobject" or "object" or "embed" || (elementName.Length <= 3 && Regex.IsMatch(elementName, @"^[A-Z]+$", RegexOptions.IgnoreCase)); if (!isKnownType) { @@ -848,7 +925,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) { @@ -862,6 +939,41 @@ public List Query(string selector) return results; } + // Handle slicer queries + if (elementName == "slicer") + { + foreach (var (sheetName, worksheetPart) in GetWorksheets()) + { + if (parsed.Sheet != null && !sheetName.Equals(parsed.Sheet, StringComparison.OrdinalIgnoreCase)) + continue; + + var slicersPart = worksheetPart.GetPartsOfType().FirstOrDefault(); + if (slicersPart?.Slicers == null) continue; + + var slicers = slicersPart.Slicers.Elements().ToList(); + for (int i = 0; i < slicers.Count; i++) + { + if (!TryFindSlicerByIndex(worksheetPart, i + 1, out var slElem, out var slCache) || slElem == null) + continue; + var node = new DocumentNode + { + Path = $"/{sheetName}/slicer[{i + 1}]", + Type = "slicer" + }; + ReadSlicerProperties(slElem, slCache, node); + + if (parsed.ValueContains != null) + { + var nm = node.Format.TryGetValue("name", out var n) ? n?.ToString() : null; + if (nm == null || !nm.Contains(parsed.ValueContains, StringComparison.OrdinalIgnoreCase)) + continue; + } + results.Add(node); + } + } + return results; + } + // Handle sparkline queries if (elementName == "sparkline") { @@ -921,6 +1033,40 @@ public List Query(string selector) return results; } + // Handle OLE object queries. Excel stores OLE objects in two + // parallel structures: + // 1. inside the worksheet (schema-typed OleObject + // elements with progId + shapeId + r:id) + // 2. EmbeddedObjectParts/EmbeddedPackageParts on the WorksheetPart + // (the actual binary payloads, joined via rel id) + // We enumerate (1) as the source of truth for path indexing and + // join (2) for contentType/fileSize enrichment. Worksheets that + // somehow have orphan parts without a matching oleObjects entry + // are still surfaced from the parts side so nothing is missed. + // CONSISTENCY(ole-alias): "oleobject" mirrors Add's case switch + if (elementName is "ole" or "oleobject" or "object" or "embed") + { + foreach (var (sheetName, worksheetPart) in GetWorksheets()) + { + if (parsed.Sheet != null && !sheetName.Equals(parsed.Sheet, StringComparison.OrdinalIgnoreCase)) + continue; + + var oleNodes = CollectOleNodesForSheet(sheetName, worksheetPart); + foreach (var node in oleNodes) + { + if (parsed.ValueContains != null) + { + var pid = node.Format.TryGetValue("progId", out var p) ? p?.ToString() : null; + if (pid == null || !pid.Contains(parsed.ValueContains, StringComparison.OrdinalIgnoreCase)) + continue; + } + if (MatchesFormatAttributes(node, parsed)) + results.Add(node); + } + } + return results; + } + // Handle picture queries if (elementName == "picture") { @@ -985,7 +1131,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 +1183,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 +1200,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..523c08ca7 100644 --- a/src/officecli/Handlers/Excel/ExcelHandler.Remove.cs +++ b/src/officecli/Handlers/Excel/ExcelHandler.Remove.cs @@ -6,7 +6,6 @@ using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Spreadsheet; using C = DocumentFormat.OpenXml.Drawing.Charts; -using X14 = DocumentFormat.OpenXml.Office2010.Excel; using XDR = DocumentFormat.OpenXml.Drawing.Spreadsheet; namespace OfficeCli.Handlers; @@ -68,11 +67,52 @@ 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(); + + // Evict the worksheet part from the row cache and dirty set BEFORE + // DeletePart destroys it. FlushDirtyParts() calls GetSheet() on + // every entry in _dirtyWorksheets; if the part is already destroyed + // that call throws InvalidOperationException. + if (sheetWsPart != null) + { + var removedSheetData = GetSheet(sheetWsPart).GetFirstChild(); + if (removedSheetData != null) InvalidateRowIndex(removedSheetData); + _dirtyWorksheets.Remove(sheetWsPart); + } + 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(); @@ -85,6 +125,14 @@ public partial class ExcelHandler if (!definedNames.HasChildren) definedNames.Remove(); } + // R9-1: invalidate stale cachedValue on formulas in other sheets + // that referenced the removed sheet. Real Excel would recompute + // to #REF! on open; our Get must not report the stale value. + // Minimum viable: clear so cachedValue drops out. We leave + // the formula body alone — rewriting it to #REF! is what Excel + // does on recalc and is hard to get right. + InvalidateFormulaCacheReferencingSheet(workbookPart, sheetName); + // Fix ActiveTab to prevent workbook corruption when deleting the last tab var remainingCount = sheets!.Elements().Count(); var bookViews = workbook.GetFirstChild(); @@ -408,6 +456,82 @@ 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; + } + + // ole[N] — remove embedded OLE object (cleanup embedded payload + + // icon image part). Same part-cleanup discipline as picture/chart + // removal to avoid orphaned binaries bloating the package. + var oleRemoveMatch = Regex.Match(cellRef, @"^(?:ole|object|embed)\[(\d+)\]$", RegexOptions.IgnoreCase); + if (oleRemoveMatch.Success) + { + var oleIdx = int.Parse(oleRemoveMatch.Groups[1].Value); + var ws = GetSheet(worksheet); + var oleElements = ws.Descendants().ToList(); + if (oleIdx < 1 || oleIdx > oleElements.Count) + throw new ArgumentException($"OLE object index {oleIdx} out of range (1..{oleElements.Count})"); + var oleToRemove = oleElements[oleIdx - 1]; + // Delete backing embedded payload + icon image part by rel id. + if (oleToRemove.Id?.Value is string oleRelId && !string.IsNullOrEmpty(oleRelId)) + { + try { worksheet.DeletePart(oleRelId); } catch { } + } + var objectPr = oleToRemove.GetFirstChild(); + if (objectPr?.Id?.Value is string oleIconRelId && !string.IsNullOrEmpty(oleIconRelId)) + { + try { worksheet.DeletePart(oleIconRelId); } catch { } + } + // Remove the OleObject element itself; if its parent OleObjects + // becomes empty, remove that too so the worksheet XML stays clean. + var oleParent = oleToRemove.Parent; + oleToRemove.Remove(); + if (oleParent is OleObjects oleColl && !oleColl.HasChildren) + oleColl.Remove(); + SaveWorksheet(worksheet); + return null; + } + // autofilter — remove AutoFilter from worksheet if (cellRef.Equals("autofilter", StringComparison.OrdinalIgnoreCase)) { @@ -494,6 +618,8 @@ internal void ShiftRowsDown(WorksheetPart worksheet, int insertRow) if (sheetData != null) { + // Row indices change after a shift — cached positions are stale + InvalidateRowIndex(sheetData); // Process in reverse order to avoid collision foreach (var row in sheetData.Elements().OrderByDescending(r => r.RowIndex?.Value ?? 0).ToList()) { @@ -707,6 +833,8 @@ private void ShiftRowsUp(WorksheetPart worksheet, int deletedRow) // 1. Shift all rows after the deleted row: update RowIndex + all CellReferences if (sheetData != null) { + // Row indices change after a shift — cached positions are stale + InvalidateRowIndex(sheetData); foreach (var row in sheetData.Elements().ToList()) { var rowIdx = (int)(row.RowIndex?.Value ?? 0); @@ -1121,4 +1249,94 @@ private static string ShiftColLettersInText(string text, string sheetName, int d }, RegexOptions.IgnoreCase); } + + /// + /// R9-1: after a sheet is removed, walk every remaining worksheet's + /// formula cells and clear the CellValue on any formula that still + /// references the removed sheet by name (bare or single-quote wrapped). + /// We do not rewrite the formula body — that is Excel's job on recalc. + /// Clearing the cached value keeps officecli's Get consistent with the + /// state Real Excel presents when it opens the file. + /// + private void InvalidateFormulaCacheReferencingSheet(WorkbookPart workbookPart, string removedSheetName) + { + // Two literal match forms Excel uses for sheet-qualified refs: + // Sheet2!A1 (bare, no special chars) + // 'My Data'!A1 (quoted when name has spaces/specials) + // Internal single quotes in sheet names are escaped as '' inside + // the quoted form, but creating such names is rare and the + // Contains check below still handles the unescaped prefix. + var bareToken = removedSheetName + "!"; + var quotedToken = "'" + removedSheetName.Replace("'", "''") + "'!"; + + foreach (var wsPart in workbookPart.WorksheetParts) + { + var sheetData = GetSheet(wsPart).GetFirstChild(); + if (sheetData == null) continue; + + bool touched = false; + foreach (var row in sheetData.Elements()) + { + foreach (var cell in row.Elements()) + { + var formula = cell.CellFormula?.Text; + if (string.IsNullOrEmpty(formula)) continue; + if (formula.IndexOf(bareToken, StringComparison.OrdinalIgnoreCase) < 0 && + formula.IndexOf(quotedToken, StringComparison.OrdinalIgnoreCase) < 0) + continue; + + // Clear the cached value. CellValue element removed so + // Get reports null/missing cachedValue, matching Excel's + // initial state on open (before recalc fills in #REF!). + cell.CellValue?.Remove(); + touched = true; + } + } + + if (touched) + { + GetSheet(wsPart).Save(); + } + } + } + + /// + /// 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.Selector.cs b/src/officecli/Handlers/Excel/ExcelHandler.Selector.cs index 01ce1966e..9ebc6611d 100644 --- a/src/officecli/Handlers/Excel/ExcelHandler.Selector.cs +++ b/src/officecli/Handlers/Excel/ExcelHandler.Selector.cs @@ -28,6 +28,22 @@ private CellSelector ParseCellSelector(string selector) string? typeEquals = null; string? typeNotEquals = null; + // Normalize path-style selectors: "/Sheet1/cell[...]" → "Sheet1!cell[...]" + if (selector.StartsWith('/')) + { + var slashIdx = selector.IndexOf('/', 1); + if (slashIdx > 0) + { + sheet = selector[1..slashIdx]; + selector = selector[(slashIdx + 1)..]; + } + else + { + // Just "/cell" — strip leading slash + selector = selector[1..]; + } + } + // Check for sheet prefix: Sheet1!cell[...] // Only treat '!' as sheet separator if NOT part of '!=' operator var exclMatch = Regex.Match(selector, @"^(.+?)!(?!=)"); @@ -202,9 +218,13 @@ private static (string Column, int Row) ParseCellReference(string cellRef) if (!match.Success) throw new ArgumentException($"Invalid cell reference: '{cellRef}'. Expected format like 'A1', 'B2', 'XFD1048576'."); var col = match.Groups[1].Value.ToUpperInvariant(); - var row = int.Parse(match.Groups[2].Value); - if (row < 1 || row > 1048576) - throw new ArgumentException($"Row {row} in cell reference '{cellRef}' is out of range. Valid range: 1-1048576."); + // Use long to avoid OverflowException when malformed files carry row numbers + // outside int range (e.g. uint.MaxValue). Surface a semantic ArgumentException + // (the same exception type used for other invalid refs below) instead. + if (!long.TryParse(match.Groups[2].Value, out var rowLong) || rowLong < 1 || rowLong > 1048576) + throw new ArgumentException( + $"Row {match.Groups[2].Value} in cell reference '{cellRef}' is out of valid range. Valid range: 1-1048576."); + var row = (int)rowLong; var colIdx = ColumnNameToIndex(col); if (colIdx < 1 || colIdx > 16384) throw new ArgumentException($"Column '{col}' in cell reference '{cellRef}' is out of range. Valid range: A-XFD (1-16384)."); diff --git a/src/officecli/Handlers/Excel/ExcelHandler.Set.Workbook.cs b/src/officecli/Handlers/Excel/ExcelHandler.Set.Workbook.cs index 0d6acf18c..43dc8ab54 100644 --- a/src/officecli/Handlers/Excel/ExcelHandler.Set.Workbook.cs +++ b/src/officecli/Handlers/Excel/ExcelHandler.Set.Workbook.cs @@ -147,10 +147,66 @@ private bool TrySetWorkbookSetting(string key, string value) return true; } + // ==================== BookViews / WorkbookView ==================== + case "activetab" or "workbook.activetab": + { + var bv = EnsureFirstWorkbookView(); + // Accept 0-based numeric index or sheet name. + uint idx; + if (uint.TryParse(value, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + { + idx = parsed; + } + else + { + var sheets = _doc.WorkbookPart?.Workbook?.GetFirstChild() + ?.Elements().ToList(); + if (sheets == null || sheets.Count == 0) + throw new ArgumentException($"Invalid activeTab: no sheets in workbook"); + var match = sheets.FindIndex(s => + string.Equals(s.Name?.Value, value, StringComparison.OrdinalIgnoreCase)); + if (match < 0) + throw new ArgumentException( + $"Invalid activeTab: '{value}' is not a 0-based index or sheet name. " + + $"Valid sheets: {string.Join(", ", sheets.Select(s => s.Name?.Value))}"); + idx = (uint)match; + } + bv.ActiveTab = idx == 0 ? null : new UInt32Value(idx); + SaveWorkbook(); + return true; + } + case "firstsheet" or "workbook.firstsheet": + { + var bv = EnsureFirstWorkbookView(); + uint idx; + if (uint.TryParse(value, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + { + idx = parsed; + } + else + { + var sheets = _doc.WorkbookPart?.Workbook?.GetFirstChild() + ?.Elements().ToList(); + if (sheets == null || sheets.Count == 0) + throw new ArgumentException($"Invalid firstSheet: no sheets in workbook"); + var match = sheets.FindIndex(s => + string.Equals(s.Name?.Value, value, StringComparison.OrdinalIgnoreCase)); + if (match < 0) + throw new ArgumentException( + $"Invalid firstSheet: '{value}' is not a 0-based index or sheet name."); + idx = (uint)match; + } + bv.FirstSheet = idx == 0 ? null : new UInt32Value(idx); + SaveWorkbook(); + return true; + } + // ==================== WorkbookProtection ==================== case "workbook.protection" or "workbookprotection": { - var workbook = _doc.WorkbookPart!.Workbook; + var workbook = _doc.WorkbookPart!.Workbook!; var existing = workbook.GetFirstChild(); existing?.Remove(); if (!string.Equals(value, "none", StringComparison.OrdinalIgnoreCase) && IsTruthy(value)) @@ -199,7 +255,7 @@ private bool TrySetWorkbookSetting(string key, string value) private WorkbookProperties EnsureWorkbookProperties() { - var workbook = _doc.WorkbookPart!.Workbook; + var workbook = _doc.WorkbookPart!.Workbook!; var props = workbook.GetFirstChild(); if (props == null) { @@ -217,7 +273,7 @@ private WorkbookProperties EnsureWorkbookProperties() private CalculationProperties EnsureCalculationProperties() { - var workbook = _doc.WorkbookPart!.Workbook; + var workbook = _doc.WorkbookPart!.Workbook!; var calc = workbook.GetFirstChild(); if (calc == null) { @@ -229,7 +285,7 @@ private CalculationProperties EnsureCalculationProperties() private WorkbookProtection EnsureWorkbookProtection() { - var workbook = _doc.WorkbookPart!.Workbook; + var workbook = _doc.WorkbookPart!.Workbook!; var prot = workbook.GetFirstChild(); if (prot == null) { @@ -247,6 +303,31 @@ private WorkbookProtection EnsureWorkbookProtection() return prot; } + private WorkbookView EnsureFirstWorkbookView() + { + var workbook = _doc.WorkbookPart!.Workbook!; + var bookViews = workbook.GetFirstChild(); + if (bookViews == null) + { + bookViews = new BookViews(); + // Schema order: bookViews sits between workbookProtection/workbookPr + // and sheets. Insert before Sheets when present. + var anchor = (DocumentFormat.OpenXml.OpenXmlElement?)workbook.GetFirstChild() + ?? workbook.GetFirstChild(); + if (anchor != null) + anchor.InsertBeforeSelf(bookViews); + else + workbook.AppendChild(bookViews); + } + var view = bookViews.GetFirstChild(); + if (view == null) + { + view = new WorkbookView(); + bookViews.AppendChild(view); + } + return view; + } + private void CleanupEmptyWorkbookProperties() { var props = _doc.WorkbookPart?.Workbook?.GetFirstChild(); @@ -299,6 +380,17 @@ private void PopulateWorkbookSettings(DocumentNode node) if (calc.ReferenceMode?.Value != null) node.Format["calc.refMode"] = calc.ReferenceMode.InnerText; } + // BookViews / first WorkbookView + var bookViews = workbook.GetFirstChild(); + var firstView = bookViews?.GetFirstChild(); + if (firstView != null) + { + if (firstView.ActiveTab?.Value is uint activeTab && activeTab != 0) + node.Format["activeTab"] = (int)activeTab; + if (firstView.FirstSheet?.Value is uint firstSheet && firstSheet != 0) + node.Format["firstSheet"] = (int)firstSheet; + } + // WorkbookProtection var prot = workbook.GetFirstChild(); if (prot != null) diff --git a/src/officecli/Handlers/Excel/ExcelHandler.Set.cs b/src/officecli/Handlers/Excel/ExcelHandler.Set.cs index c7b982ad4..6fdd2d940 100644 --- a/src/officecli/Handlers/Excel/ExcelHandler.Set.cs +++ b/src/officecli/Handlers/Excel/ExcelHandler.Set.cs @@ -9,6 +9,7 @@ using Drawing = DocumentFormat.OpenXml.Drawing; using X14 = DocumentFormat.OpenXml.Office2010.Excel; using XDR = DocumentFormat.OpenXml.Drawing.Spreadsheet; +using ThreadedCmt = DocumentFormat.OpenXml.Office2019.Excel.ThreadedComments; namespace OfficeCli.Handlers; @@ -41,6 +42,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 +55,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"); @@ -308,6 +316,189 @@ public List Set(string path, Dictionary properties) return dvUnsupported; } + // Handle /SheetName/ole[N] + // Replace backing embedded part + refresh ProgID. Cleans up the + // old payload part (CLAUDE.md Known API Quirks rule: always delete + // the old part on src replacement). + var oleSetMatch = Regex.Match(cellRef, @"^(?:ole|object|embed)\[(\d+)\]$", RegexOptions.IgnoreCase); + if (oleSetMatch.Success) + { + var oleIdxSet = int.Parse(oleSetMatch.Groups[1].Value); + var oleWs = GetSheet(worksheet); + var oleElements = oleWs.Descendants().ToList(); + if (oleIdxSet < 1 || oleIdxSet > oleElements.Count) + throw new ArgumentException($"OLE object index {oleIdxSet} out of range (1..{oleElements.Count})"); + var oleObjSet = oleElements[oleIdxSet - 1]; + var oleUnsupportedSet = new List(); + foreach (var (key, value) in properties) + { + switch (key.ToLowerInvariant()) + { + case "path" or "src": + { + if (oleObjSet.Id?.Value is string oldRel && !string.IsNullOrEmpty(oldRel)) + { + try { worksheet.DeletePart(oldRel); } catch { } + } + var (newRel, _) = OfficeCli.Core.OleHelper.AddEmbeddedPart(worksheet, value, _filePath); + oleObjSet.Id = newRel; + if (!properties.ContainsKey("progId") && !properties.ContainsKey("progid")) + { + var autoProgId = OfficeCli.Core.OleHelper.DetectProgId(value); + OfficeCli.Core.OleHelper.ValidateProgId(autoProgId); + oleObjSet.ProgId = autoProgId; + } + break; + } + case "progid": + OfficeCli.Core.OleHelper.ValidateProgId(value); + oleObjSet.ProgId = value; + break; + case "display": + // CONSISTENCY(excel-ole-display): Excel Add rejects + // any 'display' key with ArgumentException (exit 1); + // Set must do the same instead of falling into the + // default "unsupported" branch (exit 2). Excel has + // no DrawAspect concept — worksheet objects are + // always shown as icons via objectPr/anchor, so + // 'display' would be a no-op. Re-use the exact + // string Add throws so both error paths are + // indistinguishable from the caller's perspective. + throw new ArgumentException( + "'display' property is not supported for Excel OLE " + + "(Excel always shows objects as icon). Remove --prop display."); + case "width": + case "height": + { + // CONSISTENCY(ole-width-units): accept either a bare + // integer cell-span count or a unit-qualified size + // ("10cm", "3in", "72pt"). Symmetric with Add-side + // ParseAnchorDimensionEmu, so Get→Set round-trip of + // the unit-qualified string Get emits works. + long emuTotal; + try + { + emuTotal = ParseAnchorDimensionEmu(value, key.ToLowerInvariant()); + } + catch + { + oleUnsupportedSet.Add(key); + break; + } + if (emuTotal < 0) + { + oleUnsupportedSet.Add(key); + break; + } + var objectPrSet = oleObjSet.GetFirstChild(); + var objAnchorSet = objectPrSet?.GetFirstChild(); + var fromMSet = objAnchorSet?.GetFirstChild(); + var toMSet = objAnchorSet?.GetFirstChild(); + if (fromMSet == null || toMSet == null) + { + oleUnsupportedSet.Add(key); + break; + } + if (key.Equals("width", StringComparison.OrdinalIgnoreCase)) + { + int.TryParse(fromMSet.GetFirstChild()?.Text ?? "0", out var fromCol); + long.TryParse(fromMSet.GetFirstChild()?.Text ?? "0", out var fromColOff); + // Rebuild ToMarker col + offset from the parsed EMU + // extent so Get→Set preserves sub-cell precision. + long wholeCols = emuTotal / EmuPerColApprox; + long remCols = emuTotal % EmuPerColApprox; + var toColChild = toMSet.GetFirstChild(); + if (toColChild != null) toColChild.Text = (fromCol + (int)wholeCols).ToString(); + var toColOffChild = toMSet.GetFirstChild(); + if (toColOffChild != null) toColOffChild.Text = (fromColOff + remCols).ToString(); + else toMSet.InsertAfter(new XDR.ColumnOffset((fromColOff + remCols).ToString()), toColChild); + } + else + { + int.TryParse(fromMSet.GetFirstChild()?.Text ?? "0", out var fromRow); + long.TryParse(fromMSet.GetFirstChild()?.Text ?? "0", out var fromRowOff); + long wholeRows = emuTotal / EmuPerRowApprox; + long remRows = emuTotal % EmuPerRowApprox; + var toRowChild = toMSet.GetFirstChild(); + if (toRowChild != null) toRowChild.Text = (fromRow + (int)wholeRows).ToString(); + var toRowOffChild = toMSet.GetFirstChild(); + if (toRowOffChild != null) toRowOffChild.Text = (fromRowOff + remRows).ToString(); + else toMSet.InsertAfter(new XDR.RowOffset((fromRowOff + remRows).ToString()), toRowChild); + } + break; + } + case "anchor": + { + // CONSISTENCY(ole-width-units): mirror Add-side warn + // (ExcelHandler.Add.cs ~L977). anchor= defines the + // full rectangle, so width/height on the same Set + // call would be ambiguous and are silently dropped. + // Warn loudly rather than fail so existing scripts + // keep working but users notice the dropped value. + if (properties.ContainsKey("width") || properties.ContainsKey("height")) + Console.Error.WriteLine( + "Warning: 'width'/'height' are ignored when 'anchor' is provided (anchor defines the full rectangle)."); + // CONSISTENCY(ole-anchor-roundtrip): mirror Add-side + // regex so `get ... format.anchor` → `set anchor=...` + // round-trips. Whole-cell rectangle; sub-cell + // ColumnOffset/RowOffset are zeroed on both markers + // (same as Add when anchor= is provided). + var anchorM = Regex.Match(value ?? "", @"^([A-Z]+)(\d+)(?::([A-Z]+)(\d+))?$", RegexOptions.IgnoreCase); + if (!anchorM.Success) + { + oleUnsupportedSet.Add(key); + break; + } + var objectPrAnc = oleObjSet.GetFirstChild(); + var objAnchorAnc = objectPrAnc?.GetFirstChild(); + var fromMAnc = objAnchorAnc?.GetFirstChild(); + var toMAnc = objAnchorAnc?.GetFirstChild(); + if (fromMAnc == null || toMAnc == null) + { + oleUnsupportedSet.Add(key); + break; + } + // XDR ColumnId/RowId are 0-based; A1-style is 1-based. + int newFromCol = ColumnNameToIndex(anchorM.Groups[1].Value) - 1; + int newFromRow = int.Parse(anchorM.Groups[2].Value) - 1; + int newToCol, newToRow; + if (anchorM.Groups[3].Success) + { + newToCol = ColumnNameToIndex(anchorM.Groups[3].Value) - 1; + newToRow = int.Parse(anchorM.Groups[4].Value) - 1; + } + else + { + newToCol = newFromCol + 2; + newToRow = newFromRow + 3; + } + var fromColChild = fromMAnc.GetFirstChild(); + if (fromColChild != null) fromColChild.Text = newFromCol.ToString(); + var fromRowChild = fromMAnc.GetFirstChild(); + if (fromRowChild != null) fromRowChild.Text = newFromRow.ToString(); + var fromColOffChild = fromMAnc.GetFirstChild(); + if (fromColOffChild != null) fromColOffChild.Text = "0"; + var fromRowOffChild = fromMAnc.GetFirstChild(); + if (fromRowOffChild != null) fromRowOffChild.Text = "0"; + var toColChildAnc = toMAnc.GetFirstChild(); + if (toColChildAnc != null) toColChildAnc.Text = newToCol.ToString(); + var toRowChildAnc = toMAnc.GetFirstChild(); + if (toRowChildAnc != null) toRowChildAnc.Text = newToRow.ToString(); + var toColOffChildAnc = toMAnc.GetFirstChild(); + if (toColOffChildAnc != null) toColOffChildAnc.Text = "0"; + var toRowOffChildAnc = toMAnc.GetFirstChild(); + if (toRowOffChildAnc != null) toRowOffChildAnc.Text = "0"; + break; + } + default: + oleUnsupportedSet.Add(key); + break; + } + } + SaveWorksheet(worksheet); + return oleUnsupportedSet; + } + // Handle /SheetName/picture[N] var picSetMatch = Regex.Match(cellRef, @"^picture\[(\d+)\]$", RegexOptions.IgnoreCase); if (picSetMatch.Success) @@ -375,6 +566,8 @@ public List Set(string path, Dictionary properties) var lk = key.ToLowerInvariant(); if (TrySetAnchorPosition(anchor, lk, value)) continue; if (TrySetRotation(shape.ShapeProperties, lk, value)) continue; + if (TrySetShapeFlip(shape.ShapeProperties, lk, value)) continue; + if (TrySetShapeFontProp(shape, lk, value)) continue; // For effects on shapes: check if fill=none → text-level, otherwise shape-level if (lk is "shadow" or "glow" or "reflection" or "softedge") @@ -505,6 +698,81 @@ public List Set(string path, Dictionary properties) }; } break; + case "valign": + { + var txBody = shape.TextBody; + var bodyPr = txBody?.GetFirstChild(); + if (bodyPr != null) + { + bodyPr.Anchor = value.ToLowerInvariant() switch + { + "top" or "t" => Drawing.TextAnchoringTypeValues.Top, + "center" or "ctr" or "middle" or "m" or "c" => Drawing.TextAnchoringTypeValues.Center, + "bottom" or "b" => Drawing.TextAnchoringTypeValues.Bottom, + _ => throw new ArgumentException($"Invalid valign value: '{value}'. Valid values: top, center, bottom.") + }; + } + break; + } + case "gradientfill": + { + var spPr = shape.ShapeProperties; + if (spPr != null) + { + spPr.RemoveAllChildren(); + spPr.RemoveAllChildren(); + spPr.RemoveAllChildren(); + // CONSISTENCY(shape-gradient-fill): reuse the Add-branch parser so + // shape Set accepts the same "C1-C2[-C3][:angle]" spec. + spPr.AppendChild(BuildShapeGradientFill(value)); + } + break; + } + case "line" or "border": + { + // CONSISTENCY(shape-line): mirror Add — accept "none" or "color[:width[:style]]". + var spPr = shape.ShapeProperties; + if (spPr == null) break; + spPr.RemoveAllChildren(); + if (value.Equals("none", StringComparison.OrdinalIgnoreCase)) + { + spPr.AppendChild(new Drawing.Outline(new Drawing.NoFill())); + break; + } + var parts = value.Split(':'); + var (lRgb, _) = ParseHelpers.SanitizeColorForOoxml(parts[0]); + var outline = new Drawing.Outline( + new Drawing.SolidFill(new Drawing.RgbColorModelHex { Val = lRgb })); + if (parts.Length > 1 + && double.TryParse(parts[1], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var wpt)) + { + outline.Width = (int)Math.Round(wpt * 12700); + } + if (parts.Length > 2) + { + var dash = parts[2].ToLowerInvariant() switch + { + "dash" => Drawing.PresetLineDashValues.Dash, + "dot" => Drawing.PresetLineDashValues.Dot, + "dashdot" => Drawing.PresetLineDashValues.DashDot, + "longdash" => Drawing.PresetLineDashValues.LargeDash, + "solid" => Drawing.PresetLineDashValues.Solid, + _ => (Drawing.PresetLineDashValues?)null + }; + if (dash != null) + outline.AppendChild(new Drawing.PresetDash { Val = dash }); + } + spPr.AppendChild(outline); + break; + } + case "alt" or "alttext" or "descr" or "description": + { + var altNv = shape.NonVisualShapeProperties? + .GetFirstChild(); + if (altNv != null) altNv.Description = value; + break; + } default: shpUnsupported.Add(key); break; @@ -515,6 +783,42 @@ public List Set(string path, Dictionary properties) return shpUnsupported; } + // Handle /SheetName/slicer[N] — caption/style/columnCount/rowHeight/name + var slicerSetMatch = Regex.Match(cellRef, @"^slicer\[(\d+)\]$", RegexOptions.IgnoreCase); + if (slicerSetMatch.Success) + { + var slIdx = int.Parse(slicerSetMatch.Groups[1].Value); + if (!TryFindSlicerByIndex(worksheet, slIdx, out var slicer, out _) || slicer == null) + throw new ArgumentException($"slicer[{slIdx}] not found on sheet"); + + var slicersPart = worksheet.GetPartsOfType().FirstOrDefault(); + var slUnsupported = new List(); + foreach (var (key, value) in properties) + { + switch (key.ToLowerInvariant()) + { + case "caption": slicer.Caption = value; break; + case "style": slicer.Style = value; break; + case "name": slicer.Name = value; break; + case "rowheight": + if (uint.TryParse(value, out var rh)) slicer.RowHeight = rh; + else slUnsupported.Add(key); + break; + case "columncount": + if (uint.TryParse(value, out var cc) && cc >= 1 && cc <= 20000) + slicer.ColumnCount = cc; + else slUnsupported.Add(key); + break; + default: + slUnsupported.Add(key); + break; + } + } + if (slicersPart?.Slicers != null) slicersPart.Slicers.Save(slicersPart); + SaveWorksheet(worksheet); + return slUnsupported; + } + // Handle /SheetName/table[N] var tableSetMatch = Regex.Match(cellRef, @"^table\[(\d+)\]$", RegexOptions.IgnoreCase); if (tableSetMatch.Success) @@ -536,6 +840,7 @@ public List Set(string path, Dictionary properties) case "displayname": table.DisplayName = value; break; case "headerrow": table.HeaderRowCount = IsTruthy(value) ? 1u : 0u; break; case "totalrow": + case "showtotals": var totalRowEnabled = IsTruthy(value); table.TotalsRowShown = totalRowEnabled; table.TotalsRowCount = totalRowEnabled ? 1u : 0u; @@ -550,10 +855,52 @@ public List Set(string path, Dictionary properties) }); break; case "ref": - table.Reference = value.ToUpperInvariant(); + { + var newRef = value.ToUpperInvariant(); + // T5 — grow/shrink to match the new column + // count. Excel rejects the file when tableColumns.Count + // mismatches the ref width. On grow, append default + // ColumnN entries; on shrink, trim the trailing entries. + var newParts = newRef.Split(':'); + if (newParts.Length == 2) + { + var (nsc, _) = ParseCellReference(newParts[0]); + var (nec, _) = ParseCellReference(newParts[1]); + int newColCount = ColumnNameToIndex(nec) - ColumnNameToIndex(nsc) + 1; + var tc = table.GetFirstChild(); + if (tc != null && newColCount > 0) + { + var cols = tc.Elements().ToList(); + if (newColCount > cols.Count) + { + var existingIds = cols.Select(c => c.Id?.Value ?? 0u).ToList(); + var existingNames = new HashSet( + cols.Select(c => c.Name?.Value ?? string.Empty), + StringComparer.OrdinalIgnoreCase); + uint nextId = existingIds.Count > 0 ? existingIds.Max() + 1 : 1u; + for (int i = cols.Count; i < newColCount; i++) + { + var baseName = $"Column{i + 1}"; + var name = baseName; + int dedup = 2; + while (!existingNames.Add(name)) + name = $"{baseName}{dedup++}"; + tc.AppendChild(new TableColumn { Id = nextId++, Name = name }); + } + } + else if (newColCount < cols.Count) + { + for (int i = cols.Count - 1; i >= newColCount; i--) + cols[i].Remove(); + } + tc.Count = (uint)newColCount; + } + } + table.Reference = newRef; var af = table.GetFirstChild(); - if (af != null) af.Reference = value.ToUpperInvariant(); + if (af != null) af.Reference = newRef; break; + } case "showrowstripes" or "bandedrows" or "bandrows": { var si = table.GetFirstChild(); @@ -640,18 +987,36 @@ public List Set(string path, Dictionary properties) ?? throw new ArgumentException($"Comment [{cmtIndex}] not found"); var cmtUnsupported = new List(); + // CONSISTENCY(xlsx/comment-font): C8 — font.* props on Set rewrite + // the single , reusing BuildCommentRunProperties. When + // `text` and `font.*` appear together, text wins the run payload + // and font.* supplies the rPr. When only font.* appears (no text), + // preserve the existing run text and just rebuild rPr. + string? newCmtText = properties.TryGetValue("text", out var tVal) ? tVal : null; + bool hasFontProp = properties.Keys.Any(k => + k.StartsWith("font.", StringComparison.OrdinalIgnoreCase)); + if (newCmtText != null || hasFontProp) + { + string runText = newCmtText + ?? string.Concat(cmtElement.CommentText?.Elements() + .SelectMany(r => r.Elements()).Select(t => t.Text) + ?? Array.Empty()); + cmtElement.CommentText = new CommentText( + new Run( + BuildCommentRunProperties(properties), + new Text(runText) { Space = SpaceProcessingModeValues.Preserve } + ) + ); + } foreach (var (key, value) in properties) { switch (key.ToLowerInvariant()) { case "text": - cmtElement.CommentText = new CommentText( - new Run( - new RunProperties(new FontSize { Val = 9 }, new Color { Indexed = 81 }, - new RunFont { Val = "Tahoma" }), - new Text(value) { Space = SpaceProcessingModeValues.Preserve } - ) - ); + // Already applied above. + break; + case var k when k.StartsWith("font."): + // Already applied above. break; case "ref": // Update cell reference (like POI's XSSFComment.setAddress) @@ -746,7 +1111,7 @@ public List Set(string path, Dictionary properties) break; } } - ReorderWorksheetChildren(ws); ws.Save(); + SaveWorksheet(worksheet); return unsup; } @@ -782,7 +1147,8 @@ public List Set(string path, Dictionary properties) // If series sub-path, prefix all properties with series{N}. for ChartSetter var chartProps = properties; - if (chartMatch.Groups[2].Success) + var isSeriesPath = chartMatch.Groups[2].Success; + if (isSeriesPath) { var seriesIdx = int.Parse(chartMatch.Groups[2].Value); chartProps = new Dictionary(); @@ -790,15 +1156,47 @@ public List Set(string path, Dictionary properties) chartProps[$"series{seriesIdx}.{key}"] = value; } + // Chart-level position/size Set — TwoCellAnchor mutation. Skip + // for series sub-paths (series don't have their own position). + // Accepts `x`/`y`/`width`/`height` in the same units as the OLE + // object Set path and as chart Add. + // + // CONSISTENCY(chart-position-set): mirrors the PPTX path + // (PowerPointHandler.Set.cs chart case) — same prop keys, same + // value grammar — so users learn one vocabulary for all three + // document types. Excel differs only in that we mutate a + // TwoCellAnchor (row/col markers) instead of a GraphicFrame + // Transform, because xlsx charts are cell-anchored by design. + if (!isSeriesPath) + { + var positionUnsupported = ApplyChartPositionSet( + drawingsPart, chartIdx, chartProps); + // Drop handled keys from chartProps so the downstream setter + // doesn't flag them as unsupported. + foreach (var k in new[] { "x", "y", "width", "height" }) + { + var matched = chartProps.Keys + .FirstOrDefault(key => key.Equals(k, StringComparison.OrdinalIgnoreCase)); + if (matched != null && !positionUnsupported.Contains(matched)) + chartProps.Remove(matched); + } + } + if (chartInfo.StandardPart != null) { var unsup = ChartHelper.SetChartProperties(chartInfo.StandardPart, chartProps); chartInfo.StandardPart.ChartSpace?.Save(); return unsup; } + else if (chartInfo.ExtendedPart != null) + { + // cx:chart — delegates to ChartExBuilder.SetChartProperties, + // which covers title/axis/gridline styling, series fill, + // histogram binning, etc. Returns unsupported keys (if any). + return ChartExBuilder.SetChartProperties(chartInfo.ExtendedPart, chartProps); + } else { - // cx:chart — all chart-internal properties are unsupported return chartProps.Keys.ToList(); } } @@ -963,12 +1361,42 @@ public List Set(string path, Dictionary properties) private List SetCellProperties(Cell cell, string cellRef, WorksheetPart worksheet, Dictionary properties) { var unsupported = ApplyCellProperties(cell, cellRef, worksheet, properties); + // Remove completely empty cells (no value, no formula, no custom style) so that + // rows with no remaining cells are pruned from XML. This keeps maxRow correct + // and produces "remove" watch patches instead of "replace" for cleared rows. + PruneEmptyCell(cell); + // CONSISTENCY(xlsx/table-autoexpand): eager post-write auto-grow — + // only fires when the cell still carries a value/formula after prune. + if (cell.Parent != null && (cell.CellValue != null || cell.CellFormula != null || cell.InlineString != null)) + MaybeExpandTablesForCell(worksheet, cellRef); // Any mutation to a cell (value, formula, clear) can invalidate the calc chain DeleteCalcChainIfPresent(); SaveWorksheet(worksheet); return unsupported; } + private void PruneEmptyCell(Cell cell) + { + var hasValue = cell.CellValue != null && !string.IsNullOrEmpty(cell.CellValue.Text); + var hasFormula = cell.CellFormula != null; + var hasStyle = cell.StyleIndex != null && cell.StyleIndex.Value != 0; + if (!hasValue && !hasFormula && !hasStyle) + { + var row = cell.Parent as Row; + cell.Remove(); + if (row != null && !row.Elements().Any()) + { + // Capture sheetData and rowIdx before detaching — row.Parent is null after Remove() + var sheetData = row.Parent as SheetData; + var rowIdx = row.RowIndex?.Value; + row.Remove(); + // Keep row index cache in sync: detached row must not be returned by FindOrCreateRow + if (sheetData != null && rowIdx.HasValue) + _rowIndex?.GetValueOrDefault(sheetData)?.Remove(rowIdx.Value); + } + } + } + /// Apply cell properties without saving — caller is responsible for SaveWorksheet. private List ApplyCellProperties(Cell cell, WorksheetPart worksheet, Dictionary properties) => ApplyCellProperties(cell, cell.CellReference?.Value ?? "", worksheet, properties); @@ -991,6 +1419,19 @@ private List ApplyCellProperties(Cell cell, string cellRef, WorksheetPar switch (key.ToLowerInvariant()) { case "value" or "text": + // R13-1: enforce Excel's 32767-char per-cell limit. + EnsureCellValueLength(value, cell.CellReference?.Value); + // R13-3: warn if both value= and formula= supplied — formula + // takes precedence below (explicit-formula case runs last and + // clears CellValue), so the literal value is silently discarded. + if (properties.Any(p => p.Key.Equals("formula", StringComparison.OrdinalIgnoreCase))) + { + Console.Error.WriteLine( + "Warning: Both value= and formula= supplied — using formula, value ignored."); + } + // Auto-detect formula: value starting with '=' is treated as formula + if (value.StartsWith('=') && value.Length > 1) + goto case "formula"; var cellValue = value.Replace("\\n", "\n"); // Support escaped newlines cell.CellFormula = null; // Clear formula when explicit value is set // If cell is already boolean type, convert true/false to 1/0 @@ -1015,10 +1456,8 @@ private List ApplyCellProperties(Cell cell, string cellRef, WorksheetPar .Any(v => v is "number" or "num"); // Auto-detect ISO date (only if user did NOT explicitly set type=string) - if (!explicitTypeIsString && DateTime.TryParseExact(cellValue, - new[] { "yyyy-MM-dd", "yyyy/MM/dd", "yyyy-MM-dd HH:mm:ss" }, - System.Globalization.CultureInfo.InvariantCulture, - System.Globalization.DateTimeStyles.None, out var dt)) + // R13-2: accept date-with-time variants (T and space separators). + if (!explicitTypeIsString && TryParseIsoDateFlexible(cellValue, out var dt)) { cell.CellValue = new CellValue(dt.ToOADate().ToString(System.Globalization.CultureInfo.InvariantCulture)); cell.DataType = null; @@ -1033,6 +1472,25 @@ private List ApplyCellProperties(Cell cell, string cellRef, WorksheetPar cell.CellValue = new CellValue(cellValue); cell.DataType = new EnumValue(CellValues.String); } + else if (explicitTypeIsString) + { + // R15-2: honor explicit type=string even for + // numeric-looking literals. Without this, Excel + // renders 123 as a number despite user intent. + cell.CellValue = new CellValue(cellValue); + cell.DataType = new EnumValue(CellValues.String); + } + else if (explicitTypeIsNumber) + { + // R15-2: honor explicit type=number — refuse + // non-numeric values rather than silently storing + // as string. + if (!double.TryParse(cellValue, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out _)) + throw new ArgumentException( + $"Cannot store '{cellValue}' as number; use type=string or remove type="); + cell.CellValue = new CellValue(cellValue); + cell.DataType = null; + } else { cell.CellValue = new CellValue(cellValue); @@ -1044,7 +1502,7 @@ private List ApplyCellProperties(Cell cell, string cellRef, WorksheetPar } break; case "formula": - cell.CellFormula = new CellFormula(value.TrimStart('=')); + cell.CellFormula = new CellFormula(Core.ModernFunctionQualifier.Qualify(value.TrimStart('='))); // Try to evaluate and cache the result immediately var evalSheetData = GetSheet(worksheet).GetFirstChild(); var evaluator = new Core.FormulaEvaluator(evalSheetData!, _doc.WorkbookPart); @@ -1101,7 +1559,9 @@ private List ApplyCellProperties(Cell cell, string cellRef, WorksheetPar "number" or "num" => null, "boolean" or "bool" => new EnumValue(CellValues.Boolean), "date" => null, // Dates are stored as numbers; format is applied via numberformat below - _ => throw new ArgumentException($"Invalid cell 'type' value '{value}'. Valid types: string, number, boolean, date.") + // CONSISTENCY(cell-type-parity): accept `error`/`err` as in Add. + "error" or "err" => new EnumValue(CellValues.Error), + _ => throw new ArgumentException($"Invalid cell 'type' value '{value}'. Valid types: string, number, boolean, date, error.") }; // Convert cell value for boolean type if (value.ToLowerInvariant() is "boolean" or "bool" && cell.CellValue != null) @@ -1124,7 +1584,7 @@ private List ApplyCellProperties(Cell cell, string cellRef, WorksheetPar case "arrayformula": { var arrRef = properties.GetValueOrDefault("ref", cellRef); - cell.CellFormula = new CellFormula(value.TrimStart('=')) + cell.CellFormula = new CellFormula(Core.ModernFunctionQualifier.Qualify(value.TrimStart('='))) { FormulaType = CellFormulaValues.Array, Reference = arrRef @@ -1146,8 +1606,6 @@ private List ApplyCellProperties(Cell cell, string cellRef, WorksheetPar } else { - var hlUri = new Uri(value, UriKind.RelativeOrAbsolute); - var hlRel = worksheet.AddHyperlinkRelationship(hlUri, isExternal: true); if (hyperlinksEl == null) { hyperlinksEl = new Hyperlinks(); @@ -1156,15 +1614,78 @@ private List ApplyCellProperties(Cell cell, string cellRef, WorksheetPar hyperlinksEl.Elements() .Where(h => h.Reference?.Value?.Equals(cellRef, StringComparison.OrdinalIgnoreCase) == true) .ToList().ForEach(h => h.Remove()); - hyperlinksEl.AppendChild(new Hyperlink { Reference = cellRef.ToUpperInvariant(), Id = hlRel.Id }); + // H2: optional tooltip/screenTip from sibling props. + var setHlTip = properties.GetValueOrDefault("tooltip") + ?? properties.GetValueOrDefault("screenTip") + ?? properties.GetValueOrDefault("screentip"); + if (value.StartsWith("#")) + { + // Internal target (sheet cell or named range) is + // written as an in-document hyperlink via the + // `location` attribute, no relationship/target. + var location = value.Substring(1); + var hl = new Hyperlink + { + Reference = cellRef.ToUpperInvariant(), + Location = location + }; + if (!string.IsNullOrEmpty(setHlTip)) hl.Tooltip = setHlTip; + hyperlinksEl.AppendChild(hl); + } + else + { + var hlUri = new Uri(value, UriKind.RelativeOrAbsolute); + var hlRel = worksheet.AddHyperlinkRelationship(hlUri, isExternal: true); + var hl = new Hyperlink { Reference = cellRef.ToUpperInvariant(), Id = hlRel.Id }; + if (!string.IsNullOrEmpty(setHlTip)) hl.Tooltip = setHlTip; + hyperlinksEl.AppendChild(hl); + } + // H3: apply the built-in "Hyperlink" cellStyle (blue + + // underline) if the cell has no user-assigned style. + // CONSISTENCY(hyperlink-cellstyle): preserve an + // explicit StyleIndex the user already set. + if (cell.StyleIndex == null || cell.StyleIndex.Value == 0) + { + var wbPart = _doc.WorkbookPart + ?? throw new InvalidOperationException("Workbook not found"); + var styleManager = new ExcelStyleManager(wbPart); + cell.StyleIndex = styleManager.EnsureHyperlinkCellStyle(); + _dirtyStylesheet = true; + } + } + break; + } + case "tooltip": + case "screentip": + { + // H2: tooltip may also be applied to an EXISTING hyperlink. + var ws = GetSheet(worksheet); + var hyperlinksEl = ws.GetFirstChild(); + var existing = hyperlinksEl?.Elements() + .FirstOrDefault(h => h.Reference?.Value?.Equals(cellRef, StringComparison.OrdinalIgnoreCase) == true); + if (existing == null) + { + unsupported.Add($"tooltip (no hyperlink exists on {cellRef}; add a link first)"); + break; } + existing.Tooltip = string.IsNullOrEmpty(value) ? null : value; 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; } } @@ -1176,6 +1697,7 @@ private List ApplyCellProperties(Cell cell, string cellRef, WorksheetPar ?? throw new InvalidOperationException("Workbook not found"); var styleManager = new ExcelStyleManager(workbookPart); cell.StyleIndex = styleManager.ApplyStyle(cell, styleProps); + _dirtyStylesheet = true; } return unsupported; @@ -1189,6 +1711,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 +1772,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; @@ -1287,6 +1827,12 @@ static bool NeedsQuoting(string n) => var existingPane = sheetView.GetFirstChild(); existingPane?.Remove(); + // R18-B3: freeze=A1 means "no freeze". Emitting a with + // no xSplit/ySplit produces invalid OOXML (Excel repairs on + // open). Treat A1 as a no-op after clearing the existing pane. + if (colSplit <= 0 && rowSplit <= 0) + break; + var activePane = (colSplit > 0 && rowSplit > 0) ? PaneValues.BottomRight : (rowSplit > 0) ? PaneValues.BottomLeft : PaneValues.TopRight; @@ -1306,18 +1852,25 @@ static bool NeedsQuoting(string n) => } case "merge": { - // Sheet-level merge: value is the range to merge (e.g., "A1:A3") - var rangeRef = value.ToUpperInvariant(); + // Sheet-level merge: value is the range(s) to merge (e.g., "A1:A3" or + // "A1:D1,B3:B5" for multiple ranges). + // R2-1: Split comma-separated ranges into separate elements; + // Excel rejects a single . var mergeCells = ws.GetFirstChild(); if (mergeCells == null) { mergeCells = new MergeCells(); ws.AppendChild(mergeCells); } - var existing = mergeCells.Elements() - .FirstOrDefault(m => m.Reference?.Value?.Equals(rangeRef, StringComparison.OrdinalIgnoreCase) == true); - if (existing == null) - mergeCells.AppendChild(new MergeCell { Reference = rangeRef }); + foreach (var part in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var rangeRef = part.ToUpperInvariant(); + var existing = mergeCells.Elements() + .FirstOrDefault(m => m.Reference?.Value?.Equals(rangeRef, StringComparison.OrdinalIgnoreCase) == true); + if (existing == null) + mergeCells.AppendChild(new MergeCell { Reference = rangeRef }); + } + mergeCells.Count = (uint)mergeCells.Elements().Count(); break; } case "autofilter": @@ -1417,8 +1970,20 @@ static bool NeedsQuoting(string n) => sheetPr.RemoveAllChildren(); if (!value.Equals("none", StringComparison.OrdinalIgnoreCase)) { - var colorHex = OfficeCli.Core.ParseHelpers.NormalizeArgbColor(value); - sheetPr.AppendChild(new TabColor { Rgb = new HexBinaryValue(colorHex) }); + // CONSISTENCY(scheme-color): accept scheme-color names + // ("accent1"-"accent6", "lt1", "dk1", ...) by mapping + // them to TabColor.Theme index. Otherwise fall back to + // the numeric color parser for hex/named/rgb() inputs. + var themeIndex = ExcelSchemeColorNameToThemeIndex(value); + if (themeIndex.HasValue) + { + sheetPr.AppendChild(new TabColor { Theme = (UInt32Value)themeIndex.Value }); + } + else + { + var colorHex = OfficeCli.Core.ParseHelpers.NormalizeArgbColor(value); + sheetPr.AppendChild(new TabColor { Rgb = new HexBinaryValue(colorHex) }); + } } break; } @@ -1580,140 +2145,64 @@ static bool NeedsQuoting(string n) => } // ==================== Sorting ==================== + // CONSISTENCY(range-action): sort is a region action like merge. + // Sheet-level path auto-detects the full used range; explicit ranges + // go through SetRange → SortRangeRows. Keep both entry points in + // sync. See CLAUDE.md "Consistency > Robustness". case "sort": { - // Remove existing sort state - ws.GetFirstChild()?.Remove(); - - if (!string.IsNullOrEmpty(value) && !value.Equals("none", StringComparison.OrdinalIgnoreCase)) - { - var sd = ws.GetFirstChild(); - if (sd == null) break; - - // Value format: "A:asc" or "A:asc,B:desc" with optional range property - var sortRange = properties.GetValueOrDefault("range", ""); - int startRow, endRow; - if (string.IsNullOrEmpty(sortRange)) - { - var rows = sd.Elements().ToList(); - if (rows.Count == 0) break; - var maxCol = rows.SelectMany(r => r.Elements()) - .Select(c => ParseCellReference(c.CellReference?.Value ?? "A1")) - .Max(p => ColumnNameToIndex(p.Column)); - startRow = 1; - endRow = rows.Count; - sortRange = $"A1:{IndexToColumnName(maxCol)}{rows.Count}"; - } - else - { - var rangeParts = sortRange.Split(':'); - startRow = ParseCellReference(rangeParts[0]).Row; - endRow = ParseCellReference(rangeParts[1]).Row; - } - - // Parse sort specifications - var specs = value.Split(',', StringSplitOptions.RemoveEmptyEntries); - var sortKeys = new List<(int ColIndex, bool Descending)>(); - foreach (var spec in specs) - { - var specParts = spec.Trim().Split(':'); - var colName = specParts[0].Trim().ToUpperInvariant(); - bool descending = specParts.Length > 1 && - specParts[1].Trim().Equals("desc", StringComparison.OrdinalIgnoreCase); - sortKeys.Add((ColumnNameToIndex(colName), descending)); - } - - // Actually sort the rows in SheetData - var rowsInRange = sd.Elements() - .Where(r => r.RowIndex?.Value >= (uint)startRow && r.RowIndex?.Value <= (uint)endRow) - .ToList(); - - // Extract sort values for each row - string GetCellSortValue(Row row, int colIdx) - { - var colLetter = IndexToColumnName(colIdx); - var cell = row.Elements().FirstOrDefault(c => - c.CellReference?.Value?.StartsWith(colLetter, StringComparison.OrdinalIgnoreCase) == true && - ParseCellReference(c.CellReference.Value).Row == (int)(row.RowIndex?.Value ?? 0)); - return cell != null ? GetCellDisplayValue(cell) : ""; - } - - var sorted = rowsInRange.OrderBy(_ => 0); // identity - foreach (var (colIdx, desc) in sortKeys) - { - var col = colIdx; - var d = desc; - // Always sort by rank ascending (empties last), then by value in requested direction - sorted = sorted.ThenBy(r => ParseSortValue(GetCellSortValue(r, col)).Rank); - sorted = d - ? sorted.ThenByDescending(r => ParseSortValue(GetCellSortValue(r, col)).NumVal) - .ThenByDescending(r => ParseSortValue(GetCellSortValue(r, col)).StrVal) - : sorted.ThenBy(r => ParseSortValue(GetCellSortValue(r, col)).NumVal) - .ThenBy(r => ParseSortValue(GetCellSortValue(r, col)).StrVal); - } - var sortedList = sorted.ToList(); - - // Collect original row indices and reassign - var originalIndices = rowsInRange.Select(r => r.RowIndex!.Value).ToList(); - for (int si = 0; si < sortedList.Count; si++) - { - var row = sortedList[si]; - var newRowIdx = originalIndices[si]; - // Update row index and all cell references - row.RowIndex = newRowIdx; - foreach (var cell in row.Elements()) - { - if (cell.CellReference?.Value != null) - { - var (col, _) = ParseCellReference(cell.CellReference.Value); - cell.CellReference = $"{col}{newRowIdx}"; - } - } - } - - // Remove old rows and reinsert in sorted order - var beforeRow = sd.Elements() - .LastOrDefault(r => r.RowIndex?.Value < (uint)startRow); - foreach (var r in rowsInRange) r.Remove(); - OpenXmlElement insertAfter = beforeRow ?? (OpenXmlElement)sd; - foreach (var row in sortedList) - { - if (insertAfter == sd) - { - sd.InsertAt(row, 0); - insertAfter = row; - } - else - { - insertAfter.InsertAfterSelf(row); - insertAfter = row; - } - } + // R7-3: remove ALL sortState children (malformed files may + // carry more than one; GetFirstChild leaves stragglers). + foreach (var __ss in ws.Descendants().ToList()) __ss.Remove(); + if (string.IsNullOrEmpty(value) || value.Equals("none", StringComparison.OrdinalIgnoreCase)) + break; - // Write SortState metadata - var sortState = new SortState { Reference = sortRange }; - foreach (var (colIdx, desc) in sortKeys) + var sd = ws.GetFirstChild(); + if (sd == null) sd = ws.AppendChild(new SheetData()); + var rows = sd.Elements().ToList(); + // R12-2: DO NOT early-return on empty sheet here. Empty sheet + invalid + // sort spec (e.g. "XFE asc", "AAAA asc", "sort=asc") used to silently + // succeed because we bailed before spec validation. Always dispatch into + // SortRangeRows so it validates the spec first; if spec is valid and there + // is no data, it no-ops cleanly via its existing dataStartRow > row2 guard. + int maxCol = 1; + foreach (var r in rows) + foreach (var c in r.Elements()) { - var colName = IndexToColumnName(colIdx); - var condRef = $"{colName}{startRow}:{colName}{endRow}"; - var sortCondition = new SortCondition { Reference = condRef }; - if (desc) sortCondition.Descending = true; - sortState.AppendChild(sortCondition); + var cref = c.CellReference?.Value; + if (cref == null) continue; + maxCol = Math.Max(maxCol, ColumnNameToIndex(ParseCellReference(cref).Column)); } - ws.AppendChild(sortState); - } + int minRowIdx = rows.Count == 0 ? 1 : (int)rows.Min(r => r.RowIndex?.Value ?? 1u); + int maxRowIdx = rows.Count == 0 ? 1 : (int)rows.Max(r => r.RowIndex?.Value ?? 1u); + + // CONSISTENCY(sort-header-default): sortHeader defaults to false + // (row 1 participates in the reorder). This matches our general + // "caller states intent explicitly" rule and is documented in help. + // R4-D1 and R7-4 both proposed auto-detecting headers (type-mismatch + // heuristic, first-row-is-string warning). Rejected: heuristic + // warnings ship false positives on legitimately-heterogeneous + // row-1 data and are spammy in pipelines. Future revisit: make + // sortHeader default=true project-wide as a breaking change, + // documented in release notes — do NOT add a per-call warning. + bool sortHeader = properties.TryGetValue("sortheader", out var shv) && IsTruthy(shv); + SortRangeRows(worksheet, 1, minRowIdx, maxCol, maxRowIdx, value, sortHeader); + DeleteCalcChainIfPresent(); break; } + case "sortheader": + // consumed by "sort" case above; ignore silently here so it doesn't show unsupported + break; default: unsupported.Add(unsupported.Count == 0 - ? $"{key} (valid sheet props: name, freeze, zoom, showGridLines, showRowColHeaders, tabcolor, autofilter, merge, protect, password, printarea, orientation, papersize, fittopage, header, footer, sort)" + ? $"{key} (valid sheet props: name, freeze, zoom, showGridLines, showRowColHeaders, tabcolor, autofilter, merge, protect, password, printarea, orientation, papersize, fittopage, header, footer, sort, sortHeader)" : key); break; } } - ReorderWorksheetChildren(ws); ws.Save(); + SaveWorksheet(worksheet); return unsupported; } @@ -1726,10 +2215,37 @@ private List SetRange(WorksheetPart worksheet, string rangeRef, Dictiona // Separate range-level props from cell-level props var cellProps = new Dictionary(); + // CONSISTENCY(range-action): sort/sortHeader are consumed together as a + // range action (see sheet-level dispatch). If sort is present, apply it + // after cell-level props are processed. + string? sortSpec = null; + bool sortHeader = false; + // R4-4: reject merge+sort combo up front. SortRangeRows rejects any range + // containing merged cells, but if merge is applied first in this same call + // the merge write succeeds, then sort throws, leaving the file in a half- + // written state. Fail fast before touching the document. + bool hasMerge = false; + bool hasSort = false; + foreach (var (k, _) in properties) + { + var kl = k.ToLowerInvariant(); + if (kl == "merge") hasMerge = true; + else if (kl == "sort") hasSort = true; + } + if (hasMerge && hasSort) + throw new ArgumentException( + "Cannot apply 'merge' and 'sort' in the same call. Sort rejects merged cells; " + + "applying both in one call would leave the file half-written. Split into two calls."); foreach (var (key, value) in properties) { switch (key.ToLowerInvariant()) { + case "sort": + sortSpec = value; + break; + case "sortheader": + sortHeader = IsTruthy(value); + break; case "merge": { bool doMerge = value.Equals("true", StringComparison.OrdinalIgnoreCase) @@ -1751,6 +2267,7 @@ private List SetRange(WorksheetPart worksheet, string rangeRef, Dictiona { mergeCells.AppendChild(new MergeCell { Reference = rangeRef }); } + mergeCells.Count = (uint)mergeCells.Elements().Count(); } else { @@ -1765,6 +2282,8 @@ private List SetRange(WorksheetPart worksheet, string rangeRef, Dictiona // Remove empty MergeCells element if (!mergeCells.HasChildren) mergeCells.Remove(); + else + mergeCells.Count = (uint)mergeCells.Elements().Count(); } } break; @@ -1803,6 +2322,7 @@ private List SetRange(WorksheetPart worksheet, string rangeRef, Dictiona var cellRef = $"{IndexToColumnName(colIdx)}{row}"; var cell = FindOrCreateCell(sheetData, cellRef); var cellUnsupported = ApplyCellProperties(cell, worksheet, cellProps); + PruneEmptyCell(cell); // Only add to unsupported once (first cell) if (row == startRow && colIdx == startColIdx) unsupported.AddRange(cellUnsupported); @@ -1812,14 +2332,625 @@ private List SetRange(WorksheetPart worksheet, string rangeRef, Dictiona catch { ws.ReplaceChild(sheetDataBackup, sheetData); + // sheetData replaced — cached row entries for the old reference are stale + InvalidateRowIndex(); throw; } } - ReorderWorksheetChildren(ws); ws.Save(); + // Apply sort after cell-level props (range-action handler) + if (sortSpec != null) + { + var parts = rangeRef.Split(':'); + var (sc, sr) = ParseCellReference(parts[0]); + var (ec, er) = ParseCellReference(parts[1]); + SortRangeRows(worksheet, ColumnNameToIndex(sc), sr, ColumnNameToIndex(ec), er, sortSpec, sortHeader); + } + + DeleteCalcChainIfPresent(); + SaveWorksheet(worksheet); return unsupported; } + // ==================== Range Sort (region action) ==================== + + /// + /// Physically reorder rows in the given range by the given sort keys, then + /// write sortState metadata. Rejects ranges that intersect merged cells. + /// sortSpec format: "A asc, B desc" (direction optional, defaults to asc). + /// Column addressing is column letters only (A, B, AA); column names are not supported. + /// + private void SortRangeRows(WorksheetPart worksheet, int col1, int row1, int col2, int row2, + string sortSpec, bool sortHeader) + { + // Reject empty sort value at the range-level entry. Sheet-level "clear-sort" + // semantics (sort="" or "none") are handled by the sheet-level dispatcher before + // reaching here; any empty value that gets here came from a range path and is a + // user error we should surface loudly. + if (sortSpec == null || sortSpec.Length == 0 || string.IsNullOrWhiteSpace(sortSpec)) + throw new ArgumentException("sort value cannot be empty"); + if (sortSpec.Equals("none", StringComparison.OrdinalIgnoreCase)) + { + // R7-3: drop every SortState, not just the first. + var __ws0 = GetSheet(worksheet); + foreach (var __ss in __ws0.Descendants().ToList()) __ss.Remove(); + return; + } + + // Normalize reversed ranges (e.g. C5:A1 -> A1:C5) so row/column scans cover + // the intended region and sortState@ref stays well-formed (min:max). + if (col1 > col2) (col1, col2) = (col2, col1); + if (row1 > row2) (row1, row2) = (row2, row1); + + var ws = GetSheet(worksheet); + var sd = ws.GetFirstChild(); + if (sd == null) return; + + // Reject protected sheets unless the protection explicitly allows sort. + // Per OOXML sheetProtection, @sort defaults to true meaning "sort IS + // protected" (i.e. blocked). Only @sort="false" exempts sort from the + // protection and lets it run. + var protection = ws.GetFirstChild(); + if (protection != null && (protection.Sheet?.Value ?? false)) + { + bool sortBlocked = protection.Sort?.Value ?? true; + if (sortBlocked) + throw new InvalidOperationException( + "Cannot sort a protected sheet. Unprotect first (or set sheetProtection@sort=\"false\" to allow sorting while protected)."); + } + + // Reject malformed row layout within the sort row range: rows lacking RowIndex, + // or duplicate RowIndex values. Both cases would cause silent data loss or silent + // skipped rows in the sort below (RowIndex?.Value >= ... filter drops null; + // duplicate RowIndex means two rows get mapped to the same target slot). + // CONSISTENCY(sort-scope): only rows intersecting [row1..row2] are in scope; rows + // outside the sort range are irrelevant to this action (same scoping rule as the + // formula rejection below). + // A row with missing RowIndex is always rejected — it cannot be located in any + // range, and if it is logically within the sort window the sort filter would drop + // it silently. That is strictly a data-corruption signal regardless of scope. + { + var seen = new HashSet(); + foreach (var r in sd.Elements()) + { + if (r.RowIndex?.Value is not uint ri) + throw new InvalidOperationException( + "Cannot sort: sheet contains a element without a RowIndex. File is malformed."); + // Only rows within the sort row range matter for duplicate detection. + if (ri < (uint)row1 || ri > (uint)row2) continue; + if (!seen.Add(ri)) + throw new InvalidOperationException( + $"Cannot sort: sheet contains duplicate entries. File is malformed."); + } + } + + // Reject if any merged cell intersects sort range + var mergeCells = ws.GetFirstChild(); + if (mergeCells != null) + { + foreach (var mc in mergeCells.Elements()) + { + var mref = mc.Reference?.Value; + if (string.IsNullOrEmpty(mref) || !mref.Contains(':')) continue; + var mparts = mref.Split(':'); + var (mac, mar) = ParseCellReference(mparts[0]); + var (mbc, mbr) = ParseCellReference(mparts[1]); + int maci = ColumnNameToIndex(mac), mbci = ColumnNameToIndex(mbc); + bool rowsOverlap = !(mbr < row1 || mar > row2); + bool colsOverlap = !(mbci < col1 || maci > col2); + if (rowsOverlap && colsOverlap) + throw new InvalidOperationException( + $"Cannot sort range containing merged cells (found {mref}). Unmerge first or exclude merged cells from the sort range."); + } + } + + // Parse sort spec: "A asc, B desc" — default direction is asc + var sortKeys = new List<(int ColIndex, bool Descending)>(); + foreach (var spec in sortSpec.Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + var tokens = spec.Trim().Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length == 0) continue; + // Reject trailing junk like "A asc B" instead of silently dropping the tail. + if (tokens.Length > 2) + throw new ArgumentException( + $"Invalid sort key '{spec.Trim()}': too many tokens. Expected ' [asc|desc]'"); + var colName = tokens[0].ToUpperInvariant(); + if (!Regex.IsMatch(colName, @"^[A-Z]+$")) + throw new ArgumentException( + $"Invalid sort column '{tokens[0]}'. Expected column letters (A, B, AA). Column names are not supported; use letters."); + // R12-3: "asc" and "desc" are direction keywords, not column letters. When a + // user writes `sort=asc` (forgot the column) the token parses as a column + // name and produced a misleading "outside the range" error. Reject up-front + // with a targeted message. Applies regardless of case (Regex above already + // upper-cased via ToUpperInvariant, so match against "ASC"/"DESC"). + if (colName == "ASC" || colName == "DESC") + throw new ArgumentException( + $"Invalid sort key '{spec.Trim()}': sort key must start with a column letter, not a direction keyword ('{tokens[0]}'). Expected ' [asc|desc]'."); + bool desc = tokens.Length > 1 && tokens[1].Equals("desc", StringComparison.OrdinalIgnoreCase); + if (tokens.Length > 1 && !desc && !tokens[1].Equals("asc", StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException($"Invalid sort direction '{tokens[1]}'. Expected 'asc' or 'desc'."); + int keyColIdx = ColumnNameToIndex(colName); + // R11-1 / R12-2: Excel's max column is XFD (16384, 3 letters). Anything + // that parses past XFD is an invalid column: + // - length >= 4 (e.g. "AAAA", "Score"): almost certainly a column name + // - length == 3 but > XFD (e.g. "XFE", "ZZZ"): out of Excel's column space + // Both cases used to fall through to a misleading "outside the range A:B" + // error (especially pronounced on empty sheets where the range is A:A). + if (keyColIdx > 16384) + throw new ArgumentException( + $"Invalid sort column '{tokens[0]}'. Column names are not supported; use column letters (A, B, AA, up to XFD)."); + // Key column must lie within the sort range, otherwise the sort is silently + // a no-op and writes a malformed sortCondition ref. + if (keyColIdx < col1 || keyColIdx > col2) + throw new ArgumentException( + $"Sort column {colName} is outside the range {IndexToColumnName(col1)}:{IndexToColumnName(col2)}"); + sortKeys.Add((keyColIdx, desc)); + } + if (sortKeys.Count == 0) return; + + int dataStartRow = sortHeader ? row1 + 1 : row1; + // R6-2: a sort that can't reorder anything (empty data region, or a + // single data row) is a no-op. Writing sortState in those cases makes + // Excel render a bogus sort indicator on a range that was never sorted. + // Skip the metadata entirely rather than lying about having sorted. + if (dataStartRow > row2) + { + return; + } + + var rowsInRange = sd.Elements() + .Where(r => r.RowIndex?.Value >= (uint)dataStartRow && r.RowIndex?.Value <= (uint)row2) + .ToList(); + if (rowsInRange.Count <= 1) + { + return; + } + + // CONSISTENCY(sort-scope): formula rejection only applies to cells INSIDE the sort + // column range. A formula in a cell outside [col1..col2] is untouched by sort + // (its row may be reordered, but the formula text and its refs stay intact). + // Helper: test whether a cell's column lies within the sort column range. + // Name is column-specific: row containment is implied by caller (we iterate + // only rowsInRange). + bool CellColumnInSortRange(Cell c) + { + var cref = c.CellReference?.Value; + if (cref == null) return false; + var (cc, _) = ParseCellReference(cref); + int ci = ColumnNameToIndex(cc); + return ci >= col1 && ci <= col2; + } + + // Reject if any cell in the sort column range carries a shared formula group — + // sort would corrupt the ref anchor. + foreach (var r in rowsInRange) + foreach (var c in r.Elements()) + if (CellColumnInSortRange(c) && c.CellFormula?.FormulaType?.Value == CellFormulaValues.Shared) + throw new InvalidOperationException( + "Cannot sort range containing shared formulas. Rewrite them as per-cell formulas first."); + + // CONSISTENCY(sort-rejects-formulas): same shape as the shared-formula reject above. + // Sort rewrites each cell's CellReference to the new row index, but the formula text + // (e.g. "=A2+1000") still encodes the *old* relative addresses. After sort, Excel + // recalculates against the rewritten ref and silently produces wrong values — a + // data-corruption bug. A full fix would require parsing every formula and rewriting + // relative row numbers per the row's new position (handling A1 / $A$1 / A$1 / $A1 / + // A:B / Sheet!A1 / named ranges), which is high risk for partial-correctness + // regressions. Until that lands, refuse sort when any data row carries a formula. + // Known limitation: this does NOT catch formulas *outside* the sort range that + // reference cells *inside* it; those will also go stale on sort. Same scope as the + // shared-formula check above (per-row scan only). + foreach (var r in rowsInRange) + foreach (var c in r.Elements()) + if (CellColumnInSortRange(c) && c.CellFormula != null) + throw new InvalidOperationException( + $"Cannot sort range containing formulas (cell {c.CellReference?.Value}). " + + "Sort would rewrite cell references but leave formula text encoding the old row " + + "numbers, silently corrupting results. Rewrite formulas as literal values first " + + "(or evaluate and paste-as-values) before sorting."); + + // Materialize sort keys once (O(rows × keys × cells) → O(rows × keys)) + var keyed = rowsInRange.Select(r => + { + var keys = new (int Rank, double NumVal, string StrVal)[sortKeys.Count]; + for (int k = 0; k < sortKeys.Count; k++) + keys[k] = ParseSortValue(GetCellRawSortValueString(r, sortKeys[k].ColIndex)); + return (Row: r, Keys: keys); + }).ToList(); + + // Stable multi-key sort: first key primary, rest tiebreakers + IOrderedEnumerable<(Row Row, (int Rank, double NumVal, string StrVal)[] Keys)>? ordered = null; + for (int i = 0; i < sortKeys.Count; i++) + { + int idx = i; + bool desc = sortKeys[i].Descending; + if (ordered == null) + { + ordered = keyed.OrderBy(x => x.Keys[idx].Rank); + } + else + { + ordered = ordered.ThenBy(x => x.Keys[idx].Rank); + } + // R7-1: use case-insensitive comparer to match Excel's default sort + // behavior. sortState defaults caseSensitive=false, so the physical + // order must agree with that metadata declaration. Swapping to + // OrdinalIgnoreCase also matches Excel's user-visible default. + ordered = desc + ? ordered.ThenByDescending(x => x.Keys[idx].NumVal) + .ThenByDescending(x => x.Keys[idx].StrVal, StringComparer.OrdinalIgnoreCase) + : ordered.ThenBy(x => x.Keys[idx].NumVal) + .ThenBy(x => x.Keys[idx].StrVal, StringComparer.OrdinalIgnoreCase); + } + var sortedRows = ordered!.Select(x => x.Row).ToList(); + + // The sorted slots must be assigned by ascending row index; SheetData document + // order is not guaranteed to be ascending (malformed files, or legitimate writer + // output), so rely on RowIndex values rather than List position. + var originalIndices = rowsInRange.Select(r => r.RowIndex!.Value).OrderBy(v => v).ToList(); + + // R4-1/2/3: capture old→new row mapping BEFORE mutating row indices so we can + // rewrite sidecar metadata refs (hyperlinks, comments, dataValidations) that + // encode absolute cell refs and would otherwise still point at the old rows. + // Key = old row index (from the row object as it existed pre-sort); Value = new + // row index it lands on post-sort. + var oldToNewRow = new Dictionary(sortedRows.Count); + for (int i = 0; i < sortedRows.Count; i++) + { + var oldIdx = sortedRows[i].RowIndex!.Value; + var newIdx = originalIndices[i]; + oldToNewRow[oldIdx] = newIdx; + } + + // Detach from SheetData, invalidate row-index cache + foreach (var r in rowsInRange) r.Remove(); + InvalidateRowIndex(sd); + + // Rewrite row index + cell refs on sorted rows + for (int i = 0; i < sortedRows.Count; i++) + { + var newIdx = originalIndices[i]; + var r = sortedRows[i]; + r.RowIndex = newIdx; + foreach (var cell in r.Elements()) + { + var cref = cell.CellReference?.Value; + if (cref == null) continue; + var (cc, _) = ParseCellReference(cref); + cell.CellReference = $"{cc}{newIdx}"; + } + } + + // R4-1/2/3: rewrite sidecar metadata refs that live outside but + // encode cell addresses. Only refs pointing into the sort rectangle are + // rewritten; refs outside are untouched. See CLAUDE.md "Consistency > Robustness" + // — same philosophy as formula rejection: we do not attempt to rewrite refs + // that cross the sort boundary (e.g. dataValidation sqref spanning A1:A100 when + // only A2:A5 sort) because that would require partial-region splitting; instead + // the cell-anchored model covers the common case and leaves other cases intact. + RewriteSidecarRefsAfterSort(worksheet, col1, row1, col2, row2, oldToNewRow); + + // Reinsert in sorted order, preserving rows outside the data range + var beforeRow = sd.Elements().LastOrDefault(r => r.RowIndex?.Value < (uint)dataStartRow); + OpenXmlElement insertAfter = beforeRow ?? (OpenXmlElement)sd; + foreach (var r in sortedRows) + { + if (insertAfter == sd) sd.InsertAt(r, 0); + else insertAfter.InsertAfterSelf(r); + insertAfter = r; + } + InvalidateRowIndex(sd); + + WriteSortState(ws, col1, row1, col2, row2, sortKeys); + } + + /// Write sortState metadata. sortState@ref = full range; sortCondition@ref = key column within range. + private static void WriteSortState(Worksheet ws, int col1, int row1, int col2, int row2, + List<(int ColIndex, bool Descending)> sortKeys) + { + // R7-3: drop every SortState, not just the first (malformed files may + // carry duplicates). GetFirstChild would leave the tail behind and the + // newly-appended state would become the 2nd/3rd, still ambiguous. + foreach (var __ss in ws.Descendants().ToList()) __ss.Remove(); + var fullRef = $"{IndexToColumnName(col1)}{row1}:{IndexToColumnName(col2)}{row2}"; + var ss = new SortState { Reference = fullRef }; + foreach (var (colIdx, desc) in sortKeys) + { + var keyRef = $"{IndexToColumnName(colIdx)}{row1}:{IndexToColumnName(colIdx)}{row2}"; + var sc = new SortCondition { Reference = keyRef }; + if (desc) sc.Descending = true; + ss.AppendChild(sc); + } + // Honor OOXML CT_Worksheet schema order. Per ECMA-376 the child sequence that + // matters here is: + // sheetData → sheetCalcPr → sheetProtection → protectedRanges → scenarios + // → autoFilter → sortState → dataConsolidate → customSheetViews → mergeCells + // → phoneticPr → conditionalFormatting → dataValidations → hyperlinks → ... + // So sortState must be inserted AFTER the latest present predecessor and BEFORE + // any later element (mergeCells, hyperlinks, conditionalFormatting, etc.). The + // previous fallback `sheetData.InsertAfterSelf` placed sortState before mergeCells + // which violates the schema and is rejected by strict validators. + var anchor = (OpenXmlElement?)ws.GetFirstChild() + ?? (OpenXmlElement?)ws.GetFirstChild() + ?? (OpenXmlElement?)ws.GetFirstChild() + ?? (OpenXmlElement?)ws.GetFirstChild() + ?? (OpenXmlElement?)ws.GetFirstChild() + ?? (OpenXmlElement?)ws.GetFirstChild(); + if (anchor != null) + anchor.InsertAfterSelf(ss); + else + ws.AppendChild(ss); + } + + /// + /// R4-1/2/3: remap sidecar metadata cell refs after a sort. Rewrites any + /// hyperlink/comment/dataValidation reference that anchors on a single cell + /// inside the sort rectangle (col1..col2, row1..row2) using the old→new row + /// mapping. Refs outside the rectangle are left alone; multi-cell refs that + /// cross the sort boundary are also left alone (same scope-limited philosophy + /// as the formula-rejection path — see CONSISTENCY(sort-scope)). DataValidation + /// sqref may contain multiple space-separated tokens; each is processed + /// independently. + /// + private void RewriteSidecarRefsAfterSort(WorksheetPart worksheet, + int col1, int row1, int col2, int row2, + Dictionary oldToNewRow) + { + var ws = GetSheet(worksheet); + + // Helper: is a single cell ref (e.g. "A2") inside the sort rectangle? + bool CellInRect(string cref, out string col, out uint row) + { + col = ""; row = 0; + if (string.IsNullOrEmpty(cref)) return false; + if (!System.Text.RegularExpressions.Regex.IsMatch(cref, @"^[A-Za-z]+\d+$")) return false; + var parsed = ParseCellReference(cref); + col = parsed.Column; + row = (uint)parsed.Row; + int ci = ColumnNameToIndex(col); + return ci >= col1 && ci <= col2 && row >= (uint)row1 && row <= (uint)row2; + } + + // ---- Hyperlinks ---- + var hyperlinksEl = ws.GetFirstChild(); + if (hyperlinksEl != null) + { + foreach (var h in hyperlinksEl.Elements()) + { + var href = h.Reference?.Value; + if (href == null) continue; + if (CellInRect(href, out var hc, out var hr) && oldToNewRow.TryGetValue(hr, out var newR)) + { + h.Reference = $"{hc.ToUpperInvariant()}{newR}"; + } + } + } + + // ---- Comments ---- + var commentsPart = worksheet.WorksheetCommentsPart; + if (commentsPart?.Comments != null) + { + var commentList = commentsPart.Comments.GetFirstChild(); + if (commentList != null) + { + bool changed = false; + foreach (var cmt in commentList.Elements()) + { + var cref = cmt.Reference?.Value; + if (cref == null) continue; + if (CellInRect(cref, out var cc, out var cr) && oldToNewRow.TryGetValue(cr, out var newR)) + { + cmt.Reference = $"{cc.ToUpperInvariant()}{newR}"; + changed = true; + } + } + if (changed) commentsPart.Comments.Save(); + } + } + + // ---- Threaded Comments (Excel 365) ---- + // R5-2: threadedComments.xml is a separate part from legacy comments.xml + // (same storage model: per-cell entries). Rewriting + // legacy comments but not threaded ones left 365-authored files with threaded + // bubbles anchored to the wrong rows post-sort. Cell-anchored refs only; any + // non-single-cell ref is left untouched (same scoping rule as legacy comments). + foreach (var threadedPart in worksheet.WorksheetThreadedCommentsParts) + { + if (threadedPart?.ThreadedComments == null) continue; + bool tcChanged = false; + foreach (var tc in threadedPart.ThreadedComments.Elements()) + { + var tref = tc.Ref?.Value; + if (tref == null) continue; + if (CellInRect(tref, out var tcc, out var tcr) && oldToNewRow.TryGetValue(tcr, out var newR)) + { + tc.Ref = $"{tcc.ToUpperInvariant()}{newR}"; + tcChanged = true; + } + } + if (tcChanged) threadedPart.ThreadedComments.Save(); + } + + // ---- DataValidations ---- + var dvs = ws.GetFirstChild(); + if (dvs != null) + { + foreach (var dv in dvs.Elements()) + { + var sqref = dv.SequenceOfReferences; + if (sqref?.InnerText == null) continue; + // sqref is a space-separated list of ref tokens; each token may be + // a single cell (A2) or a range (A2:A5). Only single-cell tokens + // inside the sort rectangle are remapped; multi-cell ranges are + // left untouched (partial-rect rewrite would require splitting). + var tokens = sqref.InnerText.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + bool changed = false; + for (int i = 0; i < tokens.Length; i++) + { + var tok = tokens[i]; + if (tok.Contains(':')) continue; // range token — skip + if (CellInRect(tok, out var dc, out var dr) && oldToNewRow.TryGetValue(dr, out var newR)) + { + tokens[i] = $"{dc.ToUpperInvariant()}{newR}"; + changed = true; + } + } + if (changed) + { + dv.SequenceOfReferences = new ListValue( + tokens.Select(t => new StringValue(t))); + } + } + } + + // ---- ProtectedRanges (R7-2) ---- + // CONSISTENCY(sort-scope): same cell-anchored scoping as dataValidations. + // Each carries a space-separated list of + // ref tokens; only single-cell tokens inside the sort rectangle are + // remapped. Multi-cell ranges are left intact (partial-rect split would + // alter which cells are protected, same philosophy as DV/CF). + var pranges = ws.GetFirstChild(); + if (pranges != null) + { + foreach (var pr in pranges.Elements()) + { + var sqref = pr.SequenceOfReferences; + if (sqref?.InnerText == null) continue; + var tokens = sqref.InnerText.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + bool changed = false; + for (int i = 0; i < tokens.Length; i++) + { + var tok = tokens[i]; + if (tok.Contains(':')) continue; // range token — skip + if (CellInRect(tok, out var pc, out var pRow) && oldToNewRow.TryGetValue(pRow, out var newR)) + { + tokens[i] = $"{pc.ToUpperInvariant()}{newR}"; + changed = true; + } + } + if (changed) + { + pr.SequenceOfReferences = new ListValue( + tokens.Select(t => new StringValue(t))); + } + } + } + + // ---- ConditionalFormatting (R6-1) ---- + // CONSISTENCY(sort-scope): same cell-anchored scoping as dataValidations. + // CF sqref is a space-separated list where each token may be a single + // cell (A2) or a range (A1:A10). Only single-cell tokens inside the sort + // rectangle are remapped; multi-cell ranges are left untouched — a range + // that straddles reordered rows cannot be split into the new set of rows + // without changing which cells the rule covers, so we preserve the + // authored range verbatim (same partial-rect rule as dataValidations). + foreach (var cf in ws.Elements()) + { + var sqref = cf.SequenceOfReferences; + if (sqref?.InnerText == null) continue; + var tokens = sqref.InnerText.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + bool changed = false; + for (int i = 0; i < tokens.Length; i++) + { + var tok = tokens[i]; + if (tok.Contains(':')) continue; // range token — skip + if (CellInRect(tok, out var cc, out var cr) && oldToNewRow.TryGetValue(cr, out var newR)) + { + tokens[i] = $"{cc.ToUpperInvariant()}{newR}"; + changed = true; + } + } + if (changed) + { + cf.SequenceOfReferences = new ListValue( + tokens.Select(t => new StringValue(t))); + } + } + + // ---- Drawing anchors (R6-4) ---- + // CONSISTENCY(sort-scope): same cell-anchored scoping as dataValidations/CF. + // Drawing anchors (xdr:twoCellAnchor/xdr:oneCellAnchor) pin shapes, pictures, + // and charts to a (col,row) pair via xdr:from (and xdr:to for twoCell). RowId + // is 0-indexed in OOXML, so worksheet row N ↔ RowId = N-1. Before R6-4 the + // sort path rewrote cell-level sidecars but left drawing RowIds untouched, + // which dragged pictures off their original anchor row after a reorder. + // + // Scoping rule (partial-rect): for TwoCellAnchor both From and To rows must + // fall inside the sort rectangle for the anchor to move. If only one end is + // inside, preserve the authored anchor (splitting a rectangle across + // reordered rows would change which cells the drawing visually covers). + // OneCellAnchor has only From — remap iff From is inside. + // Columns aren't affected by row sort, so ColId is never rewritten. + var drawingsPart = worksheet.DrawingsPart; + if (drawingsPart?.WorksheetDrawing != null) + { + bool drawingChanged = false; + bool RowInSortRect(uint oneBasedRow) => + oneBasedRow >= (uint)row1 && oneBasedRow <= (uint)row2; + + // TwoCellAnchor: remap only if both endpoints' rows are in sort rect. + foreach (var anchor in drawingsPart.WorksheetDrawing.Elements()) + { + var from = anchor.FromMarker; + var to = anchor.ToMarker; + if (from?.RowId?.Text == null || to?.RowId?.Text == null) continue; + if (!uint.TryParse(from.RowId.Text, out uint fromRow0)) continue; + if (!uint.TryParse(to.RowId.Text, out uint toRow0)) continue; + uint fromRow1 = fromRow0 + 1; + uint toRow1 = toRow0 + 1; + if (!RowInSortRect(fromRow1) || !RowInSortRect(toRow1)) continue; + if (!oldToNewRow.TryGetValue(fromRow1, out uint newFrom1)) continue; + if (!oldToNewRow.TryGetValue(toRow1, out uint newTo1)) continue; + from.RowId = new DocumentFormat.OpenXml.Drawing.Spreadsheet.RowId( + (newFrom1 - 1).ToString()); + to.RowId = new DocumentFormat.OpenXml.Drawing.Spreadsheet.RowId( + (newTo1 - 1).ToString()); + drawingChanged = true; + } + + // OneCellAnchor: remap iff From is in sort rect. + foreach (var anchor in drawingsPart.WorksheetDrawing.Elements()) + { + var from = anchor.FromMarker; + if (from?.RowId?.Text == null) continue; + if (!uint.TryParse(from.RowId.Text, out uint fromRow0)) continue; + uint fromRow1 = fromRow0 + 1; + if (!RowInSortRect(fromRow1)) continue; + if (!oldToNewRow.TryGetValue(fromRow1, out uint newFrom1)) continue; + from.RowId = new DocumentFormat.OpenXml.Drawing.Spreadsheet.RowId( + (newFrom1 - 1).ToString()); + drawingChanged = true; + } + + if (drawingChanged) drawingsPart.WorksheetDrawing.Save(); + } + } + + /// Raw cell value for sorting: resolves SharedString/InlineString, skips number formatting. Precise column-letter match (no prefix bug). + private string GetCellRawSortValueString(Row row, int colIdx) + { + var colLetter = IndexToColumnName(colIdx); + foreach (var cell in row.Elements()) + { + var cref = cell.CellReference?.Value; + if (cref == null) continue; + var (cc, _) = ParseCellReference(cref); + if (!cc.Equals(colLetter, StringComparison.OrdinalIgnoreCase)) continue; + + if (cell.DataType?.Value == CellValues.SharedString) + { + var sst = _doc.WorkbookPart?.GetPartsOfType().FirstOrDefault(); + if (sst?.SharedStringTable != null && int.TryParse(cell.CellValue?.Text, out int idx)) + return sst.SharedStringTable.Elements().ElementAtOrDefault(idx)?.InnerText ?? ""; + return ""; + } + if (cell.DataType?.Value == CellValues.InlineString) + return cell.InlineString?.InnerText ?? ""; + return cell.CellValue?.Text ?? ""; + } + return ""; + } + // ==================== Column Set (width, hidden) ==================== private List SetColumn(WorksheetPart worksheet, string colName, Dictionary properties) @@ -1857,7 +2988,7 @@ private List SetColumn(WorksheetPart worksheet, string colName, Dictiona switch (key.ToLowerInvariant()) { case "width": - col.Width = ParseHelpers.SafeParseDouble(value, "width"); + col.Width = ParseColWidthChars(value); col.CustomWidth = true; break; case "hidden": @@ -1865,7 +2996,8 @@ private List SetColumn(WorksheetPart worksheet, string colName, Dictiona || value == "1" || value.Equals("yes", StringComparison.OrdinalIgnoreCase); break; case "outline" or "outlinelevel" or "group": - if (!byte.TryParse(value, out var colOutline)) + // DEFERRED(xlsx/row-height-validation) RC2: Excel outline level max is 7. + if (!byte.TryParse(value, out var colOutline) || colOutline > 7) throw new ArgumentException($"Invalid 'outline' value: '{value}'. Expected an integer 0-7 (outline/group level)."); col.OutlineLevel = colOutline; break; @@ -1887,7 +3019,7 @@ private List SetColumn(WorksheetPart worksheet, string colName, Dictiona } } - ReorderWorksheetChildren(ws); ws.Save(); + SaveWorksheet(worksheet); return unsupported; } @@ -1975,7 +3107,7 @@ private void AutoFitAllColumns(WorksheetPart worksheet) } } - ReorderWorksheetChildren(ws); ws.Save(); + SaveWorksheet(worksheet); } // ==================== Row Set (height, hidden) ==================== @@ -2005,9 +3137,7 @@ private List SetRow(WorksheetPart worksheet, uint rowIdx, Dictionary SetRow(WorksheetPart worksheet, uint rowIdx, Dictionary 7) throw new ArgumentException($"Invalid 'outline' value: '{value}'. Expected an integer 0-7 (outline/group level)."); row.OutlineLevel = outlineVal; break; @@ -2029,7 +3160,7 @@ private List SetRow(WorksheetPart worksheet, uint rowIdx, Dictionary SetAutoFilter(WorksheetPart worksheet, Dictionary + /// Add a slicer bound to an existing pivot table field. + /// Required props: pivotTable (path), field (field name in the pivot cache). + /// Optional props: name, caption, columnCount, rowHeight, style, x, y, width, height. + /// Returns the new slicer's path: /SheetName/slicer[N]. + /// + private string AddSlicer(string parentPath, Dictionary properties) + { + var segments = parentPath.TrimStart('/').Split('/', 2); + var sheetName = segments[0]; + var hostWorksheet = FindWorksheet(sheetName) + ?? throw SheetNotFoundException(sheetName); + + // 1. Resolve pivot table reference --------------------------------- + if (!properties.TryGetValue("pivotTable", out var pivotRef) + && !properties.TryGetValue("pivot", out pivotRef) + && !properties.TryGetValue("source", out pivotRef)) + { + throw new ArgumentException( + "slicer requires 'pivotTable' property pointing to an existing pivot table " + + "(e.g. pivotTable=/Sheet1/pivottable[1])"); + } + + var (pivotPart, pivotWorksheet, pivotSheetName) = ResolvePivotReference(pivotRef); + var pivotDef = pivotPart.PivotTableDefinition + ?? throw new ArgumentException($"Pivot table at '{pivotRef}' has no definition"); + var pivotCachePart = pivotPart.GetPartsOfType().FirstOrDefault() + ?? throw new ArgumentException($"Pivot table at '{pivotRef}' has no cache definition"); + var pivotCacheDef = pivotCachePart.PivotCacheDefinition + ?? throw new ArgumentException($"Pivot table at '{pivotRef}' has no cache definition"); + + // 2. Resolve field name → cacheField index ------------------------- + if (!properties.TryGetValue("field", out var fieldName) || string.IsNullOrWhiteSpace(fieldName)) + throw new ArgumentException("slicer requires 'field' property naming a pivot field"); + + var cacheFields = pivotCacheDef.GetFirstChild() + ?? throw new ArgumentException($"Pivot cache has no cacheFields"); + var cacheFieldList = cacheFields.Elements().ToList(); + int fieldIdx = -1; + for (int i = 0; i < cacheFieldList.Count; i++) + { + if (string.Equals(cacheFieldList[i].Name?.Value, fieldName, StringComparison.OrdinalIgnoreCase)) + { + fieldIdx = i; + break; + } + } + if (fieldIdx < 0) + { + var available = string.Join(", ", cacheFieldList.Select(f => f.Name?.Value ?? "?")); + throw new ArgumentException( + $"Field '{fieldName}' not found in pivot cache. Available: [{available}]"); + } + // Use the real cacheField name for SourceName (exact match required by Excel) + var sourceName = cacheFieldList[fieldIdx].Name?.Value ?? fieldName; + + // 3. Resolve slicer/cache names + collision check ------------------ + var slicerName = properties.GetValueOrDefault("name"); + if (string.IsNullOrWhiteSpace(slicerName)) + slicerName = $"Slicer_{sourceName}"; + slicerName = SanitizeSlicerName(slicerName); + + var cacheName = $"Slicer_{sourceName}"; + // Make both unique across the workbook + var existingSlicerNames = CollectExistingSlicerNames(); + var existingCacheNames = CollectExistingSlicerCacheNames(); + slicerName = MakeUnique(slicerName, existingSlicerNames); + cacheName = MakeUnique(cacheName, existingCacheNames); + + // 4. Pivot linkage metadata ---------------------------------------- + var pivotName = pivotDef.Name?.Value + ?? throw new ArgumentException($"Pivot table at '{pivotRef}' has no name"); + var pivotCacheId = EnsurePivotCacheSlicerExtension(pivotCacheDef); + var pivotTabId = GetSheetTabId(pivotWorksheet); + + // Enumerate shared items for the chosen field. Each distinct value + // becomes one TabularSlicerCacheItem with s=true (selected=visible). + var sharedItems = cacheFieldList[fieldIdx].SharedItems; + int itemCount = sharedItems?.ChildElements.Count ?? 0; + + // 5. Create SlicerCachePart --------------------------------------- + var workbookPart = _doc.WorkbookPart!; + var slicerCachePart = workbookPart.AddNewPart(); + + var slicerCacheDef = new X14.SlicerCacheDefinition + { + Name = cacheName, + SourceName = sourceName, + MCAttributes = new MarkupCompatibilityAttributes { Ignorable = "x" } + }; + slicerCacheDef.AddNamespaceDeclaration("mc", McNsUri); + slicerCacheDef.AddNamespaceDeclaration("x", XNsUri); + + var pivotTables = new X14.SlicerCachePivotTables(); + pivotTables.Append(new X14.SlicerCachePivotTable + { + TabId = pivotTabId, + Name = pivotName + }); + slicerCacheDef.Append(pivotTables); + + var tabularCache = new X14.TabularSlicerCache + { + PivotCacheId = pivotCacheId + }; + var items = new X14.TabularSlicerCacheItems(); + for (int i = 0; i < itemCount; i++) + { + items.Append(new X14.TabularSlicerCacheItem + { + Atom = (uint)i, + IsSelected = true + }); + } + tabularCache.Append(items); + + var slicerCacheData = new X14.SlicerCacheData(); + slicerCacheData.Append(tabularCache); + slicerCacheDef.Append(slicerCacheData); + + slicerCachePart.SlicerCacheDefinition = slicerCacheDef; + slicerCacheDef.Save(slicerCachePart); + var slicerCacheRelId = workbookPart.GetIdOfPart(slicerCachePart); + + // 6. Register slicer cache in workbook extLst --------------------- + RegisterSlicerCacheInWorkbook(workbookPart, slicerCacheRelId); + + // 6b. Register a workbook-level DefinedName placeholder for the + // slicer. Excel expects each slicer name to have a matching + // #N/A entry — it's a + // sentinel rather than a real named range, and Excel uses it to + // guard the slicer identifier namespace. + RegisterSlicerDefinedName(workbookPart, slicerName); + + // 7. Create SlicersPart + Slicer element on host worksheet --------- + // If the host sheet already has a SlicersPart, reuse it so multiple + // slicers on the same sheet share a single container (matches + // Excel's on-disk layout). + var slicersPart = hostWorksheet.GetPartsOfType().FirstOrDefault(); + X14.Slicers slicersContainer; + string slicersPartRelId; + if (slicersPart == null) + { + slicersPart = hostWorksheet.AddNewPart(); + slicersContainer = new X14.Slicers + { + MCAttributes = new MarkupCompatibilityAttributes { Ignorable = "x" } + }; + slicersContainer.AddNamespaceDeclaration("mc", McNsUri); + slicersContainer.AddNamespaceDeclaration("x", XNsUri); + slicersPart.Slicers = slicersContainer; + slicersPartRelId = hostWorksheet.GetIdOfPart(slicersPart); + RegisterSlicerListInWorksheet(hostWorksheet, slicersPartRelId); + } + else + { + slicersContainer = slicersPart.Slicers + ?? throw new InvalidOperationException("Existing SlicersPart has no Slicers element"); + slicersPartRelId = hostWorksheet.GetIdOfPart(slicersPart); + } + + var rowHeight = properties.TryGetValue("rowHeight", out var rhStr) + && uint.TryParse(rhStr, out var rh) ? rh : 225425U; + var caption = properties.GetValueOrDefault("caption") ?? sourceName; + var slicerElement = new X14.Slicer + { + Name = slicerName, + Cache = cacheName, + Caption = caption, + RowHeight = rowHeight + }; + if (properties.TryGetValue("columnCount", out var ccStr) + && uint.TryParse(ccStr, out var cc) && cc >= 1 && cc <= 20000) + slicerElement.ColumnCount = cc; + if (properties.TryGetValue("style", out var styleStr) && !string.IsNullOrWhiteSpace(styleStr)) + slicerElement.Style = styleStr; + + slicersContainer.Append(slicerElement); + slicersContainer.Save(slicersPart); + + // 8. Add drawing anchor -------------------------------------------- + AddSlicerDrawingAnchor(hostWorksheet, slicerName, properties); + + SaveWorksheet(hostWorksheet); + workbookPart.Workbook!.Save(); + + // 9. Compute index for return path --------------------------------- + var slicerIdx = slicersContainer.Elements().Count(); + return $"/{sheetName}/slicer[{slicerIdx}]"; + } + + // ==================== Pivot reference resolution ==================== + + private (PivotTablePart part, WorksheetPart worksheetPart, string sheetName) + ResolvePivotReference(string pivotRef) + { + // Accepts: /SheetName/pivottable[N] or SheetName!pivottable[N] or just the name + var normalized = NormalizeExcelPath(pivotRef.Trim()); + if (!normalized.StartsWith('/')) + normalized = "/" + normalized; + var parts = normalized.TrimStart('/').Split('/', 2); + if (parts.Length != 2) + throw new ArgumentException( + $"Invalid pivotTable reference '{pivotRef}'. Expected /SheetName/pivottable[N]"); + var sheetName = parts[0]; + var worksheetPart = FindWorksheet(sheetName) + ?? throw SheetNotFoundException(sheetName); + var m = System.Text.RegularExpressions.Regex.Match( + parts[1], @"^(?:pivottable|pivot)\[(\d+)\]$", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (!m.Success) + throw new ArgumentException( + $"Invalid pivotTable reference '{pivotRef}'. Expected form /SheetName/pivottable[N]"); + var idx = int.Parse(m.Groups[1].Value); + var pivotParts = worksheetPart.PivotTableParts.ToList(); + if (idx < 1 || idx > pivotParts.Count) + throw new ArgumentException( + $"pivottable[{idx}] out of range on sheet '{sheetName}' (have {pivotParts.Count})"); + return (pivotParts[idx - 1], worksheetPart, sheetName); + } + + private uint GetSheetTabId(WorksheetPart worksheetPart) + { + var workbookPart = _doc.WorkbookPart!; + var relId = workbookPart.GetIdOfPart(worksheetPart); + var sheets = workbookPart.Workbook!.GetFirstChild() + ?? throw new InvalidOperationException("Workbook has no Sheets element"); + var sheet = sheets.Elements().FirstOrDefault(s => s.Id?.Value == relId) + ?? throw new InvalidOperationException( + "Worksheet part is not referenced in workbook.sheets"); + return sheet.SheetId?.Value + ?? throw new InvalidOperationException($"Sheet '{sheet.Name}' has no sheetId"); + } + + // ==================== Pivot cache 2010 extension ==================== + + private const string PivotCache2010ExtUri = "{725AE2AE-9491-48be-B2B4-4EB974FC3084}"; + + /// + /// Ensure the pivot cache definition carries an Office 2010 pivot-cache + /// extension carrying a random-looking uint32 as pivotCacheId. This is + /// the ID that slicer caches reference via <x14:tabular + /// pivotCacheId="..."/> — it is NOT the same as the workbook's + /// <pivotCache cacheId="..."> attribute (which is an internal + /// list index). Excel real reference files use a random 32-bit uint + /// here. Returns the id so the caller can write it into the slicer + /// cache. Idempotent — reuses the existing id on re-entry. + /// + private static uint EnsurePivotCacheSlicerExtension(PivotCacheDefinition pivotCacheDef) + { + // CONSISTENCY(strongly-typed-extLst): must use PivotCacheDefinitionExtensionList, + // not the generic ExtensionList. The SDK has a distinct strongly-typed + // class for each schema-location extLst, and on reload from disk the + // parser produces exactly that typed instance. GetFirstChild() + // returns null against a PivotCacheDefinitionExtensionList child — so in + // direct-open mode (where every command re-reads the file), every slicer + // add fails the "already exists?" check, allocates a fresh ExtensionList, + // and appends a DUPLICATE `` sibling. Excel then either silently + // "repairs" the file (popping the "We found a problem" dialog) or drops + // the cache extension entirely, breaking slicer ↔ pivot binding. + // + // Resident mode hid this bug: within a single handler lifetime the + // originally-created ExtensionList stays in memory as ExtensionList (our + // new-expression), so GetFirstChild() finds it and reuses + // it — so single-process pipelines (like the dashboard script without an + // intervening `close`) produced clean files while every direct-open-per- + // command path (including the slicer-dashboard.py pattern once `close` is + // interposed, and most external callers) produced broken files. + // + // Cleanup: also drop any stale ExtensionList siblings left behind by + // older builds of this code, so re-opening an existing broken file + // with a new write auto-heals it. + var extList = pivotCacheDef.GetFirstChild(); + if (extList == null) + { + extList = new PivotCacheDefinitionExtensionList(); + pivotCacheDef.AppendChild(extList); + } + foreach (var stale in pivotCacheDef.Elements().ToList()) + stale.Remove(); + + // Look for an existing x14:pivotCacheDefinition extension; reuse + // its pivotCacheId so multiple slicers on the same pivot cache + // all reference the same id. + // + // CONSISTENCY(strongly-typed-extLst): same trap as the extLst container + // above — children of PivotCacheDefinitionExtensionList reload from + // disk as PivotCacheDefinitionExtension (NOT the generic Extension), + // so Elements() misses them and we fall through to "append + // a brand-new extension with a fresh random pivotCacheId" on every + // second+ slicer. That leaves the pivotCache carrying multiple + // x14:pivotCacheDefinition siblings each with its own id, while + // individual slicerCache parts reference DIFFERENT ids — a bifurcated + // structure Excel trips on at load time ("We found a problem ...", + // even though the SDK validator treats each sibling as independently + // valid). Use the strongly-typed Elements + // so the lookup sees reloaded children. + // + // Also sweep any stale generic-Extension siblings produced by older + // builds, for the same auto-heal reason as the container cleanup above. + foreach (var staleGeneric in extList.Elements().ToList()) + staleGeneric.Remove(); + + foreach (var ext in extList.Elements()) + { + if (ext.Uri?.Value != PivotCache2010ExtUri) continue; + var existingDef = ext.GetFirstChild(); + if (existingDef?.PivotCacheId?.HasValue == true) + return existingDef.PivotCacheId.Value; + // Extension exists but lacks the attribute — upgrade in place. + var upgradeId = RandomPivotCacheId(); + if (existingDef == null) + { + existingDef = new X14.PivotCacheDefinition { PivotCacheId = upgradeId }; + ext.Append(existingDef); + } + else + { + existingDef.PivotCacheId = upgradeId; + } + return upgradeId; + } + + var newId = RandomPivotCacheId(); + var newExt = new PivotCacheDefinitionExtension { Uri = PivotCache2010ExtUri }; + newExt.AddNamespaceDeclaration("x14", X14NsUri); + newExt.Append(new X14.PivotCacheDefinition { PivotCacheId = newId }); + extList.Append(newExt); + return newId; + } + + /// + /// Generate a random 32-bit unsigned integer in the range used by + /// Excel-generated pivot cache ids (1 … int.MaxValue). Positive range + /// avoids any theoretical signed-int interop issue with downstream + /// consumers that may use Int32 internally. + /// + private static uint RandomPivotCacheId() + => (uint)Random.Shared.Next(1, int.MaxValue); + + // ==================== Workbook / worksheet extLst registration ==================== + + private void RegisterSlicerCacheInWorkbook(WorkbookPart workbookPart, string slicerCachePartRelId) + { + var workbook = workbookPart.Workbook!; + var extList = workbook.GetFirstChild(); + if (extList == null) + { + extList = new WorkbookExtensionList(); + // WorkbookExtensionList must appear after most other workbook + // children — AppendChild is correct since it's the last element. + workbook.AppendChild(extList); + } + + var ext = extList.Elements() + .FirstOrDefault(e => e.Uri?.Value == SlicerCachesExtUri); + X14.SlicerCaches caches; + if (ext == null) + { + ext = new WorkbookExtension { Uri = SlicerCachesExtUri }; + ext.AddNamespaceDeclaration("x14", X14NsUri); + caches = new X14.SlicerCaches(); + ext.Append(caches); + extList.Append(ext); + } + else + { + caches = ext.GetFirstChild() + ?? ext.AppendChild(new X14.SlicerCaches()); + } + + caches.Append(new X14.SlicerCache { Id = slicerCachePartRelId }); + } + + private static void RegisterSlicerDefinedName(WorkbookPart workbookPart, string slicerName) + { + var workbook = workbookPart.Workbook!; + var definedNames = workbook.GetFirstChild(); + if (definedNames == null) + { + definedNames = new DefinedNames(); + // Schema order: per ECMA-376, DefinedNames appears AFTER sheets + // / externalReferences and BEFORE calcPr / oleSize / pivotCaches + // / extLst. Violating this order is what made Excel flag the + // file as "corrupt and unrepairable" — Excel's workbook parser + // aborts on out-of-order children without attempting recovery. + // Walk the ordered list of "later" elements and insert before + // the first one present. + OpenXmlElement? insertBefore = + workbook.GetFirstChild() as OpenXmlElement + ?? workbook.GetFirstChild() as OpenXmlElement + ?? workbook.GetFirstChild() as OpenXmlElement + ?? workbook.GetFirstChild() as OpenXmlElement + ?? workbook.GetFirstChild() as OpenXmlElement + ?? workbook.GetFirstChild() as OpenXmlElement + ?? workbook.GetFirstChild() as OpenXmlElement + ?? workbook.GetFirstChild() as OpenXmlElement; + if (insertBefore != null) + workbook.InsertBefore(definedNames, insertBefore); + else + workbook.AppendChild(definedNames); + } + + // Skip if an identically-named entry already exists (idempotent). + foreach (var dn in definedNames.Elements()) + { + if (string.Equals(dn.Name?.Value, slicerName, StringComparison.Ordinal)) + return; + } + + definedNames.Append(new DefinedName { Name = slicerName, Text = "#N/A" }); + } + + private void RegisterSlicerListInWorksheet(WorksheetPart worksheetPart, string slicersPartRelId) + { + var worksheet = GetSheet(worksheetPart); + var extList = worksheet.GetFirstChild() + ?? worksheet.AppendChild(new WorksheetExtensionList()); + + var ext = extList.Elements() + .FirstOrDefault(e => e.Uri?.Value == SlicerListExtUri); + X14.SlicerList list; + if (ext == null) + { + ext = new WorksheetExtension { Uri = SlicerListExtUri }; + ext.AddNamespaceDeclaration("x14", X14NsUri); + list = new X14.SlicerList(); + ext.Append(list); + extList.Append(ext); + } + else + { + list = ext.GetFirstChild() + ?? ext.AppendChild(new X14.SlicerList()); + } + + list.Append(new X14.SlicerRef { Id = slicersPartRelId }); + } + + // ==================== Drawing anchor ==================== + + private void AddSlicerDrawingAnchor( + WorksheetPart worksheetPart, string slicerName, Dictionary properties) + { + var worksheet = GetSheet(worksheetPart); + var drawingsPart = worksheetPart.DrawingsPart ?? worksheetPart.AddNewPart(); + if (drawingsPart.WorksheetDrawing == null) + { + // Declare xmlns:a on the wsDr root so individual a:* elements + // don't have to redeclare it per-element. Matches the format + // Excel produces and avoids a theoretical renderer quirk where + // scattered a: declarations might confuse the slicer pipeline. + drawingsPart.WorksheetDrawing = new XDR.WorksheetDrawing(); + drawingsPart.WorksheetDrawing.AddNamespaceDeclaration( + "a", "http://schemas.openxmlformats.org/drawingml/2006/main"); + drawingsPart.WorksheetDrawing.Save(); + if (worksheet.GetFirstChild() == null) + { + var drawingRelId = worksheetPart.GetIdOfPart(drawingsPart); + worksheet.Append( + new DocumentFormat.OpenXml.Spreadsheet.Drawing { Id = drawingRelId }); + } + } + + // Position: column/row indices like other Excel drawings. Default + // anchor sits to the right of column D so a pivot at column A–B is + // not covered. Width=3 cols × height=10 rows is Excel's rough + // default slicer footprint. + int fromCol, fromRow, toCol, toRow; + // CONSISTENCY(ole-width-units): accept `anchor=B2:F7` as a cell + // range (same grammar as shape/picture/chart/OLE), alongside the + // legacy x/y/width/height form. When both are supplied, warn and + // let anchor= win. + if (properties.TryGetValue("anchor", out var slAnchorStr) && !string.IsNullOrWhiteSpace(slAnchorStr)) + { + if (properties.ContainsKey("width") || properties.ContainsKey("height") + || properties.ContainsKey("x") || properties.ContainsKey("y")) + Console.Error.WriteLine( + "Warning: 'x'/'y'/'width'/'height' are ignored when 'anchor' is provided (anchor defines the full rectangle)."); + if (!TryParseCellRangeAnchor(slAnchorStr, out var sxFrom, out var syFrom, out var sxTo, out var syTo)) + throw new ArgumentException($"Invalid anchor: '{slAnchorStr}'. Expected e.g. 'B2' or 'B2:F7'."); + fromCol = sxFrom; + fromRow = syFrom; + if (sxTo < 0) { sxTo = fromCol + 3; syTo = fromRow + 10; } + toCol = sxTo; + toRow = syTo; + } + else + { + fromCol = properties.TryGetValue("x", out var xStr) + ? ParseHelpers.SafeParseInt(xStr, "x") : 5; + fromRow = properties.TryGetValue("y", out var yStr) + ? ParseHelpers.SafeParseInt(yStr, "y") : 1; + toCol = properties.TryGetValue("width", out var wStr) + ? fromCol + ParseHelpers.SafeParseInt(wStr, "width") : fromCol + 3; + toRow = properties.TryGetValue("height", out var hStr) + ? fromRow + ParseHelpers.SafeParseInt(hStr, "height") : fromRow + 10; + } + + // Reference Excel files use editAs="oneCell" for slicers (they + // resize with the top-left cell but don't stretch). Absolute + // positioning is valid but differs from what Excel writes. + var anchor = new XDR.TwoCellAnchor { EditAs = XDR.EditAsValues.OneCell }; + anchor.Append(new XDR.FromMarker( + new XDR.ColumnId(fromCol.ToString()), + new XDR.ColumnOffset("0"), + new XDR.RowId(fromRow.ToString()), + new XDR.RowOffset("0"))); + anchor.Append(new XDR.ToMarker( + new XDR.ColumnId(toCol.ToString()), + new XDR.ColumnOffset("0"), + new XDR.RowId(toRow.ToString()), + new XDR.RowOffset("0"))); + + // mc:AlternateContent lets older Excel clients render a fallback + // rectangle while newer clients use the sle:slicer shape. Pivot- + // backed slicer drawings require Choice Requires="a14" (Office + // 2010 main) — Excel silently drops the drawing if a15 is used. + // Namespace placement matches Excel reference files: `mc` on + // AlternateContent, `a14` on Choice. + var altContent = new AlternateContent(); + altContent.AddNamespaceDeclaration("mc", McNsUri); + + var choice = new AlternateContentChoice { Requires = "a14" }; + choice.AddNamespaceDeclaration("a14", A14NsUri); + var graphicFrame = new XDR.GraphicFrame { Macro = string.Empty }; + + // Allocate two unique cNvPr ids per slicer — one for the Choice + // GraphicFrame (the one modern Excel actually renders) and one + // for the Fallback Shape. + // + // Historical note: earlier code matched the reference-file + // convention of `id="0" name=""` in the Fallback. That assumption + // turned out to be WRONG in practice: Excel 2019+ on macOS runs + // a drawing-wide ID-uniqueness integrity check at load time and + // trips on duplicate `id="0"` whenever a sheet has ≥ 2 slicers + // — the whole file pops the "We found a problem" repair dialog + // even though the fallback shape itself is never rendered by + // modern clients. The OOXML validator (SDK 3.x) also flagged it + // as Sem_UniqueAttributeValue. Giving each Fallback shape its + // own fresh id fixes both. + // + // The Max() scan includes Descendants of AlternateContentFallback, + // so after adding slicer N, slicer N+1 sees the updated max and + // keeps the monotonic allocation going. + var nextId = drawingsPart.WorksheetDrawing + .Descendants() + .Select(p => (uint?)p.Id?.Value ?? 0u) + .DefaultIfEmpty(1u) + .Max() + 1; + var fallbackId = nextId + 1; + + graphicFrame.NonVisualGraphicFrameProperties = new XDR.NonVisualGraphicFrameProperties( + new XDR.NonVisualDrawingProperties { Id = nextId, Name = slicerName }, + new XDR.NonVisualGraphicFrameDrawingProperties()); + graphicFrame.Transform = new XDR.Transform( + new A.Offset { X = 0L, Y = 0L }, + new A.Extents { Cx = 0L, Cy = 0L }); + + var graphic = new A.Graphic(); + var graphicData = new A.GraphicData { Uri = SlicerDrawingNsUri }; + var sleSlicer = new Sle.Slicer { Name = slicerName }; + sleSlicer.AddNamespaceDeclaration("sle", SlicerDrawingNsUri); + graphicData.Append(sleSlicer); + graphic.Append(graphicData); + + graphicFrame.Append(graphic); + choice.Append(graphicFrame); + + var fallback = new AlternateContentFallback(); + fallback.Append(BuildSlicerFallbackShape(fallbackId, slicerName)); + + altContent.Append(choice); + altContent.Append(fallback); + + anchor.Append(altContent); + anchor.Append(new XDR.ClientData()); + + drawingsPart.WorksheetDrawing.Append(anchor); + drawingsPart.WorksheetDrawing.Save(); + } + + private static XDR.Shape BuildSlicerFallbackShape(uint id, string slicerName) + { + var shape = new XDR.Shape { Macro = string.Empty, TextLink = string.Empty }; + + var nvSp = new XDR.NonVisualShapeProperties(); + // The Fallback shape gets its own drawing-unique id even though + // modern Excel never renders it — the load-time integrity check + // walks AlternateContent/Fallback descendants too. See the + // allocation comment at the Choice branch above for the full + // rationale. `name` reuses the slicer name so the validator's + // "empty name" heuristic also stays quiet; it has no visual + // effect because the shape is schematic-only. + nvSp.Append(new XDR.NonVisualDrawingProperties { Id = id, Name = slicerName }); + var nvSpDraw = new XDR.NonVisualShapeDrawingProperties(); + nvSpDraw.Append(new A.ShapeLocks { NoTextEdit = true }); + nvSp.Append(nvSpDraw); + + var sp = new XDR.ShapeProperties(); + var xfm = new A.Transform2D(); + xfm.Append(new A.Offset { X = 0L, Y = 0L }); + xfm.Append(new A.Extents { Cx = 1828800L, Cy = 2381250L }); + sp.Append(xfm); + var geom = new A.PresetGeometry { Preset = A.ShapeTypeValues.Rectangle }; + geom.Append(new A.AdjustValueList()); + sp.Append(geom); + var fill = new A.SolidFill(); + fill.Append(new A.PresetColor { Val = A.PresetColorValues.White }); + sp.Append(fill); + var outline = new A.Outline { Width = 1 }; + var outlineFill = new A.SolidFill(); + outlineFill.Append(new A.PresetColor { Val = A.PresetColorValues.Gray }); + outline.Append(outlineFill); + sp.Append(outline); + + var tb = new XDR.TextBody(); + tb.Append(new A.BodyProperties + { + VerticalOverflow = A.TextVerticalOverflowValues.Clip, + HorizontalOverflow = A.TextHorizontalOverflowValues.Clip + }); + tb.Append(new A.ListStyle()); + var para = new A.Paragraph(); + var run = new A.Run(); + run.Append(new A.RunProperties { FontSize = 1100 }); + run.Append(new A.Text { Text = "Slicer (requires Excel 2010 or later)" }); + para.Append(run); + tb.Append(para); + + shape.Append(nvSp); + shape.Append(sp); + shape.Append(tb); + return shape; + } + + // ==================== Name / uniqueness helpers ==================== + + private static string SanitizeSlicerName(string name) + { + // Slicer names must be valid Excel defined-name-ish tokens: trim + // whitespace and replace spaces with underscores so the x14:name + // attribute passes Excel's length+character constraints. + name = name.Trim().Replace(' ', '_'); + if (string.IsNullOrEmpty(name)) + throw new ArgumentException("slicer name cannot be empty"); + return name; + } + + private static string MakeUnique(string baseName, HashSet existing) + { + if (!existing.Contains(baseName)) + { + existing.Add(baseName); + return baseName; + } + for (int i = 2; ; i++) + { + var candidate = $"{baseName}{i}"; + if (!existing.Contains(candidate)) + { + existing.Add(candidate); + return candidate; + } + } + } + + private HashSet CollectExistingSlicerNames() + { + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + var workbookPart = _doc.WorkbookPart; + if (workbookPart == null) return names; + foreach (var wsp in workbookPart.WorksheetParts) + { + foreach (var sp in wsp.GetPartsOfType()) + { + if (sp.Slicers == null) continue; + foreach (var sl in sp.Slicers.Elements()) + if (!string.IsNullOrEmpty(sl.Name?.Value)) + names.Add(sl.Name!.Value!); + } + } + return names; + } + + private HashSet CollectExistingSlicerCacheNames() + { + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + var workbookPart = _doc.WorkbookPart; + if (workbookPart == null) return names; + foreach (var scp in workbookPart.GetPartsOfType()) + { + var def = scp.SlicerCacheDefinition; + if (def?.Name?.Value is { } n) names.Add(n); + } + return names; + } + + // ==================== Readback ==================== + + /// + /// Locate a slicer by 1-based index on a sheet and resolve its backing + /// cache definition. Returns false if the sheet has fewer slicers. + /// + internal bool TryFindSlicerByIndex( + WorksheetPart worksheetPart, int index, + out X14.Slicer? slicer, out X14.SlicerCacheDefinition? cacheDef) + { + slicer = null; + cacheDef = null; + var slicersPart = worksheetPart.GetPartsOfType().FirstOrDefault(); + if (slicersPart?.Slicers == null) return false; + var list = slicersPart.Slicers.Elements().ToList(); + if (index < 1 || index > list.Count) return false; + slicer = list[index - 1]; + // Resolve the backing cache by matching Slicer.Cache → SlicerCacheDefinition.Name + var workbookPart = _doc.WorkbookPart; + if (workbookPart != null && slicer.Cache?.Value is { } cacheName) + { + foreach (var scp in workbookPart.GetPartsOfType()) + { + if (scp.SlicerCacheDefinition?.Name?.Value == cacheName) + { + cacheDef = scp.SlicerCacheDefinition; + break; + } + } + } + return true; + } + + internal static void ReadSlicerProperties( + X14.Slicer slicer, X14.SlicerCacheDefinition? cacheDef, DocumentNode node) + { + if (slicer.Name?.Value is { } name) node.Format["name"] = name; + if (slicer.Cache?.Value is { } cache) node.Format["cache"] = cache; + if (slicer.Caption?.Value is { } cap) node.Format["caption"] = cap; + if (slicer.RowHeight?.HasValue == true) node.Format["rowHeight"] = slicer.RowHeight.Value; + if (slicer.ColumnCount?.HasValue == true) node.Format["columnCount"] = slicer.ColumnCount.Value; + if (slicer.Style?.Value is { } style) node.Format["style"] = style; + + if (cacheDef?.SourceName?.Value is { } src) node.Format["field"] = src; + var pivotTable = cacheDef?.SlicerCachePivotTables? + .Elements().FirstOrDefault(); + if (pivotTable?.Name?.Value is { } pt) node.Format["pivotTableName"] = pt; + var tabular = cacheDef?.SlicerCacheData?.GetFirstChild(); + if (tabular?.PivotCacheId?.HasValue == true) + node.Format["pivotCacheId"] = tabular.PivotCacheId.Value; + if (tabular?.TabularSlicerCacheItems != null) + node.Format["itemCount"] = tabular.TabularSlicerCacheItems + .Elements().Count(); + } +} diff --git a/src/officecli/Handlers/Excel/ExcelHandler.View.cs b/src/officecli/Handlers/Excel/ExcelHandler.View.cs index 3de39c924..754469d15 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,12 +146,42 @@ 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)" : ""; + + int oleCount = CountSheetOleObjects(worksheetPart); + var oleInfo = oleCount > 0 ? $", {oleCount} ole object(s)" : ""; + + sb.AppendLine($"\u251c\u2500\u2500 \"{name}\" ({rowCount} rows \u00d7 {colCount} cols{formulaInfo}{pivotInfo}{oleInfo})"); } return sb.ToString().TrimEnd(); } + // CONSISTENCY(ole-stats): per-sheet OLE counter shared by outline and + // outlineJson. Same dedup rule as ViewAsStats — referenced oleObject + // elements count once, orphan embedded/package parts add extras. + private int CountSheetOleObjects(WorksheetPart worksheetPart) + { + int count = 0; + var referenced = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var oleEl in GetSheet(worksheetPart).Descendants()) + { + count++; + if (oleEl.Id?.Value is string rid && !string.IsNullOrEmpty(rid)) + referenced.Add(rid); + } + count += worksheetPart.EmbeddedObjectParts.Count(p => !referenced.Contains(worksheetPart.GetIdOfPart(p))); + count += worksheetPart.EmbeddedPackageParts.Count(p => !referenced.Contains(worksheetPart.GetIdOfPart(p))); + return count; + } + public string ViewAsStats() { var sb = new StringBuilder(); @@ -182,11 +213,29 @@ public string ViewAsStats() } } + // OLE object count across all sheets. Same dedup rule as + // CollectOleNodesForSheet: referenced parts count as one entry + // (via their oleObject element), orphan parts add extras. + int oleCount = 0; + foreach (var (_, worksheetPart) in sheets) + { + var referenced = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var oleEl in GetSheet(worksheetPart).Descendants()) + { + oleCount++; + if (oleEl.Id?.Value is string rid && !string.IsNullOrEmpty(rid)) + referenced.Add(rid); + } + oleCount += worksheetPart.EmbeddedObjectParts.Count(p => !referenced.Contains(worksheetPart.GetIdOfPart(p))); + oleCount += worksheetPart.EmbeddedPackageParts.Count(p => !referenced.Contains(worksheetPart.GetIdOfPart(p))); + } + sb.AppendLine($"Sheets: {sheets.Count}"); sb.AppendLine($"Total Cells: {totalCells}"); sb.AppendLine($"Empty Cells: {emptyCells}"); sb.AppendLine($"Formula Cells: {formulaCells}"); sb.AppendLine($"Error Cells: {errorCells}"); + if (oleCount > 0) sb.AppendLine($"OLE Objects: {oleCount}"); sb.AppendLine(); sb.AppendLine("Data Type Distribution:"); foreach (var (type, count) in typeCounts.OrderByDescending(kv => kv.Value)) @@ -219,13 +268,28 @@ public JsonNode ViewAsStatsJson() } } + int oleCountJson = 0; + foreach (var (_, worksheetPart) in sheets) + { + var refSet = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var oleEl in GetSheet(worksheetPart).Descendants()) + { + oleCountJson++; + if (oleEl.Id?.Value is string rid && !string.IsNullOrEmpty(rid)) + refSet.Add(rid); + } + oleCountJson += worksheetPart.EmbeddedObjectParts.Count(p => !refSet.Contains(worksheetPart.GetIdOfPart(p))); + oleCountJson += worksheetPart.EmbeddedPackageParts.Count(p => !refSet.Contains(worksheetPart.GetIdOfPart(p))); + } + var result = new JsonObject { ["sheets"] = sheets.Count, ["totalCells"] = totalCells, ["emptyCells"] = emptyCells, ["formulaCells"] = formulaCells, - ["errorCells"] = errorCells + ["errorCells"] = errorCells, + ["oleObjects"] = oleCountJson, }; var types = new JsonObject(); @@ -258,12 +322,14 @@ public JsonNode ViewAsOutlineJson() int colCount = GetSheetColumnCount(worksheet, sheetData); int formulaCount = sheetData?.Descendants().Count() ?? 0; + int oleCount = CountSheetOleObjects(worksheetPart); var sheetObj = new JsonObject { ["name"] = name, ["rows"] = rowCount, ["cols"] = colCount, - ["formulas"] = formulaCount + ["formulas"] = formulaCount, + ["oleObjects"] = oleCount }; sheetsArray.Add((JsonNode)sheetObj); } @@ -398,6 +464,21 @@ public List ViewAsIssues(string? issueType = null, int? limit = n } } + // CONSISTENCY(text-overflow-check): merged in from former `check` command. + // Emits wrapText-cells whose visible row-height budget can't fit the wrapped text. + foreach (var (path, msg) in CheckAllCellOverflow()) + { + if (limit.HasValue && issues.Count >= limit.Value) break; + issues.Add(new DocumentIssue + { + Id = $"O{++issueNum}", + Type = IssueType.Format, + Severity = IssueSeverity.Warning, + Path = path, + Message = msg + }); + } + return issues; } } diff --git a/src/officecli/Handlers/ExcelHandler.cs b/src/officecli/Handlers/ExcelHandler.cs index 892d0e2c7..01e141fec 100644 --- a/src/officecli/Handlers/ExcelHandler.cs +++ b/src/officecli/Handlers/ExcelHandler.cs @@ -1,7 +1,6 @@ // Copyright 2025 OfficeCli (officecli.ai) // SPDX-License-Identifier: Apache-2.0 -using System.Text; using System.Text.RegularExpressions; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; @@ -15,6 +14,14 @@ public partial class ExcelHandler : IDocumentHandler private readonly SpreadsheetDocument _doc; private readonly string _filePath; private readonly HashSet _initialSheetNames; + private readonly HashSet _dirtyWorksheets = new(); + private bool _dirtyStylesheet; + private bool _disposed; + // Row index cache: SheetData → sorted map of rowIndex → Row. + // Turns the O(n) linear scan in FindOrCreateCell into O(1) lookup + O(log n) insert. + // Invalidated by InvalidateRowIndex() whenever rows are structurally modified (shift, remove). + private Dictionary>? _rowIndex; + public int LastFindMatchCount { get; internal set; } public ExcelHandler(string filePath, bool editable) { @@ -103,7 +110,47 @@ public string Raw(string partPath, int? startRow = null, int? endRow = null, Has return GetSheet(worksheet).OuterXml; } - throw new ArgumentException($"Unknown part: {partPath}. Available: /workbook, /styles, /sharedstrings, /, //drawing, //chart[N], /chart[N]"); + // /SheetName/ fallback — resolve a worksheet relationship by id + // (covers OLE embed parts, image parts, etc. that have no named path). + // Open XML SDK generates relIds like "rId12" or "Rff3244f593f8481a"; + // accept both forms (any non-slash token starting with R/r). + var relIdMatch = Regex.Match(partPath, @"^/([^/]+)/([Rr][A-Za-z0-9]+)$"); + if (relIdMatch.Success) + { + var relSheetName = relIdMatch.Groups[1].Value; + var relId = relIdMatch.Groups[2].Value; + var relWs = FindWorksheet(relSheetName) + ?? throw SheetNotFoundException(relSheetName); + try + { + var part = relWs.GetPartById(relId); + if (part != null) + { + var ct = part.ContentType ?? ""; + bool isText = ct.Contains("xml", StringComparison.OrdinalIgnoreCase) + || ct.StartsWith("text/", StringComparison.OrdinalIgnoreCase); + using var partStream = part.GetStream(); + if (isText) + { + using var reader = new StreamReader(partStream); + return reader.ReadToEnd(); + } + long size = 0; + try { size = partStream.Length; } catch { /* non-seekable */ } + return $"(binary part: {ct}, {size} bytes)"; + } + } + catch (KeyNotFoundException) + { + // fall through to the unknown-part error + } + catch (ArgumentException) + { + // fall through to the unknown-part error + } + } + + throw new ArgumentException($"Unknown part: {partPath}. Available: /workbook, /styles, /sharedstrings, /, //drawing, //chart[N], /chart[N], //"); } private static string RawSheetWithFilter(WorksheetPart worksheetPart, int? startRow, int? endRow, HashSet? cols) @@ -220,6 +267,12 @@ public void RawSet(string partPath, string xpath, string action, string? xml) public List Validate() => RawXmlHelper.ValidateDocument(_doc); - public void Dispose() => _doc.Dispose(); + public void Dispose() + { + if (_disposed) return; + _disposed = true; + try { FlushDirtyParts(); } + finally { _doc.Dispose(); } + } } diff --git a/src/officecli/Handlers/PowerPointHandler.cs b/src/officecli/Handlers/PowerPointHandler.cs index e6c8426c9..2266d0f13 100644 --- a/src/officecli/Handlers/PowerPointHandler.cs +++ b/src/officecli/Handlers/PowerPointHandler.cs @@ -1,14 +1,11 @@ // Copyright 2025 OfficeCli (officecli.ai) // SPDX-License-Identifier: Apache-2.0 -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; -using M = DocumentFormat.OpenXml.Math; namespace OfficeCli.Handlers; @@ -16,11 +13,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(); } /// @@ -32,454 +34,6 @@ public PowerPointHandler(string filePath, bool editable) return (sldSz?.Cx?.Value ?? 12192000L, sldSz?.Cy?.Value ?? 6858000L); } - private (SlidePart slidePart, Shape shape) ResolveShape(int slideIdx, int shapeIdx) - { - 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 - ?? throw new ArgumentException($"Slide {slideIdx} has no shapes"); - - var shapes = shapeTree.Elements().ToList(); - if (shapeIdx < 1 || shapeIdx > shapes.Count) - throw new ArgumentException($"Shape {shapeIdx} not found"); - - return (slidePart, shapes[shapeIdx - 1]); - } - - private (SlidePart slidePart, GraphicFrame gf, ChartPart? chartPart) ResolveChart(int slideIdx, int chartIdx) - { - 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 - ?? throw new ArgumentException($"Slide {slideIdx} has no shapes"); - - var chartFrames = shapeTree.Elements() - .Where(gf => gf.Descendants().Any() - || IsExtendedChartFrame(gf)) - .ToList(); - if (chartIdx < 1 || chartIdx > chartFrames.Count) - throw new ArgumentException($"Chart {chartIdx} not found (total: {chartFrames.Count})"); - - var gf = chartFrames[chartIdx - 1]; - var chartRef = gf.Descendants().FirstOrDefault(); - ChartPart? chartPart = null; - if (chartRef?.Id?.Value != null) - chartPart = (ChartPart)slidePart.GetPartById(chartRef.Id.Value); - return (slidePart, gf, chartPart); - } - - private (SlidePart slidePart, Drawing.Table table) ResolveTable(int slideIdx, int tblIdx) - { - 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 - ?? throw new ArgumentException($"Slide {slideIdx} has no shapes"); - - var tables = shapeTree.Elements() - .Select(gf => gf.Descendants().FirstOrDefault()) - .Where(t => t != null).ToList(); - if (tblIdx < 1 || tblIdx > tables.Count) - throw new ArgumentException($"Table {tblIdx} not found (total: {tables.Count})"); - - return (slidePart, tables[tblIdx - 1]!); - } - - /// - /// Resolve a logical PPT path (e.g. /slide[1]/table[1]/tr[2]) to the actual OpenXML element. - /// Returns null if the path doesn't contain logical segments that need resolving. - /// - private (SlidePart slidePart, OpenXmlElement element)? ResolveLogicalPath(string path) - { - // /slide[N]/table[M]... - var tblPathMatch = Regex.Match(path, @"^/slide\[(\d+)\]/table\[(\d+)\](.*)$"); - if (tblPathMatch.Success) - { - var slideIdx = int.Parse(tblPathMatch.Groups[1].Value); - var tblIdx = int.Parse(tblPathMatch.Groups[2].Value); - var rest = tblPathMatch.Groups[3].Value; // e.g. /tr[1]/tc[2]/txBody - - var (slidePart, table) = ResolveTable(slideIdx, tblIdx); - OpenXmlElement current = table; - - if (!string.IsNullOrEmpty(rest)) - { - var segments = GenericXmlQuery.ParsePathSegments(rest); - var target = GenericXmlQuery.NavigateByPath(current, segments); - if (target != null) current = target; - else throw new ArgumentException($"Element not found: {path}. Resolved table[{tblIdx}] on slide[{slideIdx}] but sub-path '{rest}' does not exist. Available children: {DescribeChildren(current)}"); - } - return (slidePart, current); - } - - // /slide[N]/placeholder[X]... - var phPathMatch = Regex.Match(path, @"^/slide\[(\d+)\]/placeholder\[(\w+)\](.*)$"); - if (phPathMatch.Success) - { - var slideIdx = int.Parse(phPathMatch.Groups[1].Value); - var phId = phPathMatch.Groups[2].Value; - var rest = phPathMatch.Groups[3].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]; - OpenXmlElement current = ResolvePlaceholderShape(slidePart, phId); - - if (!string.IsNullOrEmpty(rest)) - { - var segments = GenericXmlQuery.ParsePathSegments(rest); - var target = GenericXmlQuery.NavigateByPath(current, segments); - if (target != null) current = target; - else throw new ArgumentException($"Element not found: {path}. Resolved placeholder[{phId}] on slide[{slideIdx}] but sub-path '{rest}' does not exist. Available children: {DescribeChildren(current)}"); - } - return (slidePart, current); - } - - return null; - } - - /// Summarize child element types for error messages. - private static string DescribeChildren(OpenXmlElement parent) - { - var groups = parent.ChildElements - .GroupBy(e => e.LocalName) - .Select(g => g.Count() > 1 ? $"{g.Key}[1..{g.Count()}]" : g.Key) - .Take(10) - .ToList(); - return groups.Count > 0 ? string.Join(", ", groups) : "(empty)"; - } - - /// Summarize slide contents for error messages (e.g. "3 shapes, 1 table, 2 pictures"). - private static string DescribeSlideInventory(ShapeTree? shapeTree) - { - if (shapeTree == null) return "(empty slide)"; - var parts = new List(); - var shapes = shapeTree.Elements().Count(); - var tables = shapeTree.Elements().Count(gf => gf.Descendants().Any()); - var charts = shapeTree.Elements().Count(gf => gf.Descendants().Any()); - var pics = shapeTree.Elements().Count(); - var connectors = shapeTree.Elements().Count(); - var groups = shapeTree.Elements().Count(); - if (shapes > 0) parts.Add($"{shapes} shape(s)"); - if (tables > 0) parts.Add($"{tables} table(s)"); - if (charts > 0) parts.Add($"{charts} chart(s)"); - if (pics > 0) parts.Add($"{pics} picture(s)"); - if (connectors > 0) parts.Add($"{connectors} connector(s)"); - if (groups > 0) parts.Add($"{groups} group(s)"); - return parts.Count > 0 ? string.Join(", ", parts) : "(empty slide)"; - } - - private static PlaceholderValues? ParsePlaceholderType(string name) - { - return name.ToLowerInvariant() switch - { - "title" => PlaceholderValues.Title, - "centertitle" or "centeredtitle" or "ctitle" => PlaceholderValues.CenteredTitle, - "body" or "content" => PlaceholderValues.Body, - "subtitle" or "sub" => PlaceholderValues.SubTitle, - "date" or "datetime" or "dt" => PlaceholderValues.DateAndTime, - "footer" => PlaceholderValues.Footer, - "slidenum" or "slidenumber" or "sldnum" => PlaceholderValues.SlideNumber, - "object" or "obj" => PlaceholderValues.Object, - "chart" => PlaceholderValues.Chart, - "table" => PlaceholderValues.Table, - "clipart" => PlaceholderValues.ClipArt, - "diagram" or "dgm" => PlaceholderValues.Diagram, - "media" => PlaceholderValues.Media, - "picture" or "pic" => PlaceholderValues.Picture, - "header" => PlaceholderValues.Header, - _ => null - }; - } - - private Shape ResolvePlaceholderShape(SlidePart slidePart, string phId) - { - var shapeTree = GetSlide(slidePart).CommonSlideData?.ShapeTree - ?? throw new ArgumentException("Slide has no shape tree"); - - // Try numeric index first - if (int.TryParse(phId, out var numIdx)) - { - // Match by placeholder index - var byIndex = shapeTree.Elements() - .FirstOrDefault(s => - { - var ph = s.NonVisualShapeProperties?.ApplicationNonVisualDrawingProperties - ?.GetFirstChild(); - return ph?.Index?.Value == (uint)numIdx; - }); - if (byIndex != null) return byIndex; - - // Also try as 1-based ordinal of all placeholders - var allPh = shapeTree.Elements() - .Where(s => s.NonVisualShapeProperties?.ApplicationNonVisualDrawingProperties - ?.GetFirstChild() != null).ToList(); - if (numIdx >= 1 && numIdx <= allPh.Count) - return allPh[numIdx - 1]; - - throw new ArgumentException($"Placeholder index {numIdx} not found"); - } - - // Try by type name - var phType = ParsePlaceholderType(phId) - ?? throw new ArgumentException($"Unknown placeholder type: '{phId}'. " + - "Known types: title, body, subtitle, date, footer, slidenum, object, picture, centerTitle"); - - var byType = shapeTree.Elements() - .FirstOrDefault(s => - { - var ph = s.NonVisualShapeProperties?.ApplicationNonVisualDrawingProperties - ?.GetFirstChild(); - return ph?.Type?.Value == phType; - }); - - if (byType != null) return byType; - - // Check layout for inherited placeholders and create one on the slide - var layoutPart = slidePart.SlideLayoutPart; - if (layoutPart?.SlideLayout?.CommonSlideData?.ShapeTree != null) - { - var layoutShape = layoutPart.SlideLayout.CommonSlideData.ShapeTree.Elements() - .FirstOrDefault(s => - { - var ph = s.NonVisualShapeProperties?.ApplicationNonVisualDrawingProperties - ?.GetFirstChild(); - return ph?.Type?.Value == phType; - }); - - if (layoutShape != null) - { - // Clone from layout and add to slide - var newShape = (Shape)layoutShape.CloneNode(true); - // Clear any text content from layout placeholder - if (newShape.TextBody != null) - { - newShape.TextBody.RemoveAllChildren(); - newShape.TextBody.Append(new Drawing.Paragraph( - new Drawing.EndParagraphRunProperties { Language = "en-US" })); - } - shapeTree.AppendChild(newShape); - return newShape; - } - } - - throw new ArgumentException($"Placeholder '{phId}' not found on slide or its layout"); - } - - private DocumentNode GetPlaceholderNode(SlidePart slidePart, int slideIdx, int phIdx, int depth) - { - var shapeTree = GetSlide(slidePart).CommonSlideData?.ShapeTree - ?? throw new ArgumentException("Slide has no shape tree"); - - // Get all placeholders on slide - var placeholders = shapeTree.Elements() - .Where(s => s.NonVisualShapeProperties?.ApplicationNonVisualDrawingProperties - ?.GetFirstChild() != null).ToList(); - - if (phIdx < 1 || phIdx > placeholders.Count) - throw new ArgumentException($"Placeholder {phIdx} not found (total: {placeholders.Count})"); - - var shape = placeholders[phIdx - 1]; - var ph = shape.NonVisualShapeProperties!.ApplicationNonVisualDrawingProperties! - .GetFirstChild()!; - - var node = ShapeToNode(shape, slideIdx, phIdx, depth); - node.Path = $"/slide[{slideIdx}]/placeholder[{phIdx}]"; - node.Type = "placeholder"; - if (ph.Type?.HasValue == true) node.Format["phType"] = ph.Type.InnerText; - if (ph.Index?.HasValue == true) node.Format["phIndex"] = ph.Index.Value; - return node; - } - // ==================== Media Timing Lookup ==================== - - /// - /// Find the CommonMediaNode in the timing tree for a given shape ID. - /// - private static CommonMediaNode? FindMediaTimingNode(SlidePart slidePart, uint shapeId) - { - var timing = GetSlide(slidePart).GetFirstChild(); - if (timing == null) return null; - - foreach (var mediaNode in timing.Descendants()) - { - var target = mediaNode.TargetElement?.GetFirstChild(); - if (target?.ShapeId?.Value == shapeId.ToString()) - return mediaNode; - } - return null; - } - - // ==================== Cleanup (POI-style reference counting) ==================== - - /// - /// Remove a Picture element with proper cleanup of relationships and media parts. - /// Follows Apache POI's pattern: reference-count blipIds, only delete parts when - /// no other shapes reference the same media. - /// - private static void RemovePictureWithCleanup(SlidePart slidePart, ShapeTree shapeTree, Picture pic) - { - // Collect all relationship IDs referenced by this picture - var relIdsToClean = new HashSet(); - - // BlipFill → Blip.Embed (poster/image) - var blipEmbed = pic.BlipFill?.GetFirstChild()?.Embed?.Value; - if (blipEmbed != null) relIdsToClean.Add(blipEmbed); - - // VideoFromFile.Link or AudioFromFile.Link - var nvPr = pic.NonVisualPictureProperties?.ApplicationNonVisualDrawingProperties; - var videoLink = nvPr?.GetFirstChild()?.Link?.Value; - if (videoLink != null) relIdsToClean.Add(videoLink); - var audioLink = nvPr?.GetFirstChild()?.Link?.Value; - if (audioLink != null) relIdsToClean.Add(audioLink); - - // p14:media.Embed (MediaReferenceRelationship) - var p14Media = nvPr?.Descendants().FirstOrDefault(); - var mediaEmbed = p14Media?.Embed?.Value; - if (mediaEmbed != null) relIdsToClean.Add(mediaEmbed); - - // Reference count: check all OTHER pictures on the same slide for shared relIds - var sharedRelIds = new HashSet(); - foreach (var otherPic in shapeTree.Elements()) - { - if (otherPic == pic) continue; // skip the one being removed - - var otherBlip = otherPic.BlipFill?.GetFirstChild()?.Embed?.Value; - if (otherBlip != null && relIdsToClean.Contains(otherBlip)) sharedRelIds.Add(otherBlip); - - var otherNvPr = otherPic.NonVisualPictureProperties?.ApplicationNonVisualDrawingProperties; - var otherVid = otherNvPr?.GetFirstChild()?.Link?.Value; - if (otherVid != null && relIdsToClean.Contains(otherVid)) sharedRelIds.Add(otherVid); - var otherAud = otherNvPr?.GetFirstChild()?.Link?.Value; - if (otherAud != null && relIdsToClean.Contains(otherAud)) sharedRelIds.Add(otherAud); - - var otherMedia = otherNvPr?.Descendants().FirstOrDefault()?.Embed?.Value; - if (otherMedia != null && relIdsToClean.Contains(otherMedia)) sharedRelIds.Add(otherMedia); - } - - // Remove the XML element first - pic.Remove(); - - // Clean up relationships that are no longer referenced - foreach (var relId in relIdsToClean) - { - if (sharedRelIds.Contains(relId)) continue; // still referenced by another shape - - try { slidePart.DeletePart(relId); } catch (ArgumentException) { } - // Also try removing data part relationships (video/audio/media) - try - { - foreach (var dpr in slidePart.DataPartReferenceRelationships.Where(r => r.Id == relId).ToList()) - slidePart.DeleteReferenceRelationship(dpr); - } - catch (ArgumentException) { } - } - } - - // ==================== Layout ==================== - - /// - /// Resolve a SlideLayoutPart by name, type, or index. - /// If layoutHint is null, returns the first layout. - /// Matching order: exact name → layout type → numeric index → first layout. - /// - private static SlideLayoutPart? ResolveSlideLayout(PresentationPart presentationPart, string? layoutHint) - { - var allLayouts = presentationPart.SlideMasterParts - .SelectMany(m => m.SlideLayoutParts).ToList(); - if (allLayouts.Count == 0) return null; - - if (string.IsNullOrEmpty(layoutHint)) - return allLayouts.FirstOrDefault(); - - // 1. Match by layout name (CommonSlideData.Name or SlideLayout.MatchingName) - var byName = allLayouts.FirstOrDefault(lp => - { - var sl = lp.SlideLayout; - var csdName = sl?.CommonSlideData?.Name?.Value; - var matchName = sl?.MatchingName?.Value; - return string.Equals(csdName, layoutHint, StringComparison.OrdinalIgnoreCase) - || string.Equals(matchName, layoutHint, StringComparison.OrdinalIgnoreCase); - }); - if (byName != null) return byName; - - // 2. Match by layout type keyword - var layoutType = layoutHint.ToLowerInvariant() switch - { - "title" => SlideLayoutValues.Title, - "titleonly" or "title_only" => SlideLayoutValues.TitleOnly, - "blank" => SlideLayoutValues.Blank, - "twocontent" or "two_content" or "twocol" => SlideLayoutValues.TwoColumnText, - "titlecontent" or "title_content" => SlideLayoutValues.ObjectText, - "section" or "sectionheader" => SlideLayoutValues.SectionHeader, - "comparison" => SlideLayoutValues.TwoTextAndTwoObjects, - "contentwithcaption" or "caption" => SlideLayoutValues.ObjectAndText, - "picturewithcaption" or "pictxt" => SlideLayoutValues.PictureText, - "custom" => SlideLayoutValues.Custom, - _ => (SlideLayoutValues?)null - }; - if (layoutType.HasValue) - { - var byType = allLayouts.FirstOrDefault(lp => - lp.SlideLayout?.Type?.HasValue == true && - lp.SlideLayout.Type.Value == layoutType.Value); - if (byType != null) return byType; - } - - // 3. Match by 1-based numeric index - if (int.TryParse(layoutHint, out var idx) && idx >= 1 && idx <= allLayouts.Count) - return allLayouts[idx - 1]; - - // 4. Fuzzy match: layout name contains the hint (case-insensitive) - var fuzzy = allLayouts.FirstOrDefault(lp => - { - var csdName = lp.SlideLayout?.CommonSlideData?.Name?.Value; - return csdName != null && csdName.Contains(layoutHint, StringComparison.OrdinalIgnoreCase); - }); - if (fuzzy != null) return fuzzy; - - throw new ArgumentException( - $"Layout '{layoutHint}' not found. Available layouts: " + - string.Join(", ", allLayouts.Select((lp, i) => - { - var name = lp.SlideLayout?.CommonSlideData?.Name?.Value ?? "(unnamed)"; - var type = lp.SlideLayout?.Type?.HasValue == true ? lp.SlideLayout.Type.InnerText : "?"; - return $"[{i + 1}] {name} ({type})"; - }))); - } - - /// - /// Get the layout name for a slide part. - /// Falls back to type name if no explicit name is set. - /// - private static string? GetSlideLayoutName(SlidePart slidePart) - { - var layoutPart = slidePart.SlideLayoutPart; - if (layoutPart?.SlideLayout == null) return null; - return layoutPart.SlideLayout.CommonSlideData?.Name?.Value - ?? layoutPart.SlideLayout.MatchingName?.Value - ?? (layoutPart.SlideLayout.Type?.HasValue == true - ? layoutPart.SlideLayout.Type.InnerText : null); - } - - /// - /// Get the layout type for a slide part. - /// - private static string? GetSlideLayoutType(SlidePart slidePart) - { - var layoutPart = slidePart.SlideLayoutPart; - if (layoutPart?.SlideLayout?.Type?.HasValue != true) return null; - return layoutPart.SlideLayout.Type.InnerText; - } - // ==================== Raw Layer ==================== public string Raw(string partPath, int? startRow = null, int? endRow = null, HashSet? cols = null) diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Media.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Media.cs index 9b0a63571..9e28f72c6 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Media.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Media.cs @@ -8,7 +8,6 @@ using OfficeCli.Core; using Drawing = DocumentFormat.OpenXml.Drawing; using C = DocumentFormat.OpenXml.Drawing.Charts; -using M = DocumentFormat.OpenXml.Math; namespace OfficeCli.Handlers; @@ -18,7 +17,7 @@ private string AddPicture(string parentPath, int? index, Dictionary 0, Height: > 0 } d) + { + double ratio = (double)d.Height / d.Width; + if (hasWidth && !hasHeight) + cyEmu = (long)(cxEmu * ratio); + else if (!hasWidth && hasHeight) + cxEmu = (long)(cyEmu / ratio); + else // neither supplied — default width, compute height + cyEmu = (long)(cxEmu * ratio); + } + } // Position (default: centered on slide) var (slideW, slideH) = GetSlideSize(); @@ -59,8 +109,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 @@ -76,7 +126,176 @@ private string AddPicture(string parentPath, int? index, Dictionary 100) + throw new ArgumentException($"Invalid 'crop' value: '{s.Trim()}'. Crop percentage must be between 0 and 100."); + return v; + } + if (parts.Length == 4) + { + cropL = (int)(Parse1(parts[0]) * 1000); + cropT = (int)(Parse1(parts[1]) * 1000); + cropR = (int)(Parse1(parts[2]) * 1000); + cropB = (int)(Parse1(parts[3]) * 1000); + } + else if (parts.Length == 2) + { + var v = (int)(Parse1(parts[0]) * 1000); + var h = (int)(Parse1(parts[1]) * 1000); + cropT = v; cropB = v; cropL = h; cropR = h; + } + else if (parts.Length == 1) + { + var p = (int)(Parse1(parts[0]) * 1000); + cropL = p; cropT = p; cropR = p; cropB = p; + } + else + { + throw new ArgumentException($"Invalid 'crop' value: '{cropAll}'. Expected 1, 2, or 4 comma-separated percentages."); + } + } + int? SidePct(string k) + { + if (!properties.TryGetValue(k, out var v)) return null; + if (!double.TryParse(v, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var d)) + throw new ArgumentException($"Invalid '{k}' value: '{v}'. Expected a percentage (0-100)."); + if (d < 0 || d > 100) + throw new ArgumentException($"Invalid '{k}' value: '{v}'. Crop percentage must be between 0 and 100."); + return (int)(d * 1000); + } + cropL = SidePct("cropleft") ?? cropL; + cropT = SidePct("croptop") ?? cropT; + cropR = SidePct("cropright") ?? cropR; + cropB = SidePct("cropbottom") ?? cropB; + var hasCrop = cropL is not null || cropT is not null || cropR is not null || cropB is not null; + var anyNonZero = (cropL ?? 0) != 0 || (cropT ?? 0) != 0 || (cropR ?? 0) != 0 || (cropB ?? 0) != 0; + if (hasCrop && anyNonZero) + { + var srcRect = new Drawing.SourceRectangle(); + if (cropL is not null) srcRect.Left = cropL; + if (cropT is not null) srcRect.Top = cropT; + if (cropR is not null) srcRect.Right = cropR; + if (cropB is not null) srcRect.Bottom = cropB; + picture.BlipFill.AppendChild(srcRect); // stretch not yet appended + } + // Fill mode: stretch (default) | contain (letterbox) | + // cover (crop) | tile. stretch preserves the historical + // emission so existing + // docs stay byte-identical. contain/cover require image and + // container dimensions; if either is unknown, we fall back + // to a bare stretch. + var fillMode = (properties.GetValueOrDefault("fill", "stretch") ?? "stretch") + .Trim().ToLowerInvariant(); + if (fillMode == "tile") + { + double tileScale = 1.0; + if (properties.TryGetValue("tilescale", out var tsStr) + && double.TryParse(tsStr, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var ts) && ts > 0) + tileScale = ts; + var tile = new Drawing.Tile + { + HorizontalRatio = (int)(tileScale * 100000), + VerticalRatio = (int)(tileScale * 100000), + Flip = Drawing.TileFlipValues.None, + Alignment = Drawing.RectangleAlignmentValues.TopLeft, + }; + if (properties.TryGetValue("tilealign", out var taStr)) + { + tile.Alignment = taStr.Trim().ToLowerInvariant() switch + { + "tl" or "topleft" => Drawing.RectangleAlignmentValues.TopLeft, + "t" or "top" => Drawing.RectangleAlignmentValues.Top, + "tr" or "topright" => Drawing.RectangleAlignmentValues.TopRight, + "l" or "left" => Drawing.RectangleAlignmentValues.Left, + "ctr" or "center" or "centre" => Drawing.RectangleAlignmentValues.Center, + "r" or "right" => Drawing.RectangleAlignmentValues.Right, + "bl" or "bottomleft" => Drawing.RectangleAlignmentValues.BottomLeft, + "b" or "bottom" => Drawing.RectangleAlignmentValues.Bottom, + "br" or "bottomright" => Drawing.RectangleAlignmentValues.BottomRight, + _ => Drawing.RectangleAlignmentValues.TopLeft, + }; + } + if (properties.TryGetValue("tileflip", out var tfStr)) + { + tile.Flip = tfStr.Trim().ToLowerInvariant() switch + { + "none" => Drawing.TileFlipValues.None, + "x" => Drawing.TileFlipValues.Horizontal, + "y" => Drawing.TileFlipValues.Vertical, + "xy" or "both" => Drawing.TileFlipValues.HorizontalAndVertical, + _ => Drawing.TileFlipValues.None, + }; + } + picture.BlipFill.AppendChild(tile); + } + else if (fillMode == "contain" || fillMode == "cover") + { + // Compute native-vs-container aspect to derive fillRect + // offsets. a:fillRect insets are in thousandths of a + // percent (100000 = 100%). Positive insets shrink the + // stretched area (letterbox for contain), negatives + // enlarge it (crop for cover). + imgStream.Position = 0; + var dims = OfficeCli.Core.ImageSource.TryGetDimensions(imgStream); + if (dims is { Width: > 0, Height: > 0 } d2 && cxEmu > 0 && cyEmu > 0) + { + double imgAspect = (double)d2.Width / d2.Height; + double boxAspect = (double)cxEmu / cyEmu; + var fr = new Drawing.FillRectangle(); + if (fillMode == "contain") + { + if (imgAspect > boxAspect) + { + // Image wider than box — pad top/bottom + var pad = (int)Math.Round(((1.0 - boxAspect / imgAspect) / 2.0) * 100000); + fr.Top = pad; fr.Bottom = pad; + } + else + { + var pad = (int)Math.Round(((1.0 - imgAspect / boxAspect) / 2.0) * 100000); + fr.Left = pad; fr.Right = pad; + } + } + else // cover + { + if (imgAspect > boxAspect) + { + // Image wider than box — crop left/right (negative inset) + var crop = (int)Math.Round(((imgAspect / boxAspect - 1.0) / 2.0) * 100000); + fr.Left = -crop; fr.Right = -crop; + } + else + { + var crop = (int)Math.Round(((boxAspect / imgAspect - 1.0) / 2.0) * 100000); + fr.Top = -crop; fr.Bottom = -crop; + } + } + picture.BlipFill.AppendChild(new Drawing.Stretch(fr)); + } + else + { + picture.BlipFill.AppendChild(new Drawing.Stretch(new Drawing.FillRectangle())); + } + } + else + { + picture.BlipFill.AppendChild(new Drawing.Stretch(new Drawing.FillRectangle())); + } picture.ShapeProperties = new ShapeProperties(); picture.ShapeProperties.Transform2D = new Drawing.Transform2D(); @@ -89,11 +308,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 +348,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 +362,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) @@ -161,19 +379,19 @@ private string AddChart(string parentPath, int? index, Dictionary ChartHelper.DeferredAddKeys.Contains(kv.Key)) + .Where(kv => ChartHelper.IsDeferredKey(kv.Key)) .ToDictionary(kv => kv.Key, kv => kv.Value); if (deferredProps.Count > 0) ChartHelper.SetChartProperties(chartPart, deferredProps); var chartGf = BuildChartGraphicFrame(chartSlidePart, chartPart, chartId, chartName, chartX, chartY, chartCx, chartCy); - chartShapeTree.AppendChild(chartGf); + InsertAtPosition(chartShapeTree, chartGf, index); GetSlide(chartSlidePart).Save(); var chartCount = chartShapeTree.Elements() .Count(gf => gf.Descendants().Any()); - return $"/slide[{chartSlideIdx}]/chart[{chartCount}]"; + return $"/slide[{chartSlideIdx}]/{BuildElementPathSegment("chart", chartGf, chartCount)}"; } @@ -183,10 +401,12 @@ private string AddMedia(string parentPath, int? index, Dictionary + // where p:oleObj carries progId + r:id (the payload relationship) and + // an inner p:pic element rendering the icon preview. + // + // Caller props: + // src (required) path to the file to embed + // progId defaults to OleHelper.DetectProgId(src) + // width / height EMU-parsed; defaults to 2in × 0.75in + // x / y position in EMU; defaults to top-left (457200,457200) + // icon path to a custom icon (png/jpg/emf); defaults to tiny PNG + // display "icon" (default, sets showAsIcon) or "content" + private string AddOle(string parentPath, int? index, Dictionary properties) + { + properties ??= new Dictionary(); + var srcPath = OfficeCli.Core.OleHelper.RequireSource(properties); + OfficeCli.Core.OleHelper.WarnOnUnknownOleProps(properties); + + var oleSlideMatch = Regex.Match(parentPath, @"^/slide\[(\d+)\]$"); + if (!oleSlideMatch.Success) + throw new ArgumentException("OLE objects must be added to a slide: /slide[N]"); + + var oleSlideIdx = int.Parse(oleSlideMatch.Groups[1].Value); + var oleSlideParts = GetSlideParts().ToList(); + if (oleSlideIdx < 1 || oleSlideIdx > oleSlideParts.Count) + throw new ArgumentException($"Slide {oleSlideIdx} not found (total: {oleSlideParts.Count})"); + + var oleSlidePart = oleSlideParts[oleSlideIdx - 1]; + var oleShapeTree = GetSlide(oleSlidePart).CommonSlideData?.ShapeTree + ?? throw new InvalidOperationException("Slide has no shape tree"); + + // 1. Create the embedded payload part. + var (embedRelId, _) = OfficeCli.Core.OleHelper.AddEmbeddedPart(oleSlidePart, srcPath, _filePath); + + // 2. ProgID (explicit or auto-detected). + var progId = OfficeCli.Core.OleHelper.ResolveProgId(properties, srcPath); + + // 3. Icon image part (placeholder PNG or user-supplied). + var (_, oleIconRelId) = OfficeCli.Core.OleHelper.CreateIconPart(oleSlidePart, properties); + + // 4. Dimensions. + long oleCx = properties.TryGetValue("width", out var wv) + ? ParseEmu(wv) : OfficeCli.Core.OleHelper.DefaultOleWidthEmu; + long oleCy = properties.TryGetValue("height", out var hv) + ? ParseEmu(hv) : OfficeCli.Core.OleHelper.DefaultOleHeightEmu; + long oleX = properties.TryGetValue("x", out var xv) ? ParseEmu(xv) : 457200; + long oleY = properties.TryGetValue("y", out var yv) ? ParseEmu(yv) : 457200; + + // 5. Display mode: icon (default) or content. Strict validation — + // unknown values throw (see OleHelper.NormalizeOleDisplay). + var oleDisplay = OfficeCli.Core.OleHelper.NormalizeOleDisplay( + properties.GetValueOrDefault("display", "icon")); + bool showAsIcon = oleDisplay != "content"; + + // 6. Build the GraphicFrame + OleObject subtree. We lean on + // strong-typed p:oleObj / p:embed / p:pic from the SDK so + // attributes get schema-checked; only the outer GraphicFrame + // wrapper uses hand-built OuterXml because GraphicData.Uri is + // a string attribute, not a type particle. + var oleShapeId = GenerateUniqueShapeId(oleShapeTree); + var oleName = properties.GetValueOrDefault("name", $"Object {oleShapeId}"); + + var oleObj = new DocumentFormat.OpenXml.Presentation.OleObject + { + ShapeId = "", + Name = oleName, + ShowAsIcon = showAsIcon, + Id = embedRelId, + ImageWidth = (int)oleCx, + ImageHeight = (int)oleCy, + ProgId = progId, + }; + // p:embed followColorScheme="full" — lets PowerPoint paint the + // icon using the current slide theme accent, matching PPT's own + // default for embed-mode OLE. + oleObj.AppendChild(new DocumentFormat.OpenXml.Presentation.OleObjectEmbed + { + FollowColorScheme = DocumentFormat.OpenXml.Presentation.OleObjectFollowColorSchemeValues.Full, + }); + + // Inner p:pic holding the icon preview (bound to the image part we + // just created). Structure mirrors a minimal non-animated picture. + var olePic = new DocumentFormat.OpenXml.Presentation.Picture(); + olePic.NonVisualPictureProperties = new NonVisualPictureProperties( + new NonVisualDrawingProperties { Id = 0U, Name = "" }, + new NonVisualPictureDrawingProperties(), + new ApplicationNonVisualDrawingProperties() + ); + olePic.BlipFill = new BlipFill( + new Drawing.Blip { Embed = oleIconRelId }, + new Drawing.Stretch(new Drawing.FillRectangle()) + ); + olePic.ShapeProperties = new ShapeProperties( + new Drawing.Transform2D( + new Drawing.Offset { X = oleX, Y = oleY }, + new Drawing.Extents { Cx = oleCx, Cy = oleCy } + ), + new Drawing.PresetGeometry(new Drawing.AdjustValueList()) { Preset = Drawing.ShapeTypeValues.Rectangle } + ); + oleObj.AppendChild(olePic); + + // 7. Wrap the OleObject in a GraphicFrame with the ole URI. + var oleGraphicData = new Drawing.GraphicData(oleObj) + { + Uri = "http://schemas.openxmlformats.org/presentationml/2006/ole", + }; + var oleFrame = new GraphicFrame( + new NonVisualGraphicFrameProperties( + new NonVisualDrawingProperties { Id = oleShapeId, Name = oleName }, + new NonVisualGraphicFrameDrawingProperties(), + new ApplicationNonVisualDrawingProperties() + ), + new Transform( + new Drawing.Offset { X = oleX, Y = oleY }, + new Drawing.Extents { Cx = oleCx, Cy = oleCy } + ), + new Drawing.Graphic(oleGraphicData) + ); + + InsertAtPosition(oleShapeTree, oleFrame, index); + GetSlide(oleSlidePart).Save(); + + // Count OLE frames on this slide for the return path. + var oleFrames = oleShapeTree.Elements() + .Count(gf => gf.Descendants().Any()); + return $"/slide[{oleSlideIdx}]/ole[{oleFrames}]"; + } } diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Misc.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Misc.cs index 4834f66c2..52e798146 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Misc.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Misc.cs @@ -29,8 +29,8 @@ private string AddConnector(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(); @@ -276,6 +275,78 @@ private string AddGroup(string parentPath, int? index, Dictionary with that binds to the layout's matching + // placeholder. Leaves empty so PowerPoint inherits geometry/font + // from the layout placeholder. Optional --prop text=... prepopulates text. + private string AddPlaceholder(string parentPath, int? index, Dictionary properties) + { + var phSlideMatch = Regex.Match(parentPath, @"^/slide\[(\d+)\]$"); + if (!phSlideMatch.Success) + throw new ArgumentException("Placeholders must be added to a slide: /slide[N]"); + + var phSlideIdx = int.Parse(phSlideMatch.Groups[1].Value); + var phSlideParts = GetSlideParts().ToList(); + if (phSlideIdx < 1 || phSlideIdx > phSlideParts.Count) + throw new ArgumentException($"Slide {phSlideIdx} not found (total: {phSlideParts.Count})"); + + var phSlidePart = phSlideParts[phSlideIdx - 1]; + var phShapeTree = GetSlide(phSlidePart).CommonSlideData?.ShapeTree + ?? throw new InvalidOperationException("Slide has no shape tree"); + + if (!properties.TryGetValue("phType", out var phTypeStr) + && !properties.TryGetValue("phtype", out phTypeStr) + && !properties.TryGetValue("type", out phTypeStr)) + throw new ArgumentException("'phType' property required for placeholder type (e.g. phType=body|date|footer|slidenum|header|subtitle|title)"); + + var phTypeVal = ParsePlaceholderType(phTypeStr) + ?? throw new ArgumentException( + $"Invalid placeholder type: '{phTypeStr}'. Valid: title, body, subtitle, date, footer, slidenum, header, picture, chart, table, diagram, media, obj, clipart."); + + var phId = GenerateUniqueShapeId(phShapeTree); + var phName = properties.GetValueOrDefault("name", $"{phTypeStr} Placeholder {phId}"); + + var shape = new Shape(); + var appNvPr = new ApplicationNonVisualDrawingProperties(); + appNvPr.AppendChild(new PlaceholderShape { Type = phTypeVal }); + shape.NonVisualShapeProperties = new NonVisualShapeProperties( + new NonVisualDrawingProperties { Id = phId, Name = phName }, + new NonVisualShapeDrawingProperties(), + appNvPr + ); + // Leave ShapeProperties empty — PowerPoint pulls geometry from layout. + shape.ShapeProperties = new ShapeProperties(); + + // Optional text prepopulation. Build a minimal TextBody so PowerPoint + // still renders layout placeholder typography. + var textBody = new TextBody( + new Drawing.BodyProperties(), + new Drawing.ListStyle() + ); + var para = new Drawing.Paragraph(); + if (properties.TryGetValue("text", out var phText) && phText.Length > 0) + { + para.AppendChild(new Drawing.Run( + new Drawing.RunProperties { Language = "en-US" }, + new Drawing.Text(phText) + )); + } + else + { + // Empty paragraph is valid — PowerPoint shows the layout prompt text. + para.AppendChild(new Drawing.EndParagraphRunProperties { Language = "en-US" }); + } + textBody.AppendChild(para); + shape.TextBody = textBody; + + InsertAtPosition(phShapeTree, shape, index); + GetSlide(phSlidePart).Save(); + + var shapeCount = phShapeTree.Elements().Count(); + return $"/slide[{phSlideIdx}]/shape[{shapeCount}]"; + } + + private string AddAnimation(string parentPath, int? index, Dictionary properties) { // Add animation to a shape: parentPath must be /slide[N]/shape[M] @@ -375,8 +446,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 +651,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 @@ -632,7 +703,7 @@ private string AddDefault(string parentPath, int? index, Dictionary slideParts.Count) throw new ArgumentException($"Slide {slideIdx} not found (total: {slideParts.Count})"); - // Resolve file path - var fullPath = Path.GetFullPath(modelPath); - if (!File.Exists(fullPath)) - throw new FileNotFoundException($"3D model file not found: {modelPath}"); - - var fileExt = Path.GetExtension(fullPath).ToLowerInvariant(); + // Resolve source (local path, HTTP URL, or data URI) + var (modelStream, fileExt) = OfficeCli.Core.FileSource.Resolve(modelPath); + using var modelStreamDispose = modelStream; if (fileExt != ".glb") throw new ArgumentException($"Unsupported 3D model format: {fileExt}. Only .glb (glTF-Binary) is supported."); @@ -46,12 +43,12 @@ private string AddModel3D(string parentPath, int? index, Dictionary - private static GlbBoundingBox ParseGlbBoundingBox(string glbPath) + private static GlbBoundingBox ParseGlbBoundingBox(Stream glbStream) { try { - using var fs = File.OpenRead(glbPath); - using var reader = new BinaryReader(fs); + glbStream.Position = 0; + using var reader = new BinaryReader(glbStream, System.Text.Encoding.UTF8, leaveOpen: true); var magic = reader.ReadUInt32(); var version = reader.ReadUInt32(); diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Shape.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Shape.cs index 4d5a9f2a4..1dbfef301 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Shape.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Shape.cs @@ -30,13 +30,8 @@ private string AddShape(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 // Must come after gradient so it can apply to gradient stops too if (properties.TryGetValue("opacity", out var opacityVal)) @@ -347,16 +347,19 @@ private string AddShape(string parentPath, int? index, Dictionary(StringComparer.OrdinalIgnoreCase) { "linedash", "line.dash", "shadow", "glow", "reflection", - "softedge", "fliph", "flipv", "rot3d", "rotation3d", + "softedge", "blur", "fliph", "flipv", "rot3d", "rotation3d", "rotx", "roty", "rotz", "bevel", "beveltop", "bevelbottom", "depth", "extrusion", "material", "lighting", "lightrig", "spacing", "charspacing", "letterspacing", @@ -378,8 +381,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..d91e8d2ca 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Table.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Table.cs @@ -29,14 +29,14 @@ private string AddTable(string parentPath, int? index, Dictionary !string.IsNullOrWhiteSpace(l)) .Select(l => l.Split(',').Select(c => c.Trim()).ToArray()) .ToArray(); @@ -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..658199d67 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())}"; } @@ -166,6 +162,34 @@ private string AddParagraph(string parentPath, int? index, Dictionary 8) + throw new ArgumentException($"Invalid 'level' value: '{pLevelStr}'. Expected an integer between 0 and 8 (OOXML a:pPr/@lvl)."); + pProps.Level = pLevelVal; + } + // Line spacing (CONSISTENCY(lineSpacing): same idiom as AddShape:~180) + if (properties.TryGetValue("lineSpacing", out var pLsVal) || properties.TryGetValue("linespacing", out pLsVal)) + { + var (pLsInternal, pLsIsPercent) = SpacingConverter.ParsePptLineSpacing(pLsVal); + pProps.RemoveAllChildren(); + if (pLsIsPercent) + pProps.AppendChild(new Drawing.LineSpacing( + new Drawing.SpacingPercent { Val = pLsInternal })); + else + pProps.AppendChild(new Drawing.LineSpacing( + new Drawing.SpacingPoints { Val = pLsInternal })); + } + if (properties.TryGetValue("spaceBefore", out var pSbVal) || properties.TryGetValue("spacebefore", out pSbVal)) + { + pProps.RemoveAllChildren(); + pProps.AppendChild(new Drawing.SpaceBefore(new Drawing.SpacingPoints { Val = SpacingConverter.ParsePptSpacing(pSbVal) })); + } + if (properties.TryGetValue("spaceAfter", out var pSaVal) || properties.TryGetValue("spaceafter", out pSaVal)) + { + pProps.RemoveAllChildren(); + pProps.AppendChild(new Drawing.SpaceAfter(new Drawing.SpacingPoints { Val = SpacingConverter.ParsePptSpacing(pSaVal) })); + } newPara.ParagraphProperties = pProps; @@ -225,7 +249,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 +344,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..00c1c8147 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Add.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Add.cs @@ -14,9 +14,31 @@ 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) { + // CONSISTENCY(prop-key-case): property keys are case-insensitive + // ("SRC"/"src"/"Src" all resolve the same). Normalize once at the + // dispatch entry so every AddXxx helper can rely on TryGetValue("src"). + properties = properties == null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : properties.Comparer == StringComparer.OrdinalIgnoreCase + ? properties + : new Dictionary(properties, StringComparer.OrdinalIgnoreCase); + 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 { @@ -24,6 +46,7 @@ public string Add(string parentPath, string type, int? index, Dictionary AddEquation(parentPath, index, properties), "shape" or "textbox" => AddShape(parentPath, index, properties ?? new()), "picture" or "image" or "img" => AddPicture(parentPath, index, properties), + "ole" or "oleobject" or "object" or "embed" => AddOle(parentPath, index, properties ?? new()), "chart" => AddChart(parentPath, index, properties), "table" => AddTable(parentPath, index, properties), "equation" or "formula" or "math" => AddEquation(parentPath, index, properties), @@ -31,6 +54,7 @@ public string Add(string parentPath, string type, int? index, Dictionary AddMedia(parentPath, index, properties, type), "connector" or "connection" => AddConnector(parentPath, index, properties), "group" => AddGroup(parentPath, index, properties), + "placeholder" or "ph" => AddPlaceholder(parentPath, index, properties), "row" or "tr" => AddRow(parentPath, index, properties), "col" or "column" => AddColumn(parentPath, index, properties), "cell" or "tc" => AddCell(parentPath, index, properties), 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.Effects.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Effects.cs index 9fb86796f..75fa87dc1 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Effects.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Effects.cs @@ -153,6 +153,30 @@ private static void ApplySoftEdge(ShapeProperties spPr, string value) InsertEffectInOrder(effectList, new Drawing.SoftEdge { Radius = (long)(radiusPt * 12700) }); } + /// + /// Apply blur effect to ShapeProperties. + /// Value: radius in points (e.g. "4" or "4pt") or "none" to remove. + /// Converts pt → EMU (1pt = 12700 EMU). Sets GrowBounds = true. + /// + private static void ApplyBlur(ShapeProperties spPr, string value) + { + var effectList = EnsureEffectList(spPr); + effectList.RemoveAllChildren(); + + if (value.Equals("none", StringComparison.OrdinalIgnoreCase) || value.Equals("false", StringComparison.OrdinalIgnoreCase)) + { + if (!effectList.HasChildren) spPr.RemoveChild(effectList); + return; + } + + var numStr = value.EndsWith("pt", StringComparison.OrdinalIgnoreCase) ? value[..^2].Trim() : value; + if (!double.TryParse(numStr, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var radiusPt) + || double.IsNaN(radiusPt) || double.IsInfinity(radiusPt) || radiusPt < 0) + throw new ArgumentException($"Invalid 'blur' value '{value}'. Expected a finite non-negative numeric radius in points."); + + InsertEffectInOrder(effectList, new Drawing.Blur { Radius = (long)(radiusPt * 12700), Grow = true }); + } + private static void ApplyTextReflection(Drawing.Run run, string value) => OfficeCli.Core.DrawingEffectsHelper.ApplyTextEffect(run, value, () => OfficeCli.Core.DrawingEffectsHelper.BuildReflection(value)); diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Fill.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Fill.cs index 8fb1305c6..ecb41c17a 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Fill.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Fill.cs @@ -145,6 +145,109 @@ private static void ApplyGradientFill(ShapeProperties spPr, string value) InsertFillElement(spPr, newFill); } + /// + /// Apply pattern fill to ShapeProperties. + /// Format: "" or ":" or "::" + /// preset: e.g. pct25, ltHorz, dkCross, weave, zigZag (Drawing.PresetPatternValues) + /// fgColor / bgColor: lenient hex/named/scheme color (defaults: fg=000000, bg=FFFFFF) + /// Examples: "pct25", "ltHorz:FF0000", "dkCross:red:white" + /// + private static void ApplyPatternFill(ShapeProperties spPr, string value) + { + // Build new fill BEFORE removing old one (atomic: no data loss on invalid input) + var newFill = BuildPatternFill(value); + spPr.RemoveAllChildren(); + spPr.RemoveAllChildren(); + spPr.RemoveAllChildren(); + spPr.RemoveAllChildren(); + spPr.RemoveAllChildren(); + InsertFillElement(spPr, newFill); + } + + private static Drawing.PatternFill BuildPatternFill(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("pattern value cannot be empty."); + + var parts = value.Split(':'); + var presetName = parts[0].Trim(); + var fg = parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]) ? parts[1].Trim() : "000000"; + var bg = parts.Length > 2 && !string.IsNullOrWhiteSpace(parts[2]) ? parts[2].Trim() : "FFFFFF"; + + var patternFill = new Drawing.PatternFill { Preset = ParsePresetPattern(presetName) }; + // Schema order: fgClr → bgClr + var fgClr = new Drawing.ForegroundColor(); + fgClr.Append(BuildColorElement(fg)); + patternFill.Append(fgClr); + var bgClr = new Drawing.BackgroundColor(); + bgClr.Append(BuildColorElement(bg)); + patternFill.Append(bgClr); + return patternFill; + } + + private static Drawing.PresetPatternValues ParsePresetPattern(string name) + { + return name.ToLowerInvariant() switch + { + "pct5" => Drawing.PresetPatternValues.Percent5, + "pct10" => Drawing.PresetPatternValues.Percent10, + "pct20" => Drawing.PresetPatternValues.Percent20, + "pct25" => Drawing.PresetPatternValues.Percent25, + "pct30" => Drawing.PresetPatternValues.Percent30, + "pct40" => Drawing.PresetPatternValues.Percent40, + "pct50" => Drawing.PresetPatternValues.Percent50, + "pct60" => Drawing.PresetPatternValues.Percent60, + "pct70" => Drawing.PresetPatternValues.Percent70, + "pct75" => Drawing.PresetPatternValues.Percent75, + "pct80" => Drawing.PresetPatternValues.Percent80, + "pct90" => Drawing.PresetPatternValues.Percent90, + "dkhorz" => Drawing.PresetPatternValues.DarkHorizontal, + "dkvert" => Drawing.PresetPatternValues.DarkVertical, + "dkdndiag" => Drawing.PresetPatternValues.DarkDownwardDiagonal, + "dkupdiag" => Drawing.PresetPatternValues.DarkUpwardDiagonal, + "lthorz" => Drawing.PresetPatternValues.LightHorizontal, + "ltvert" => Drawing.PresetPatternValues.LightVertical, + "ltdndiag" => Drawing.PresetPatternValues.LightDownwardDiagonal, + "ltupdiag" => Drawing.PresetPatternValues.LightUpwardDiagonal, + "narhorz" => Drawing.PresetPatternValues.NarrowHorizontal, + "narvert" => Drawing.PresetPatternValues.NarrowVertical, + "horz" or "horizontal" => Drawing.PresetPatternValues.Horizontal, + "vert" or "vertical" => Drawing.PresetPatternValues.Vertical, + "dndiag" or "downdiag" => Drawing.PresetPatternValues.DownwardDiagonal, + "updiag" => Drawing.PresetPatternValues.UpwardDiagonal, + "wdupdiag" => Drawing.PresetPatternValues.WideUpwardDiagonal, + "wddndiag" => Drawing.PresetPatternValues.WideDownwardDiagonal, + "dashhorz" => Drawing.PresetPatternValues.DashedHorizontal, + "dashvert" => Drawing.PresetPatternValues.DashedVertical, + "dashdndiag" => Drawing.PresetPatternValues.DashedDownwardDiagonal, + "dashupdiag" => Drawing.PresetPatternValues.DashedUpwardDiagonal, + "smconfetti" => Drawing.PresetPatternValues.SmallConfetti, + "lgconfetti" => Drawing.PresetPatternValues.LargeConfetti, + "zigzag" => Drawing.PresetPatternValues.ZigZag, + "wave" => Drawing.PresetPatternValues.Wave, + "diagbrick" => Drawing.PresetPatternValues.DiagonalBrick, + "horzbrick" => Drawing.PresetPatternValues.HorizontalBrick, + "weave" => Drawing.PresetPatternValues.Weave, + "plaid" => Drawing.PresetPatternValues.Plaid, + "divot" => Drawing.PresetPatternValues.Divot, + "dotgrid" => Drawing.PresetPatternValues.DotGrid, + "dotdiamond" => Drawing.PresetPatternValues.DottedDiamond, + "shingle" => Drawing.PresetPatternValues.Shingle, + "trellis" => Drawing.PresetPatternValues.Trellis, + "sphere" => Drawing.PresetPatternValues.Sphere, + "smgrid" => Drawing.PresetPatternValues.SmallGrid, + "lggrid" => Drawing.PresetPatternValues.LargeGrid, + "smcheck" => Drawing.PresetPatternValues.SmallCheck, + "lgcheck" => Drawing.PresetPatternValues.LargeCheck, + "openDmnd" or "opendmnd" => Drawing.PresetPatternValues.OpenDiamond, + "solidDmnd" or "soliddmnd" => Drawing.PresetPatternValues.SolidDiamond, + "cross" => Drawing.PresetPatternValues.Cross, + "diagcross" => Drawing.PresetPatternValues.DiagonalCross, + _ => throw new ArgumentException( + $"Unknown pattern preset: '{name}'. Examples: pct25, ltHorz, dkCross, weave, zigZag, wave, diagBrick, plaid.") + }; + } + /// /// Apply image (blip) fill to a shape. /// Format: file path to image, e.g. "/tmp/bg.png" @@ -265,7 +368,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 +378,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..54dd8b533 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Helpers.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Helpers.cs @@ -4,10 +4,10 @@ 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; -using M = DocumentFormat.OpenXml.Math; namespace OfficeCli.Handlers; @@ -29,6 +29,237 @@ 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) + { + // Null/empty paths are a valid "duplicate in place" / "no target" + // signal from CopyFrom and friends; pass them through untouched so + // downstream dispatch can interpret the null itself. + if (path == null) return 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 +804,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 +1181,713 @@ 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..]; - var presentationPart = _doc.PresentationPart; - if (presentationPart == null) return 0; + // Clone the run for the right side + var rightRun = (Drawing.Run)run.CloneNode(true); - foreach (var slidePart in presentationPart.SlideParts) + // 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) { - var slide = slidePart.Slide; - if (slide == null) continue; + if (charEnd > rt.Start && charEnd < rt.End) + { + SplitPptRunAtOffset(rt.Run, charEnd - rt.Start); + break; + } + } - foreach (var text in slide.Descendants()) + // Rebuild, then split at charStart + runTexts = BuildPptRunTexts(para); + foreach (var rt in runTexts) + { + if (charStart > rt.Start && charStart < rt.End) { - if (text.Text != null && text.Text.Contains(find, StringComparison.Ordinal)) + 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 { - int count = 0; - int idx = 0; - while ((idx = text.Text.IndexOf(find, idx, StringComparison.Ordinal)) >= 0) + "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 fullText = string.Concat(runTexts.Select(rt => rt.TextElement.Text)); + var matches = FindMatchRanges(fullText, pattern, isRegex); + if (matches.Count == 0) return 0; + + for (int i = matches.Count - 1; i >= 0; i--) + { + var (matchStart, matchLen) = matches[i]; + var matchEnd = matchStart + matchLen; + + if (replace != null) + { + // Replace text in affected runs + var currentRunTexts = BuildPptRunTexts(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..]; + 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; + } + + /// + /// 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 { } } - slidePart.Slide!.Save(); + 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; + } + + // ==================== Binary Extraction ==================== + // + // Support for `officecli get --save `. The node's relId plus + // the /slide[N]/ prefix in the path identifies the owning SlidePart; + // the payload part is then looked up and its stream copied out. + public bool TryExtractBinary(string path, string destPath, out string? contentType, out long byteCount) + { + contentType = null; + byteCount = 0; + var node = Get(path, 0); + if (node == null) return false; + if (!node.Format.TryGetValue("relId", out var relObj) || relObj is not string relId + || string.IsNullOrEmpty(relId)) + return false; + + // Infer slide index from the path (/slide[N]/...). + var m = System.Text.RegularExpressions.Regex.Match(path, @"^/slide\[(\d+)\]"); + if (!m.Success) return false; + var slideIdx = int.Parse(m.Groups[1].Value); + var slideParts = GetSlideParts().ToList(); + if (slideIdx < 1 || slideIdx > slideParts.Count) return false; + + var slidePart = slideParts[slideIdx - 1]; + DocumentFormat.OpenXml.Packaging.OpenXmlPart? part = null; + try { part = slidePart.GetPartById(relId); } catch { /* not on slide */ } + if (part == null) return false; + + // BUG-R10-04: create the destination directory if missing so + // `get --save ./outdir/file.bin` works when outdir doesn't exist. + var destDir = Path.GetDirectoryName(destPath); + if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir)) + Directory.CreateDirectory(destDir); + + // CONSISTENCY(ole-cfb-wrap): unwrap CFB Ole10Native payload on read. + byte[] rawBytes; + using (var src = part.GetStream()) + using (var ms = new MemoryStream()) + { + src.CopyTo(ms); + rawBytes = ms.ToArray(); + } + var payload = OfficeCli.Core.OleHelper.UnwrapOle10NativeIfCfb(rawBytes); + File.WriteAllBytes(destPath, payload); + byteCount = payload.Length; + contentType = part.ContentType; + return true; + } + + // ==================== OLE Object Reading ==================== + // + // Enumerate all OLE objects on a slide. PPTX wraps OLE in a + // GraphicFrame whose GraphicData uri = "*/ole" contains a + // element with progId + r:id. We walk descendants to catch both the + // modern (p:oleObj as direct child) and alternate content fallback + // forms. Orphan embedded parts (not referenced by any oleObj) are + // surfaced the same way as the Excel reader, so nothing disappears. + internal List CollectOleNodesForSlide(int slideNum, SlidePart slidePart) + { + var nodes = new List(); + var seenRelIds = new HashSet(StringComparer.OrdinalIgnoreCase); + var shapeTree = GetSlide(slidePart).CommonSlideData?.ShapeTree; + if (shapeTree == null) return nodes; + + // 1. Walk GraphicFrames hosting p:oleObj (strong-typed via SDK). + int oleIdx = 0; + foreach (var gf in shapeTree.Descendants()) + { + // A GraphicFrame may carry table/chart/ole — filter on the + // presence of a strong-typed OleObject descendant. + var oleObj = gf.Descendants().FirstOrDefault(); + if (oleObj == null) continue; + + oleIdx++; + var node = new DocumentNode + { + Path = $"/slide[{slideNum}]/ole[{oleIdx}]", + Type = "ole", + Text = oleObj.ProgId?.Value ?? "", + }; + node.Format["objectType"] = "ole"; + if (oleObj.ProgId?.Value != null) node.Format["progId"] = oleObj.ProgId.Value; + if (oleObj.Name?.Value != null) node.Format["name"] = oleObj.Name.Value; + // CONSISTENCY(ole-display): always emit display key so callers can + // rely on it being present; mirrors Word OLE DrawAspect normalization. + node.Format["display"] = (oleObj.ShowAsIcon?.Value == true) ? "icon" : "content"; + // CONSISTENCY(ole-width-units): imgW/imgH (raw EMU) used to be + // surfaced here but duplicated the unit-qualified width/height + // emitted from the graphicFrame xfrm below. Kept internal only. + + // Extents from the frame's own xfrm. + var xfrm = gf.Transform; + if (xfrm?.Extents != null) + { + if (xfrm.Extents.Cx?.Value != null) + node.Format["width"] = OfficeCli.Core.EmuConverter.FormatEmu(xfrm.Extents.Cx.Value); + if (xfrm.Extents.Cy?.Value != null) + node.Format["height"] = OfficeCli.Core.EmuConverter.FormatEmu(xfrm.Extents.Cy.Value); + } + + var relId = oleObj.Id?.Value; + if (!string.IsNullOrEmpty(relId)) + { + node.Format["relId"] = relId; + seenRelIds.Add(relId); + try + { + var part = slidePart.GetPartById(relId); + if (part != null) + OfficeCli.Core.OleHelper.PopulateFromPart(node, part, oleObj.ProgId?.Value); + } + catch + { + // Ignore rel-join failures; keep whatever we got from XML. + } + } + + nodes.Add(node); + } + + // CONSISTENCY(ole-orphan-indexing): orphan embedded parts are NOT + // indexed under ole[N] to keep Get/Set/Remove in lockstep. Set/Remove + // dispatch on schema-typed elements only; indexing orphans + // here would produce Get-visible nodes that Set/Remove cannot + // address. See ExcelHandler.Helpers.cs for the mirror comment. + + return nodes; + } } diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Charts.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Charts.cs index 7c06b64cc..d3217fa02 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Charts.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Charts.cs @@ -14,24 +14,13 @@ public partial class PowerPointHandler { // ==================== Chart Rendering ==================== - // Default chart colors matching PowerPoint Office theme accent colors - private static readonly string[] ChartColors = [ - "#4472C4", "#ED7D31", "#A5A5A5", "#FFC000", "#5B9BD5", "#70AD47", - "#264478", "#9E480E", "#636363", "#997300", "#255E91", "#43682B" - ]; + // Chart text color — set per-chart, also used by SvgPreview + private string _chartValueColor = "#D0D8E0"; - // Chart styling — set per-chart in RenderChart, used by all sub-render methods - private string _chartValueColor = "#D0D8E0"; // data value labels - private string _chartCatColor = "#C8D0D8"; // category axis labels - private string _chartAxisColor = "#B0B8C0"; // value axis labels - private string _chartGridColor = "#333"; // gridlines - private string _chartAxisLineColor = "#555"; // axis lines - private int _chartValFontPx = 9; // value axis label font size (from OOXML or default) - private int _chartCatFontPx = 9; // category axis label font size (from OOXML or default) - - 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) { - // p:xfrm contains a:off and a:ext + var dataPathAttr = string.IsNullOrEmpty(dataPath) ? "" : $" data-path=\"{HtmlEncode(dataPath)}\""; + // Position and size from p:xfrm var pxfrm = gf.GetFirstChild(); var off = pxfrm?.GetFirstChild(); var ext = pxfrm?.GetFirstChild(); @@ -42,560 +31,104 @@ private void RenderChart(StringBuilder sb, GraphicFrame gf, SlidePart slidePart, var w = Units.EmuToPt(ext.Cx?.Value ?? 0); var h = Units.EmuToPt(ext.Cy?.Value ?? 0); - // Read chart data — find c:chart element with r:id + // Get chart part var chartEl = gf.Descendants().FirstOrDefault(e => e.LocalName == "chart" && e.NamespaceUri.Contains("chart")); var rId = chartEl?.GetAttributes().FirstOrDefault(a => a.LocalName == "id" && a.NamespaceUri.Contains("relationships")).Value; if (rId == null) return; DocumentFormat.OpenXml.Drawing.Charts.Chart? chart; DocumentFormat.OpenXml.Drawing.Charts.PlotArea? plotArea; + ChartSvgRenderer.ChartInfo info; try { - var chartPart = (ChartPart)slidePart.GetPartById(rId); - chart = chartPart.ChartSpace?.GetFirstChild(); - plotArea = chart?.GetFirstChild(); - if (plotArea == null) return; + var anyPart = slidePart.GetPartById(rId); + // cx:chart (extended) path — branch early, extract via ExtractCxChartInfo, + // skip the regular c:PlotArea pipeline since cx uses its own layout. + if (anyPart is ExtendedChartPart extPart) + { + var cxChart = extPart.ChartSpace? + .GetFirstChild(); + if (cxChart == null) return; + info = ChartSvgRenderer.ExtractCxChartInfo(cxChart); + chart = null; + plotArea = null; + } + else if (anyPart is ChartPart chartPart) + { + chart = chartPart.ChartSpace?.GetFirstChild(); + plotArea = chart?.GetFirstChild(); + if (plotArea == null) return; + info = ChartSvgRenderer.ExtractChartInfo(plotArea, chart); + } + else return; } catch { return; } - var chartType = ChartHelper.DetectChartType(plotArea) ?? "bar"; - var categories = ChartHelper.ReadCategories(plotArea) ?? []; - var seriesList = ChartHelper.ReadAllSeries(plotArea); - if (seriesList.Count == 0) return; + if (info.Series.Count == 0) return; - // Read series colors - var seriesColors = new List(); - var serElements = plotArea.Descendants() - .Where(e => e.LocalName == "ser").ToList(); - for (int i = 0; i < seriesList.Count; i++) - { - var serEl = i < serElements.Count ? serElements[i] : null; - var spPr = serEl?.GetFirstChild(); - var fill = spPr?.GetFirstChild(); - var rgb = fill?.GetFirstChild()?.Val?.Value; - seriesColors.Add(rgb != null ? $"#{rgb}" : ChartColors[i % ChartColors.Length]); - } - - // Derive text color from theme: use tx1 or dk1 (with #), fallback to light gray + // Derive text color from theme var chartTextColor = themeColors.TryGetValue("tx1", out var tx1) ? $"#{tx1}" : themeColors.TryGetValue("dk1", out var dk1) ? $"#{dk1}" : "#D0D8E0"; - var chartLabelColor = chartTextColor; - var chartAxisColor = chartTextColor; - - // Set instance fields for sub-render methods to use theme-derived colors _chartValueColor = chartTextColor; - _chartCatColor = chartTextColor; - _chartAxisColor = chartTextColor; - // Derive gridline/axis line colors: dim version of text color var isDarkText = IsColorDark(chartTextColor.TrimStart('#')); - _chartGridColor = isDarkText ? "#ccc" : "#333"; - _chartAxisLineColor = isDarkText ? "#aaa" : "#555"; - // Title - var chartTitle = chart?.GetFirstChild(); - var titleText = chartTitle?.Descendants().FirstOrDefault()?.Text ?? ""; - var titleFontSize = chartTitle?.Descendants().FirstOrDefault()?.FontSize; - var titleSizeCss = titleFontSize?.HasValue == true ? $"{titleFontSize.Value / 100.0:0.##}pt" : "8pt"; - - // Check if dataLabels are enabled - var dataLabels = plotArea.Descendants().FirstOrDefault(); - var showValues = dataLabels?.GetFirstChild()?.Val?.Value == true - || dataLabels?.GetFirstChild()?.Val?.Value == true - || dataLabels?.GetFirstChild()?.Val?.Value == true; - - // Plot/chart fill — only direct children, not series fills - var plotSpPr = plotArea.GetFirstChild(); - var plotFillColor = plotSpPr?.GetFirstChild()?.GetFirstChild()?.Val?.Value; - var chartSpPr = chart?.Parent?.GetFirstChild(); - var chartFillColor = chartSpPr?.GetFirstChild()?.GetFirstChild()?.Val?.Value; - - // Axis titles - var valAxis = plotArea.GetFirstChild(); - var valAxisTitle = valAxis?.GetFirstChild()?.Descendants().FirstOrDefault()?.Text; - var catAxis = plotArea.GetFirstChild(); - var catAxisTitle = catAxis?.GetFirstChild()?.Descendants().FirstOrDefault()?.Text; - - // Read explicit axis parameters from OOXML (override auto-calculation when present) - var valScaling = valAxis?.GetFirstChild(); - double? ooxmlAxisMax = null, ooxmlAxisMin = null, ooxmlMajorUnit = null; - var maxEl = valScaling?.GetFirstChild(); - if (maxEl?.Val?.HasValue == true) ooxmlAxisMax = maxEl.Val.Value; - var minEl = valScaling?.GetFirstChild(); - if (minEl?.Val?.HasValue == true) ooxmlAxisMin = minEl.Val.Value; - var majorUnitEl = valAxis?.GetFirstChild(); - if (majorUnitEl?.Val?.HasValue == true) ooxmlMajorUnit = majorUnitEl.Val.Value; - - // Read gapWidth from bar/column chart - var gapWidthEl = plotArea.Descendants().FirstOrDefault(); - int? ooxmlGapWidth = gapWidthEl?.Val?.HasValue == true ? (int)gapWidthEl.Val.Value : null; - - // Read axis label font sizes from OOXML - var valAxisFontSize = valAxis?.Descendants().FirstOrDefault()?.FontSize; - var catAxisFontSize = catAxis?.Descendants().FirstOrDefault()?.FontSize; - int valLabelPx = valAxisFontSize?.HasValue == true ? (int)(valAxisFontSize.Value / 100.0 * 96 / 72) : 9; - int catLabelPx = catAxisFontSize?.HasValue == true ? (int)(catAxisFontSize.Value / 100.0 * 96 / 72) : 9; - _chartValFontPx = valLabelPx; - _chartCatFontPx = catLabelPx; - - // Shared SVG renderer for 2D charts (shared with Excel) - var svgRenderer = new OfficeCli.Core.ChartSvgRenderer - { - ValueColor = _chartValueColor, CatColor = _chartCatColor, - AxisColor = _chartAxisColor, GridColor = _chartGridColor, - AxisLineColor = _chartAxisLineColor, ValFontPx = _chartValFontPx, CatFontPx = _chartCatFontPx + // Create renderer with theme-derived colors + var renderer = new ChartSvgRenderer + { + ThemeAccentColors = ChartSvgRenderer.BuildThemeAccentColors(themeColors), + ValueColor = chartTextColor, + CatColor = chartTextColor, + AxisColor = chartTextColor, + GridColor = info.GridlineColor != null ? $"#{info.GridlineColor}" : (isDarkText ? "#ccc" : "#333"), + AxisLineColor = info.AxisLineColor != null ? $"#{info.AxisLineColor}" : (isDarkText ? "#aaa" : "#555"), + ValFontPx = info.ValFontPx, + CatFontPx = info.CatFontPx }; - // Container with optional chart background - var bgStyle = chartFillColor != null ? $"background:#{chartFillColor};" : "background:transparent;"; - sb.AppendLine($"
    "); - - // Title - if (!string.IsNullOrEmpty(titleText)) - sb.AppendLine($"
    {OfficeCli.Core.ChartSvgRenderer.HtmlEncode(titleText)}
    "); - - // SVG chart area — proportional to actual shape dimensions + // SVG dimensions (scale EMU to reasonable SVG units) var widthEmu = ext.Cx?.Value ?? 3600000; var heightEmu = ext.Cy?.Value ?? 2520000; - var svgW = (int)(widthEmu / 10000.0); // scale down to reasonable SVG units + var svgW = (int)(widthEmu / 10000.0); var svgH = (int)(heightEmu / 10000.0); - var titleH = string.IsNullOrEmpty(titleText) ? 0 : 20; + var titleH = string.IsNullOrEmpty(info.Title) ? 0 : 20; var chartSvgH = svgH - titleH; - // Try to read manual layout from OOXML plotArea + + // Manual layout margins — only regular c:chart has a ManualLayout. var plotAreaLayout = plotArea?.GetFirstChild(); var manualLayout = plotAreaLayout?.GetFirstChild(); int marginTop, marginRight, marginBottom, marginLeft; if (manualLayout != null) { - // ManualLayout x/y/w/h are fractions of the chart area (0.0 - 1.0) var mlX = manualLayout.Left?.Val?.Value ?? 0.0; var mlY = manualLayout.Top?.Val?.Value ?? 0.0; var mlW = manualLayout.Width?.Val?.Value ?? 1.0; var mlH = manualLayout.Height?.Val?.Value ?? 1.0; - marginLeft = (int)(mlX * svgW); - marginTop = (int)(mlY * chartSvgH); - marginRight = (int)((1.0 - mlX - mlW) * svgW); - marginBottom = (int)((1.0 - mlY - mlH) * chartSvgH); - if (marginLeft < 5) marginLeft = 5; - if (marginRight < 5) marginRight = 5; - if (marginTop < 5) marginTop = 5; - if (marginBottom < 5) marginBottom = 5; + marginLeft = Math.Max((int)(mlX * svgW), 5); + marginTop = Math.Max((int)(mlY * chartSvgH), 5); + marginRight = Math.Max((int)((1.0 - mlX - mlW) * svgW), 5); + marginBottom = Math.Max((int)((1.0 - mlY - mlH) * chartSvgH), 5); } else { marginTop = 10; marginRight = 15; marginBottom = 25; marginLeft = 40; } - var margin = new { top = marginTop, right = marginRight, bottom = marginBottom, left = marginLeft }; - var plotW = svgW - margin.left - margin.right; - var plotH = chartSvgH - margin.top - margin.bottom; - var is3D = chartType.Contains("3d"); + // Container with chart background + var bgStyle = info.ChartFillColor != null ? $"background:#{info.ChartFillColor};" : "background:transparent;"; + sb.AppendLine($"
    "); - // Show legend by default for multi-series or pie/doughnut charts. - // Only hide if the OOXML chart explicitly has with . - var legendEl = chart?.GetFirstChild(); - var isPieOrDoughnut = chartType.Contains("pie") || chartType.Contains("doughnut"); - bool hasLegend; - if (legendEl != null) - { - var deleteEl = legendEl.GetFirstChild(); - hasLegend = deleteEl?.Val?.Value != true; - } - else - { - hasLegend = seriesList.Count > 1 || isPieOrDoughnut; - } - sb.AppendLine($" "); - - // Plot area background - if (plotFillColor != null) - sb.AppendLine($" "); + // Title + if (!string.IsNullOrEmpty(info.Title)) + sb.AppendLine($"
    {ChartSvgRenderer.HtmlEncode(info.Title)}
    "); - if (is3D && (chartType.Contains("pie") || chartType.Contains("doughnut"))) - { - RenderPie3DSvg(sb, seriesList, categories, seriesColors, svgW, chartSvgH); - } - else if (is3D && (chartType.Contains("column") || chartType.Contains("bar"))) - { - var isHorizontal = chartType.Contains("bar") && !chartType.Contains("column"); - var is3DStacked = chartType.Contains("stacked") || chartType.Contains("Stacked"); - if (is3DStacked) - { - // 3D stacked bars: fall through to 2D stacked renderer for correct stacking - var isPercent = chartType.Contains("percent") || chartType.Contains("Percent"); - svgRenderer.RenderBarChartSvg(sb, seriesList, categories, seriesColors, margin.left, margin.top, plotW, plotH, isHorizontal, true, isPercent); - } - else - { - RenderBar3DSvg(sb, seriesList, categories, seriesColors, margin.left, margin.top, plotW, plotH, isHorizontal); - } - } - else if (is3D && chartType.Contains("line")) - { - // 3D line: render with depth shadows - RenderLine3DSvg(sb, seriesList, categories, seriesColors, margin.left, margin.top, plotW, plotH); - } - else if (chartType.Contains("pie") || chartType.Contains("doughnut")) - { - var isDoughnut = chartType.Contains("doughnut"); - var holeSize = 0.0; - if (isDoughnut) - { - var holeSizeEl = plotArea!.Descendants().FirstOrDefault(); - holeSize = (holeSizeEl?.Val?.Value ?? 50) / 100.0; - } - svgRenderer.RenderPieChartSvg(sb, seriesList, categories, seriesColors, svgW, chartSvgH, holeSize, showValues); - } - else if (chartType.Contains("area")) - { - var areaStacked = chartType.Contains("stacked") || chartType.Contains("Stacked"); - var areaW = plotW - (int)(plotW * 0.03); - svgRenderer.RenderAreaChartSvg(sb, seriesList, categories, seriesColors, margin.left, margin.top, areaW, plotH, areaStacked); - } - else if (chartType == "combo") - { - svgRenderer.RenderComboChartSvg(sb, plotArea!, seriesList, categories, seriesColors, margin.left, margin.top, plotW, plotH); - } - else if (chartType.Contains("radar")) - { - svgRenderer.RenderRadarChartSvg(sb, seriesList, categories, seriesColors, svgW, chartSvgH, catLabelPx); - } - else if (chartType == "bubble") - { - svgRenderer.RenderBubbleChartSvg(sb, plotArea!, seriesList, categories, seriesColors, margin.left, margin.top, plotW, plotH); - } - else if (chartType == "stock") - { - svgRenderer.RenderStockChartSvg(sb, plotArea!, seriesList, categories, seriesColors, margin.left, margin.top, plotW, plotH); - } - else if (chartType.Contains("line") || chartType == "scatter") - { - var lineW = plotW - (int)(plotW * 0.03); - svgRenderer.RenderLineChartSvg(sb, seriesList, categories, seriesColors, margin.left, margin.top, lineW, plotH, showValues); - } - else - { - var isHorizontal = chartType.Contains("bar") && !chartType.Contains("column"); - var isStacked = chartType.Contains("stacked") || chartType.Contains("Stacked"); - var isPercent = chartType.Contains("percent") || chartType.Contains("Percent"); - svgRenderer.RenderBarChartSvg(sb, seriesList, categories, seriesColors, margin.left, margin.top, plotW, plotH, isHorizontal, isStacked, isPercent, - ooxmlAxisMax, ooxmlAxisMin, ooxmlMajorUnit, ooxmlGapWidth, valLabelPx, catLabelPx); - } + sb.AppendLine($" "); - // Axis titles inside SVG - if (!string.IsNullOrEmpty(valAxisTitle)) - sb.AppendLine($" {OfficeCli.Core.ChartSvgRenderer.HtmlEncode(valAxisTitle)}"); - if (!string.IsNullOrEmpty(catAxisTitle)) - sb.AppendLine($" {OfficeCli.Core.ChartSvgRenderer.HtmlEncode(catAxisTitle)}"); + renderer.RenderChartSvgContent(sb, info, svgW, chartSvgH, marginLeft, marginTop, marginRight, marginBottom); sb.AppendLine(" "); - // Legend — render when the OOXML chart contains a element - var legendFontSize = legendEl?.Descendants().FirstOrDefault()?.FontSize; - var legendSizeCss = legendFontSize?.HasValue == true ? $"{legendFontSize.Value / 100.0:0.##}pt" : "8pt"; - if (hasLegend) - { - sb.Append($"
    "); - if (isPieOrDoughnut && categories.Length > 0) - { - for (int i = 0; i < categories.Length; i++) - { - var color = i < seriesColors.Count ? seriesColors[i] : ChartColors[i % ChartColors.Length]; - sb.Append($"{OfficeCli.Core.ChartSvgRenderer.HtmlEncode(categories[i])}"); - } - } - else - { - for (int i = 0; i < seriesList.Count; i++) - { - sb.Append($"{OfficeCli.Core.ChartSvgRenderer.HtmlEncode(seriesList[i].name)}"); - } - } - sb.AppendLine("
    "); - } + renderer.RenderLegendHtml(sb, info, chartTextColor); sb.AppendLine("
    "); } - - - // ==================== 3D Chart Helpers ==================== - - /// Darken or lighten a hex color by a factor (0.0-2.0, 1.0=unchanged) - private static string AdjustColor(string hexColor, double factor) - { - var hex = hexColor.TrimStart('#'); - if (hex.Length < 6) return hexColor; - var r = (int)Math.Clamp(int.Parse(hex[..2], System.Globalization.NumberStyles.HexNumber) * factor, 0, 255); - var g = (int)Math.Clamp(int.Parse(hex[2..4], System.Globalization.NumberStyles.HexNumber) * factor, 0, 255); - var b = (int)Math.Clamp(int.Parse(hex[4..6], System.Globalization.NumberStyles.HexNumber) * factor, 0, 255); - return $"#{r:X2}{g:X2}{b:X2}"; - } - - // 3D isometric offsets (simulating ~30° viewing angle) - private const double Depth3D = 12; // pixel depth for 3D extrusion - private const double DxIso = 8; // horizontal offset for depth - private const double DyIso = -6; // vertical offset for depth (negative = upward) - - private void RenderBar3DSvg(StringBuilder sb, List<(string name, double[] values)> series, - string[] categories, List colors, int ox, int oy, int pw, int ph, bool horizontal) - { - var allValues = series.SelectMany(s => s.values).ToArray(); - if (allValues.Length == 0) return; - var (maxVal, _, _) = OfficeCli.Core.ChartSvgRenderer.ComputeNiceAxis(allValues.Max()); - var catCount = Math.Max(categories.Length, series.Max(s => s.values.Length)); - var serCount = series.Count; - - if (horizontal) - { - var hLabelMargin = 50; - var plotOx = ox + hLabelMargin; - var plotPw = pw - hLabelMargin; - var groupH = (double)ph / Math.Max(catCount, 1); - var barH = groupH * 0.5 / serCount; - var gap = groupH * 0.2; - - // Gridlines - for (int t = 1; t <= 4; t++) - { - var gx = plotOx + (double)plotPw * t / 4; - sb.AppendLine($" "); - } - // Axis lines - sb.AppendLine($" "); - sb.AppendLine($" "); - - for (int s = 0; s < serCount; s++) - { - var color = colors[s % colors.Count]; - var sideColor = AdjustColor(color, 0.7); - var topColor = AdjustColor(color, 1.3); - for (int c = 0; c < series[s].values.Length && c < catCount; c++) - { - var val = series[s].values[c]; - var barW = (val / maxVal) * plotPw; - var bx = plotOx; - var by = oy + c * groupH + gap + s * barH; - sb.AppendLine($" "); - sb.AppendLine($" "); - sb.AppendLine($" "); - // Value label - var vlabel = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"; - sb.AppendLine($" {vlabel}"); - } - } - for (int c = 0; c < catCount; c++) - { - var label = c < categories.Length ? categories[c] : ""; - var ly = oy + c * groupH + groupH / 2; - sb.AppendLine($" {OfficeCli.Core.ChartSvgRenderer.HtmlEncode(label)}"); - } - for (int t = 0; t <= 4; t++) - { - var val = maxVal * t / 4; - var label = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"; - var tx = plotOx + (double)plotPw * t / 4; - sb.AppendLine($" {label}"); - } - } - else - { - var groupW = (double)pw / Math.Max(catCount, 1); - var barW = groupW * 0.5 / serCount; - var gap = groupW * 0.2; - - // Gridlines - for (int t = 1; t <= 4; t++) - { - var gy = oy + ph - (double)ph * t / 4; - sb.AppendLine($" "); - } - // Axis lines - sb.AppendLine($" "); - sb.AppendLine($" "); - - for (int c = 0; c < catCount; c++) - { - for (int s = 0; s < serCount; s++) - { - if (c >= series[s].values.Length) continue; - var val = series[s].values[c]; - var color = colors[s % colors.Count]; - var sideColor = AdjustColor(color, 0.65); - var topColor = AdjustColor(color, 1.25); - var barH = (val / maxVal) * ph; - var bx = ox + c * groupW + gap + s * barW; - var by = oy + ph - barH; - - sb.AppendLine($" "); - sb.AppendLine($" "); - sb.AppendLine($" "); - // Value label above top face - var vlabel = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"; - sb.AppendLine($" {vlabel}"); - } - } - for (int c = 0; c < catCount; c++) - { - var label = c < categories.Length ? categories[c] : ""; - var lx = ox + c * groupW + groupW / 2; - sb.AppendLine($" {OfficeCli.Core.ChartSvgRenderer.HtmlEncode(label)}"); - } - for (int t = 0; t <= 4; t++) - { - var val = maxVal * t / 4; - var label = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}"; - var ty = oy + ph - (double)ph * t / 4; - sb.AppendLine($" {label}"); - } - } - } - - private void RenderPie3DSvg(StringBuilder sb, List<(string name, double[] values)> series, - string[] categories, List colors, int svgW, int svgH) - { - var values = series.FirstOrDefault().values ?? []; - if (values.Length == 0) return; - var total = values.Sum(); - if (total <= 0) return; - - var cx = svgW / 2.0; - var cy = svgH / 2.0; - var rx = Math.Min(svgW, svgH) * 0.35; // horizontal radius - var ry = rx * 0.55; // vertical radius (elliptical for 3D tilt) - var depth = rx * 0.15; // extrusion depth - var startAngle = -Math.PI / 2; - - // Render extrusion sides first (back to front) - // Sort slices by midpoint angle for correct z-ordering of sides - var slices = new List<(int idx, double start, double end, string color)>(); - var angle = startAngle; - for (int i = 0; i < values.Length; i++) - { - var sliceAngle = 2 * Math.PI * values[i] / total; - var color = i < colors.Count ? colors[i] : ChartColors[i % ChartColors.Length]; - slices.Add((i, angle, angle + sliceAngle, color)); - angle += sliceAngle; - } - - // Draw side extrusions for slices that face the viewer (bottom half) - foreach (var (idx, start, end, color) in slices) - { - var sideColor = AdjustColor(color, 0.6); - // Only draw sides for the visible portion (angles where sin > 0, i.e. bottom) - var visStart = Math.Max(start, 0); - var visEnd = Math.Min(end, Math.PI); - if (start < Math.PI && end > 0) - { - var clampedStart = Math.Max(start, -0.01); // slightly past top to avoid gaps - var clampedEnd = Math.Min(end, Math.PI + 0.01); - // Build side path: outer arc at bottom, lines down, inner arc at top+depth - var steps = Math.Max(8, (int)((clampedEnd - clampedStart) / 0.1)); - var pathPoints = new StringBuilder(); - pathPoints.Append($"M {cx + rx * Math.Cos(clampedStart):0.#},{cy + ry * Math.Sin(clampedStart):0.#} "); - for (int step = 0; step <= steps; step++) - { - var a = clampedStart + (clampedEnd - clampedStart) * step / steps; - pathPoints.Append($"L {cx + rx * Math.Cos(a):0.#},{cy + ry * Math.Sin(a):0.#} "); - } - for (int step = steps; step >= 0; step--) - { - var a = clampedStart + (clampedEnd - clampedStart) * step / steps; - pathPoints.Append($"L {cx + rx * Math.Cos(a):0.#},{cy + ry * Math.Sin(a) + depth:0.#} "); - } - pathPoints.Append("Z"); - sb.AppendLine($" "); - } - } - - // Draw top elliptical slices - startAngle = -Math.PI / 2; - for (int i = 0; i < values.Length; i++) - { - var sliceAngle = 2 * Math.PI * values[i] / total; - var endAngle = startAngle + sliceAngle; - var color = i < colors.Count ? colors[i] : ChartColors[i % ChartColors.Length]; - - if (values.Length == 1) - { - sb.AppendLine($" "); - } - else - { - var x1 = cx + rx * Math.Cos(startAngle); - var y1 = cy + ry * Math.Sin(startAngle); - var x2 = cx + rx * Math.Cos(endAngle); - var y2 = cy + ry * Math.Sin(endAngle); - var largeArc = sliceAngle > Math.PI ? 1 : 0; - sb.AppendLine($" "); - } - - // Label - var midAngle = startAngle + sliceAngle / 2; - var lx = cx + rx * 0.55 * Math.Cos(midAngle); - var ly = cy + ry * 0.55 * Math.Sin(midAngle); - var label = i < categories.Length ? categories[i] : ""; - if (!string.IsNullOrEmpty(label)) - sb.AppendLine($" {OfficeCli.Core.ChartSvgRenderer.HtmlEncode(label)}"); - - startAngle = endAngle; - } - } - - private void RenderLine3DSvg(StringBuilder sb, List<(string name, double[] values)> series, - string[] categories, List colors, int ox, int oy, int pw, int ph) - { - var allValues = series.SelectMany(s => s.values).ToArray(); - if (allValues.Length == 0) return; - var (maxVal, _, _) = OfficeCli.Core.ChartSvgRenderer.ComputeNiceAxis(allValues.Max()); - var catCount = Math.Max(categories.Length, series.Max(s => s.values.Length)); - - // Axis lines - sb.AppendLine($" "); - sb.AppendLine($" "); - - // Render series back to front - for (int s = series.Count - 1; s >= 0; s--) - { - var color = colors[s % colors.Count]; - var shadowColor = AdjustColor(color, 0.5); - var points = new List<(double x, double y)>(); - for (int c = 0; c < series[s].values.Length && c < catCount; c++) - { - var px = ox + (catCount > 1 ? (double)pw * c / (catCount - 1) : pw / 2.0); - var py = oy + ph - (series[s].values[c] / maxVal) * ph; - points.Add((px, py)); - } - if (points.Count > 1) - { - // Draw "ribbon" — a filled area between the line and its offset - var ribbon = new StringBuilder(); - ribbon.Append("M "); - for (int p = 0; p < points.Count; p++) - ribbon.Append($"{points[p].x:0.#},{points[p].y:0.#} L "); - for (int p = points.Count - 1; p >= 0; p--) - ribbon.Append($"{points[p].x + DxIso:0.#},{points[p].y + DyIso:0.#} L "); - ribbon.Length -= 2; // remove trailing " L" - ribbon.Append(" Z"); - sb.AppendLine($" "); - - // Main line - var linePoints = string.Join(" ", points.Select(p => $"{p.x:0.#},{p.y:0.#}")); - sb.AppendLine($" "); - foreach (var pt in points) - sb.AppendLine($" "); - } - } - - // Category labels - for (int c = 0; c < catCount; c++) - { - var label = c < categories.Length ? categories[c] : ""; - var lx = ox + (catCount > 1 ? (double)pw * c / (catCount - 1) : pw / 2.0); - sb.AppendLine($" {OfficeCli.Core.ChartSvgRenderer.HtmlEncode(label)}"); - } - } - - /// - /// Compute a "nice" axis scale with ~10-15% headroom above the data max. - /// Returns (niceMax, tickStep, nTicks). - /// } diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Css.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Css.cs index a2cf06b1b..7fc5d0ff1 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Css.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Css.cs @@ -1,7 +1,6 @@ // Copyright 2025 OfficeCli (officecli.ai) // SPDX-License-Identifier: Apache-2.0 -using System.Text; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Presentation; @@ -226,7 +225,11 @@ private static string GradientToCss(Drawing.GradientFill gradFill, Dictionary(); if (pathGrad != null) - return $"radial-gradient(circle, {string.Join(", ", cssStops)})"; + // OOXML with default fill rectangle fills to the shape + // bounds (last stop at the edge). CSS default is `farthest-corner`, which overshoots + // for square-ish shapes. `closest-side` lands the final stop at the nearer edge, + // matching Office's rendering for rectangular shapes. + return $"radial-gradient(circle closest-side, {string.Join(", ", cssStops)})"; var linear = gradFill.GetFirstChild(); var angleDeg = linear?.Angle?.HasValue == true ? linear.Angle.Value / 60000.0 : 90.0; @@ -246,7 +249,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; @@ -283,16 +287,20 @@ private static string DashTypeToSvgDasharray(string dashType, double strokeWidth var w = strokeWidth; return dashType switch { + // Dot is a visible short segment (length = stroke width) with linecap=butt + // so the dot renders as a square of side w. Prior implementation used "0.1" + // as a zero-length segment relying on stroke-linecap=round to paint a cap; + // that collapses when linecap=butt or when stroke-width rounds down. "solid" => "", - "dot" or "sysDot" => $"0.1 {w * 2.5:0.##}", + "dot" or "sysDot" => $"{w:0.##} {w * 2:0.##}", "dash" => $"{w * 4:0.##} {w * 3:0.##}", "lgDash" => $"{w * 8:0.##} {w * 3:0.##}", "sysDash" => $"{w * 3:0.##} {w * 1:0.##}", - "dashDot" => $"{w * 4:0.##} {w * 2:0.##} 0.1 {w * 2:0.##}", - "lgDashDot" => $"{w * 8:0.##} {w * 2:0.##} 0.1 {w * 2:0.##}", - "sysDashDot" => $"{w * 3:0.##} {w * 1.5:0.##} 0.1 {w * 1.5:0.##}", - "sysDashDotDot" => $"{w * 3:0.##} {w * 1.5:0.##} 0.1 {w * 1.5:0.##} 0.1 {w * 1.5:0.##}", - "lgDashDotDot" => $"{w * 8:0.##} {w * 2:0.##} 0.1 {w * 2:0.##} 0.1 {w * 2:0.##}", + "dashDot" => $"{w * 4:0.##} {w * 2:0.##} {w:0.##} {w * 2:0.##}", + "lgDashDot" => $"{w * 8:0.##} {w * 2:0.##} {w:0.##} {w * 2:0.##}", + "sysDashDot" => $"{w * 3:0.##} {w * 1.5:0.##} {w:0.##} {w * 1.5:0.##}", + "sysDashDotDot" => $"{w * 3:0.##} {w * 1.5:0.##} {w:0.##} {w * 1.5:0.##} {w:0.##} {w * 1.5:0.##}", + "lgDashDotDot" => $"{w * 8:0.##} {w * 2:0.##} {w:0.##} {w * 2:0.##} {w:0.##} {w * 2:0.##}", _ => "" }; } @@ -335,9 +343,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 +388,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 + } } } @@ -410,17 +430,17 @@ private static string EffectListToReflectionCss(Drawing.EffectList? effectList) // EndAlpha: final opacity (thousandths of a percent) var endOpacity = refl.EndAlpha?.HasValue == true ? refl.EndAlpha.Value / 100000.0 : 0.0; - // EndPosition: how much of the shape height is reflected (thousandths of a percent → CSS percentage) - // This controls where the gradient reaches full transparency. - var endPos = refl.EndPosition?.HasValue == true ? refl.EndPosition.Value / 1000.0 : 90.0; + // EndPosition: how much of the shape height is reflected (thousandths of a percent → CSS percentage). + // In -webkit-box-reflect, 0% is the top of the reflection (closest to the source shape) and + // 100% is the far edge. The reflection should be most opaque at the top (startOpacity) and + // fade to endOpacity at endPos%, then fully transparent beyond endPos. + var endPos = refl.EndPosition?.HasValue == true ? Math.Clamp(refl.EndPosition.Value / 1000.0, 0, 100) : 90.0; - // Map endPos to the gradient: the transparent region starts at (100 - endPos)% of the reflected image - // For endPos=55 (tight): fade starts early → reflection visible ~55% - // For endPos=90 (half): fade occupies most → reflection visible ~90% - // For endPos=100 (full): full height reflection - var fadeStartPct = Math.Max(0, 100.0 - endPos); + var startStop = $"rgba(255,255,255,{startOpacity:0.###}) 0%"; + var endStop = $"rgba(255,255,255,{endOpacity:0.###}) {endPos:0.#}%"; + var tailStop = endPos < 100 ? $",transparent 100%" : ""; - return $"-webkit-box-reflect:below {distPt:0.##}pt linear-gradient(transparent {fadeStartPct:0.#}%,rgba(255,255,255,{startOpacity:0.##}) {100:0.#}%)"; + return $"-webkit-box-reflect:below {distPt:0.##}pt linear-gradient({startStop},{endStop}{tailStop})"; } // ==================== CSS Helper: Preset Geometry ==================== @@ -441,9 +461,93 @@ private static string PlusPolygon(long w, long h) private static string PresetGeometryToCss(string preset) => PresetGeometryToCss(preset, 0, 0, null); + /// + /// Read an adjustment value from PresetGeometry's AdjustValueList (OOXML "val NNNNN" formula). + /// + private static long ReadAdjValueCss(Drawing.PresetGeometry? presetGeom, int index, long defaultValue) + { + var avList = presetGeom?.GetFirstChild(); + if (avList == null) return defaultValue; + var guides = avList.Elements().ToList(); + if (index >= guides.Count) return defaultValue; + var formula = guides[index].Formula?.Value; + if (formula != null && formula.StartsWith("val ")) + { + if (long.TryParse(formula.AsSpan(4), out var parsed)) + return parsed; + } + return defaultValue; + } + + /// + /// Build a clip-path polygon for rightArrow honoring OOXML avLst. + /// adj1 = tail height relative to shape height (0..100000, default 50000 = 50%) + /// adj2 = head width relative to min(w,h) (0..100000, default 50000) + /// + private static string RightArrowPolygon(long widthEmu, long heightEmu, Drawing.PresetGeometry? presetGeom) + { + var adj1 = ReadAdjValueCss(presetGeom, 0, 50000); + var adj2 = ReadAdjValueCss(presetGeom, 1, 50000); + // Clamp avLst values to sane range + if (adj1 < 0) adj1 = 0; if (adj1 > 100000) adj1 = 100000; + if (adj2 < 0) adj2 = 0; if (adj2 > 100000) adj2 = 100000; + + // Tail vertical extent (centered on midline): adj1 fraction of height + var tailTop = (100000.0 - adj1) / 2000.0; // e.g. 25% + var tailBot = 100.0 - tailTop; // e.g. 75% + + // Head width measured from the right edge. Fallback to square assumption if dims missing. + double headStartX; + if (widthEmu > 0 && heightEmu > 0) + { + var minSide = Math.Min(widthEmu, heightEmu); + var headWidthEmu = minSide * adj2 / 100000.0; + if (headWidthEmu > widthEmu) headWidthEmu = widthEmu; + headStartX = (widthEmu - headWidthEmu) / (double)widthEmu * 100.0; + } + else + { + headStartX = 100.0 - adj2 / 1000.0; // fallback: treat adj2 as % of width + } + + return $"clip-path:polygon(0 {tailTop:0.##}%,{headStartX:0.##}% {tailTop:0.##}%,{headStartX:0.##}% 0,100% 50%,{headStartX:0.##}% 100%,{headStartX:0.##}% {tailBot:0.##}%,0 {tailBot:0.##}%)"; + } + + /// + /// Build a clip-path polygon for a 5-point star honoring OOXML adj value. + /// adj = inner radius fraction * 50000 (default 19098, giving inner ratio ~0.382). + /// Star is stretched to fill bounding box (outer radius = min(w,h)/2 scaled independently to w,h). + /// + private static string Star5Polygon(Drawing.PresetGeometry? presetGeom) + { + var adj = ReadAdjValueCss(presetGeom, 0, 19098); + if (adj < 0) adj = 0; if (adj > 50000) adj = 50000; + var innerRatio = adj / 50000.0; + + var pts = new List(); + // 10 points around the center, alternating outer (radius=0.5) and inner (radius=0.5*innerRatio). + // Start at top (angle = -90°), step = 36° = PI/5. Scale x,y to 0..100%. + for (int i = 0; i < 10; i++) + { + var angle = -Math.PI / 2 + Math.PI * i / 5; + var r = (i % 2 == 0) ? 0.5 : 0.5 * innerRatio; + var x = 50.0 + r * Math.Cos(angle) * 100.0; + var y = 50.0 + r * Math.Sin(angle) * 100.0; + pts.Add($"{x:0.##}% {y:0.##}%"); + } + return $"clip-path:polygon({string.Join(",", pts)})"; + } + private static string PresetGeometryToCss(string preset, long widthEmu, long heightEmu, Drawing.PresetGeometry? presetGeom) { + // Parametric rightArrow honoring avLst + if (preset == "rightArrow") + return RightArrowPolygon(widthEmu, heightEmu, presetGeom); + // Parametric star5 honoring avLst + if (preset == "star5") + return Star5Polygon(presetGeom); + // Calculate roundRect corner radius from avLst or default (16.667% of shorter side) if (preset is "roundRect" or "round1Rect" or "round2SameRect" or "round2DiagRect") { diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Shapes.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Shapes.cs index 5bc6bdea4..641b40b08 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Shapes.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Shapes.cs @@ -19,10 +19,47 @@ 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; + // Shape-level hyperlink → wrap rendered shape
    in for clickability in HTML preview. + // Only external URLs are wrapped; internal slide-jump links (ppaction://hlinksldjump) are + // skipped because there is no corresponding external href in this static HTML context. + string? shapeHrefUrl = null; + string? shapeHrefTooltip = null; + { + var nvHlink = shape.NonVisualShapeProperties?.NonVisualDrawingProperties + ?.GetFirstChild(); + if (nvHlink != null) + { + shapeHrefTooltip = nvHlink.Tooltip?.Value; + var action = nvHlink.Action?.Value; + var hlId = nvHlink.Id?.Value; + // Skip if this is a slide-jump action (no external URL target) + if (string.IsNullOrEmpty(action) || !action.Contains("hlink")) + { + // Plain external: no action + r:id → look up external relationship + if (!string.IsNullOrEmpty(hlId)) + { + try + { + var rel = part.HyperlinkRelationships.FirstOrDefault(r => r.Id == hlId); + if (rel?.Uri != null) shapeHrefUrl = rel.Uri.ToString(); + } + catch { } + } + } + else if (action.Contains("hlinksldjump")) + { + // Internal slide-jump — deliberately not wrapped (no navigable href in static HTML) + shapeHrefUrl = null; + } + } + } + long x, y, cx, cy; if (overridePos != null) { @@ -183,7 +220,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 +235,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) @@ -236,6 +273,14 @@ private static void RenderShape(StringBuilder sb, Shape shape, OpenXmlPart part, || shape.ShapeProperties?.GetFirstChild() != null; var shapeClass = hasFillBg ? "shape has-fill" : "shape"; + // Open wrapper for shape-level hyperlink (before the shape
    ) + if (!string.IsNullOrEmpty(shapeHrefUrl)) + { + var tooltipAttr = !string.IsNullOrEmpty(shapeHrefTooltip) + ? $" title=\"{HtmlEncode(shapeHrefTooltip!)}\"" : ""; + sb.Append($" "); + } + if (!string.IsNullOrEmpty(clipPathCss)) { // For clip-path shapes: move fill to a clipped background layer, keep text unclipped @@ -252,7 +297,9 @@ private static void RenderShape(StringBuilder sb, Shape shape, OpenXmlPart part, else outerStyles.Add(s); } - sb.Append($"
    "); + // When wrapped in a link, add cursor:pointer to the shape
    itself + if (!string.IsNullOrEmpty(shapeHrefUrl)) outerStyles.Add("cursor:pointer"); + sb.Append($"
    "); // Fill layer (clipped) if (fillStyles.Count > 0) sb.Append($"
    "); @@ -266,13 +313,14 @@ private static void RenderShape(StringBuilder sb, Shape shape, OpenXmlPart part, var dashAttr = !string.IsNullOrEmpty(dashArr) ? $" stroke-dasharray=\"{dashArr}\"" : ""; var safeColor = CssSanitizeColor(bc); sb.Append($""); - sb.Append($""); + sb.Append($""); sb.Append(""); } } else { - sb.Append($"
    "); + if (!string.IsNullOrEmpty(shapeHrefUrl)) styles.Add("cursor:pointer"); + sb.Append($"
    "); } // Text content @@ -312,7 +360,7 @@ private static void RenderShape(StringBuilder sb, Shape shape, OpenXmlPart part, var polyStr = clipPathCss["clip-path:polygon(".Length..^1]; var svgPoints = polyStr.Replace("%", ""); sb.Append($""); - sb.Append($""); + sb.Append($""); sb.Append(""); } else if (!string.IsNullOrEmpty(borderRadiusCss)) @@ -321,26 +369,33 @@ private static void RenderShape(StringBuilder sb, Shape shape, OpenXmlPart part, var rxMatch = System.Text.RegularExpressions.Regex.Match(borderRadiusCss, @"border-radius:([\d.]+)"); var rx = rxMatch.Success ? rxMatch.Groups[1].Value : "0"; sb.Append($""); - sb.Append($""); + sb.Append($""); sb.Append(""); } else if (presetGeom?.Preset?.InnerText == "ellipse") { - // Ellipse — use SVG ellipse - sb.Append($""); - sb.Append($""); + // Ellipse — size in pt so stroke-width matches CSS border path. + // CONSISTENCY(shape-stroke-unit): keep stroke-width in pt across solid/non-solid paths. + sb.Append($""); + sb.Append($""); sb.Append(""); } else { - // Plain rect — use SVG rect - sb.Append($""); - sb.Append($""); + // Plain rect — use SVG rect sized in pt so stroke-width matches the CSS + // `border:Npt solid` path (same visual weight). Inset by bw/2 so the stroke + // sits entirely inside the content box (box-sizing:border-box equivalent). + // CONSISTENCY(shape-stroke-unit): keep stroke-width in pt across solid/non-solid paths. + sb.Append($""); + sb.Append($""); sb.Append(""); } } - sb.AppendLine("
    "); + sb.Append("
    "); + if (!string.IsNullOrEmpty(shapeHrefUrl)) + sb.Append("
    "); + sb.AppendLine(); } // ==================== Placeholder Position Inheritance ==================== @@ -511,6 +566,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 +678,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 +731,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; @@ -689,24 +747,42 @@ private static void RenderPicture(StringBuilder sb, Picture pic, OpenXmlPart sli var base64 = Convert.ToBase64String(ms.ToArray()); var contentType = SanitizeContentType(imgPart.ContentType ?? "image/png"); - // Crop + // Crop — PowerPoint srcRect semantics: select a rectangular region of the + // source image, then scale that region to fill the container. + // CSS equivalent: render as a
    with background-image, setting + // background-size = container / visibleFraction and background-position + // so the srcRect region aligns to the container edge. var srcRect = blipFill?.GetFirstChild(); - var imgStyles = new List(); + double srcL = 0, srcT = 0, srcR = 0, srcB = 0; if (srcRect != null) { - var cl = (srcRect.Left?.Value ?? 0) / 1000.0; - var ct = (srcRect.Top?.Value ?? 0) / 1000.0; - var cr = (srcRect.Right?.Value ?? 0) / 1000.0; - var cb = (srcRect.Bottom?.Value ?? 0) / 1000.0; - if (cl != 0 || ct != 0 || cr != 0 || cb != 0) - { - // Use clip-path for cropping - imgStyles.Add($"clip-path:inset({ct:0.##}% {cr:0.##}% {cb:0.##}% {cl:0.##}%)"); - } + srcL = (srcRect.Left?.Value ?? 0) / 100000.0; + srcT = (srcRect.Top?.Value ?? 0) / 100000.0; + srcR = (srcRect.Right?.Value ?? 0) / 100000.0; + srcB = (srcRect.Bottom?.Value ?? 0) / 100000.0; + } + var hasCrop = srcL != 0 || srcT != 0 || srcR != 0 || srcB != 0; + if (hasCrop) + { + var visibleW = Math.Max(1 - srcL - srcR, 0.0001); + var visibleH = Math.Max(1 - srcT - srcB, 0.0001); + var bgSizeW = 100.0 / visibleW; + var bgSizeH = 100.0 / visibleH; + // background-position percentage semantics: pos% aligns pos%-of-image with pos%-of-container. + // To align srcRect (image region starting at fraction L) with container's left edge: + // pos_x% = L / (srcL + srcR) * 100 (denominator = 1 - visibleW) + // Fallback to 0 when there's no crop on that axis (denominator == 0). + var denomX = srcL + srcR; + var denomY = srcT + srcB; + var bgPosX = denomX > 0 ? (srcL / denomX) * 100.0 : 0.0; + var bgPosY = denomY > 0 ? (srcT / denomY) * 100.0 : 0.0; + var bgStyle = $"width:100%;height:100%;background-image:url(data:{contentType};base64,{base64});background-repeat:no-repeat;background-size:{bgSizeW:0.##}% {bgSizeH:0.##}%;background-position:{bgPosX:0.##}% {bgPosY:0.##}%"; + sb.Append($"
    "); + } + else + { + sb.Append($""); } - - var imgStyle = imgStyles.Count > 0 ? $" style=\"{string.Join(";", imgStyles)}\"" : ""; - sb.Append($""); } catch { @@ -720,7 +796,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; @@ -820,32 +896,85 @@ private static void RenderConnector(StringBuilder sb, ConnectionShape cxn, Dicti var arrowSize = Math.Max(3, lineWidth * 3); var defs = new StringBuilder(); defs.Append(""); + // Both markers use a right-pointing triangle with tip at (arrowSize, arrowSize/2). + // For marker-start we use orient="auto-start-reverse" so SVG flips the right-pointing + // triangle to point outward (leftward) at the line's start. Authoring both markers + // with the same geometry avoids a past bug where the head marker was authored + // leftward-pointing and the reverse flipped it inward on straight connectors. if (hasHead) { - defs.Append($""); + defs.Append($""); markerStartAttr = " marker-start=\"url(#ah)\""; } if (hasTail) { - defs.Append($""); + defs.Append($""); markerEndAttr = " marker-end=\"url(#at)\""; } defs.Append(""); markerDefs = defs.ToString(); } - sb.AppendLine($"
    "); - sb.AppendLine($" "); - if (!string.IsNullOrEmpty(markerDefs)) - sb.AppendLine($" {markerDefs}"); - sb.AppendLine($" "); - sb.AppendLine(" "); + // Branch on preset geometry: straightConnectorN -> line; bentConnectorN -> polyline; + // curvedConnectorN -> cubic bezier path. Falls back to straight line for unknown presets. + var prstGeom = cxn.ShapeProperties?.GetFirstChild(); + var preset = prstGeom?.Preset?.HasValue == true ? prstGeom.Preset.InnerText : "straightConnector1"; + + // CONSISTENCY(shape-stroke-unit): stroke-width in pt matches CSS border path (see R3 fix). + var strokeAttrs = $"stroke=\"{safeColor}\" stroke-width=\"{lineWidth:0.##}pt\" fill=\"none\"{dashAttr}{markerStartAttr}{markerEndAttr}"; + + var dataPathAttr = string.IsNullOrEmpty(dataPath) ? "" : $" data-path=\"{HtmlEncode(dataPath)}\""; + sb.AppendLine($"
    "); + + if (preset.StartsWith("bentConnector", StringComparison.Ordinal)) + { + // Bent connectors: right-angle polyline. Use viewBox=0..100 so stretched + // preserveAspectRatio=none fills the container. + // bentConnector2: single 90-degree bend (2 segments, 3 points). + // bentConnector3 (default): 3 segments with mid bend — (0,0) -> (50,0) -> (50,100) -> (100,100). + // bentConnector4/5: approximate with 25/75 splits when no adjustments set. + string points = preset switch + { + "bentConnector2" => "0,0 100,0 100,100", + "bentConnector4" or "bentConnector5" => "0,0 25,0 25,50 75,50 75,100 100,100", + _ => "0,0 50,0 50,100 100,100", // bentConnector3 + }; + sb.AppendLine(" "); + if (!string.IsNullOrEmpty(markerDefs)) + sb.AppendLine($" {markerDefs}"); + sb.AppendLine($" "); + sb.AppendLine(" "); + } + else if (preset.StartsWith("curvedConnector", StringComparison.Ordinal)) + { + // Curved connectors: cubic bezier S-curve. Author in 0..100 viewBox. + // curvedConnector3 default: M 0,0 C 50,0 50,100 100,100 (horizontal-entry S). + string d = preset switch + { + "curvedConnector2" => "M 0,0 Q 100,0 100,100", + "curvedConnector4" or "curvedConnector5" => "M 0,0 C 25,0 25,50 50,50 C 75,50 75,100 100,100", + _ => "M 0,0 C 50,0 50,100 100,100", // curvedConnector3 + }; + sb.AppendLine(" "); + if (!string.IsNullOrEmpty(markerDefs)) + sb.AppendLine($" {markerDefs}"); + sb.AppendLine($" "); + sb.AppendLine(" "); + } + else + { + sb.AppendLine(" "); + if (!string.IsNullOrEmpty(markerDefs)) + sb.AppendLine($" {markerDefs}"); + sb.AppendLine($" "); + sb.AppendLine(" "); + } sb.AppendLine("
    "); } // ==================== 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 +992,19 @@ 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)}\""; + // CONSISTENCY(group-rotation): match single-shape rotation idiom from RenderShape + // (transform:rotate(Ndeg)). OOXML group rotation rotates children as a composite + // around the group's bounding-box center; CSS default transform-origin (50% 50%) + // matches this. + var grpTransform = ""; + if (grpXfrm?.Rotation != null && grpXfrm.Rotation.Value != 0) + grpTransform = $";transform:rotate({grpXfrm.Rotation.Value / 60000.0:0.##}deg)"; + sb.AppendLine($"
    "); foreach (var child in grp.ChildElements) { @@ -947,7 +1088,11 @@ private void RenderNestedGroup(StringBuilder sb, GroupShape grp, SlidePart slide var offX = childOff?.X?.Value ?? 0; var offY = childOff?.Y?.Value ?? 0; - sb.AppendLine($"
    "); + // CONSISTENCY(group-rotation): same idiom as RenderGroup + var grpTransform = ""; + if (grpXfrm?.Rotation != null && grpXfrm.Rotation.Value != 0) + grpTransform = $";transform:rotate({grpXfrm.Rotation.Value / 60000.0:0.##}deg)"; + 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..6fcfb3836 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Tables.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Tables.cs @@ -3,7 +3,6 @@ using System.Text; using DocumentFormat.OpenXml; -using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Presentation; using OfficeCli.Core; using Drawing = DocumentFormat.OpenXml.Drawing; @@ -14,8 +13,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 +35,7 @@ private static void RenderTable(StringBuilder sb, GraphicFrame gf, Dictionary"); + sb.AppendLine($"
    "); sb.AppendLine(" "); // Column widths @@ -107,8 +107,7 @@ private static void RenderTable(StringBuilder sb, GraphicFrame gf, Dictionary()?.Typeface?.Value @@ -120,25 +119,39 @@ private static void RenderTable(StringBuilder sb, GraphicFrame gf, Dictionary(); - var borderRight = tcPr.GetFirstChild(); - var borderTop = tcPr.GetFirstChild(); - var borderBottom = tcPr.GetFirstChild(); - var bl = TableBorderToCss(borderLeft, themeColors); - var br = TableBorderToCss(borderRight, themeColors); - var bt = TableBorderToCss(borderTop, themeColors); - var bb = TableBorderToCss(borderBottom, themeColors); - if (bl != null) cellStyles.Add($"border-left:{bl}"); - if (br != null) cellStyles.Add($"border-right:{br}"); - if (bt != null) cellStyles.Add($"border-top:{bt}"); - if (bb != null) cellStyles.Add($"border-bottom:{bb}"); - // If no explicit borders at all, render a thin default border - if (bl == null && br == null && bt == null && bb == null) - cellStyles.Add("border:1px solid rgba(0,0,0,0.2)"); - } + // Cell borders (per-edge). When the edge is absent from tcPr, + // fall back to Office's implicit default: 1pt solid black hairline. + // An explicit /// with still + // yields "none" via TableBorderToCss and is preserved as-is. + // CONSISTENCY(table-borders): matches the `Npt solid #color` idiom + // already produced by TableBorderToCss. + const string defaultBorder = "1pt solid #000000"; + var borderLeft = tcPr?.GetFirstChild(); + var borderRight = tcPr?.GetFirstChild(); + var borderTop = tcPr?.GetFirstChild(); + var borderBottom = tcPr?.GetFirstChild(); + var bl = TableBorderToCss(borderLeft, themeColors) ?? defaultBorder; + var br = TableBorderToCss(borderRight, themeColors) ?? defaultBorder; + var bt = TableBorderToCss(borderTop, themeColors) ?? defaultBorder; + var bb = TableBorderToCss(borderBottom, themeColors) ?? defaultBorder; + cellStyles.Add($"border-left:{bl}"); + cellStyles.Add($"border-right:{br}"); + cellStyles.Add($"border-top:{bt}"); + cellStyles.Add($"border-bottom:{bb}"); + + // Diagonal borders ( / ) — HTML has no + // native diagonal-border; emit an absolute-positioned inline + // SVG overlay inside the "); + var diagOverlay = ""; + if (hasDiag) + { + var diagLines = new StringBuilder(); + if (tlBrCss != null && tlBrCss != "none") + { + var (stroke, widthPt) = ParseBorderCssForSvg(tlBrCss); + diagLines.Append($""); + } + if (blTrCss != null && blTrCss != "none") + { + var (stroke, widthPt) = ParseBorderCssForSvg(blTrCss); + diagLines.Append($""); + } + diagOverlay = $"{diagLines}"; + } + + sb.AppendLine($" {diagOverlay}{HtmlEncode(cellText)}"); } sb.AppendLine(" "); rowIndex++; @@ -223,6 +253,10 @@ private static void RenderTable(StringBuilder sb, GraphicFrame gf, Dictionary "dashed", "dot" or "sysDot" => "dotted", + "dashDot" or "lgDashDot" or "lgDashDotDot" + or "sysDashDot" or "sysDashDotDot" => "dashed", _ => "solid" }; } @@ -241,6 +279,29 @@ private static void RenderTable(StringBuilder sb, GraphicFrame gf, Dictionary + /// Parse the "Npt style #color" shorthand produced by TableBorderToCss + /// back into (stroke-color, stroke-width-in-pt) for SVG diagonal lines. + /// Format is deterministic: "{w:0.##}pt {solid|dashed|dotted} {color}". + /// + private static (string stroke, double widthPt) ParseBorderCssForSvg(string css) + { + var parts = css.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries); + double widthPt = 1.0; + string stroke = "#000000"; + if (parts.Length >= 1) + { + var w = parts[0]; + if (w.EndsWith("pt", StringComparison.OrdinalIgnoreCase)) + w = w[..^2]; + double.TryParse(w, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out widthPt); + } + if (parts.Length >= 3) + stroke = parts[2]; + return (stroke, widthPt); + } + /// /// Returns (background, foreground) CSS colors for a table style based on row position. /// Colors are derived from theme colors with lumMod/lumOff transforms matching PowerPoint's diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Text.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Text.cs index 013700ef6..3878c89e5 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Text.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Text.cs @@ -17,6 +17,11 @@ public partial class PowerPointHandler private static void RenderTextBody(StringBuilder sb, OpenXmlElement textBody, Dictionary themeColors, Shape? placeholderShape = null, OpenXmlPart? placeholderPart = null) { + // Per-textbody auto-number counters, keyed by scheme type + paragraph level. + // Resets when switching type/level. Paragraphs aren't wrapped in
      , so + // we count manually and emit the numeric glyph inline. + var autoNumCounters = new Dictionary(); + string? lastAutoKey = null; foreach (var para in textBody.Elements()) { // Resolve per-paragraph font size based on paragraph level @@ -65,12 +70,81 @@ private static void RenderTextBody(StringBuilder sb, OpenXmlElement textBody, Di var bulletAuto = pProps?.GetFirstChild(); var hasBullet = bulletChar != null || bulletAuto != null; + // Resolve auto-numbered glyph (e.g. "1.", "a.", "iv.") and track per-scheme counter. + string? autoNumGlyph = null; + if (bulletAuto != null) + { + int paraLevel = pProps?.Level?.Value ?? 0; + string schemeKey = (bulletAuto.Type?.HasValue == true ? bulletAuto.Type.Value.ToString() : "arabicPeriod") + "@" + paraLevel; + if (lastAutoKey != schemeKey) + { + autoNumCounters[schemeKey] = 0; + lastAutoKey = schemeKey; + } + int startAt = bulletAuto.StartAt?.Value ?? 1; + int n = autoNumCounters.TryGetValue(schemeKey, out var c) ? c : 0; + int index = (n == 0 ? startAt : startAt + n); + autoNumCounters[schemeKey] = n + 1; + autoNumGlyph = FormatAutoNumberGlyph(bulletAuto.Type?.HasValue == true ? bulletAuto.Type.Value : Drawing.TextAutoNumberSchemeValues.ArabicPeriod, index); + } + else + { + lastAutoKey = null; + } + sb.Append($"
      "); if (hasBullet) { - var bullet = bulletChar ?? "\u2022"; - sb.Append($"{HtmlEncode(bullet)} "); + var bullet = autoNumGlyph ?? bulletChar ?? "\u2022"; + 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"); + } + } + + // Hanging-indent tab gap: size bullet span to match the negative + // indent so text starts at marL regardless of bullet glyph width. + // OOXML marL (e.g. 457200 EMU = 0.5in = 36pt) paired with indent + // = -marL creates the hanging layout; we mirror it in CSS by + // making the bullet an inline-block of width |indent|. + long indentEmu = pProps?.Indent?.Value ?? 0; + if (indentEmu < 0) + { + var gapPt = Units.EmuToPt(-indentEmu); + buStyles.Add($"display:inline-block"); + buStyles.Add($"width:{gapPt}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 @@ -110,7 +184,7 @@ private static void RenderTextBody(StringBuilder sb, OpenXmlElement textBody, Di { foreach (var run in runs) { - RenderRun(sb, run, themeColors, defaultFontSizeHundredths); + RenderRun(sb, run, themeColors, defaultFontSizeHundredths, placeholderPart); } } @@ -123,7 +197,7 @@ private static void RenderTextBody(StringBuilder sb, OpenXmlElement textBody, Di } private static void RenderRun(StringBuilder sb, Drawing.Run run, Dictionary themeColors, - int? defaultFontSizeHundredths = null) + int? defaultFontSizeHundredths = null, OpenXmlPart? part = null) { var text = run.Text?.Text ?? ""; if (string.IsNullOrEmpty(text)) return; @@ -131,6 +205,23 @@ private static void RenderRun(StringBuilder sb, Drawing.Run run, Dictionary(); var rp = run.RunProperties; + // Hyperlink resolution (RUN-level only; shape-level deferred). + // Read from run.RunProperties, resolve relationship ID + // via containing part's HyperlinkRelationships to an external URI. + string? hyperlinkUrl = null; + bool hasExplicitColor = rp?.GetFirstChild() != null; + bool hasExplicitUnderline = rp?.Underline?.HasValue == true; + var hlinkClick = rp?.GetFirstChild(); + if (hlinkClick?.Id?.Value is string relId && part != null) + { + try + { + var rel = part.HyperlinkRelationships.FirstOrDefault(r => r.Id == relId); + if (rel?.Uri != null) hyperlinkUrl = rel.Uri.ToString(); + } + catch { } + } + if (rp != null) { // Font @@ -155,11 +246,93 @@ private static void RenderRun(StringBuilder sb, Drawing.Run run, Dictionary(); @@ -195,18 +368,80 @@ private static void RenderRun(StringBuilder sb, Drawing.Run run, Dictionary() - .Where(r => r == run) - .Select(_ => run.Parent) - .FirstOrDefault() - ?.GetFirstChild(); - // Actually check run's parent paragraph for hyperlinks on this run - // Not critical for preview, skip for simplicity - - if (styles.Count > 0) - sb.Append($"{HtmlEncode(text)}"); + // Auto-style hyperlink runs that lack explicit color/underline. Uses + // theme-less fallback #0563C1 (PowerPoint default hyperlink color). + // Shape-level hyperlinks are deferred (R14-supplemental). + if (hlinkClick != null) + { + if (!hasExplicitColor) styles.Add("color:#0563C1"); + if (!hasExplicitUnderline) styles.Add("text-decoration:underline"); + } + + string inner = styles.Count > 0 + ? $"{HtmlEncode(text)}" + : HtmlEncode(text); + + if (!string.IsNullOrEmpty(hyperlinkUrl)) + { + sb.Append($"{inner}"); + } + else + { + sb.Append(inner); + } + } + + // Format an auto-numbered bullet glyph (e.g. "1.", "(a)", "iv)") for a given + // OOXML scheme and 1-based index. Covers the common schemes emitted by + // ApplyListStyle; unsupported schemes fall back to "N." arabic-period. + private static string FormatAutoNumberGlyph(Drawing.TextAutoNumberSchemeValues scheme, int n) + { + string key = scheme.ToString(); + // Decompose the scheme name — it's of form "{alpha|AlphaUc|romanLc|RomanUc|arabic|...}{Period|ParenBoth|ParenR|Plain|Minus}" + // Use InnerText style match when possible + string body; + if (key.StartsWith("alphaLc", StringComparison.OrdinalIgnoreCase) || key.StartsWith("AlphaLc", StringComparison.OrdinalIgnoreCase)) + body = ToAlpha(n, upper: false); + else if (key.StartsWith("alphaUc", StringComparison.OrdinalIgnoreCase) || key.StartsWith("AlphaUc", StringComparison.OrdinalIgnoreCase)) + body = ToAlpha(n, upper: true); + else if (key.StartsWith("romanLc", StringComparison.OrdinalIgnoreCase) || key.StartsWith("RomanLc", StringComparison.OrdinalIgnoreCase)) + body = ToRoman(n).ToLowerInvariant(); + else if (key.StartsWith("romanUc", StringComparison.OrdinalIgnoreCase) || key.StartsWith("RomanUc", StringComparison.OrdinalIgnoreCase)) + body = ToRoman(n); else - sb.Append(HtmlEncode(text)); + body = n.ToString(); + + if (key.EndsWith("Period", StringComparison.OrdinalIgnoreCase)) return body + "."; + if (key.EndsWith("ParenBoth", StringComparison.OrdinalIgnoreCase)) return "(" + body + ")"; + if (key.EndsWith("ParenR", StringComparison.OrdinalIgnoreCase)) return body + ")"; + if (key.EndsWith("Minus", StringComparison.OrdinalIgnoreCase)) return "- " + body + " -"; + if (key.EndsWith("Plain", StringComparison.OrdinalIgnoreCase)) return body; + return body + "."; + } + + private static string ToAlpha(int n, bool upper) + { + if (n <= 0) n = 1; + var sb = new StringBuilder(); + while (n > 0) + { + n--; + sb.Insert(0, (char)((upper ? 'A' : 'a') + (n % 26))); + n /= 26; + } + return sb.ToString(); + } + + private static string ToRoman(int n) + { + if (n <= 0) return n.ToString(); + int[] values = { 1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1 }; + string[] numerals = { "M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I" }; + var sb = new StringBuilder(); + for (int i = 0; i < values.Length; i++) + { + while (n >= values[i]) { sb.Append(numerals[i]); n -= values[i]; } + } + return sb.ToString(); } } 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.Hyperlinks.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Hyperlinks.cs index 0f14f447d..e039abcb4 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Hyperlinks.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Hyperlinks.cs @@ -1,6 +1,7 @@ // Copyright 2025 OfficeCli (officecli.ai) // SPDX-License-Identifier: Apache-2.0 +using System.Text.RegularExpressions; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Presentation; using Drawing = DocumentFormat.OpenXml.Drawing; @@ -11,47 +12,147 @@ public partial class PowerPointHandler { // ==================== Hyperlink helpers ==================== + // Result of resolving a user-supplied link string. + // Exactly one of (Id, Action) corresponds to a jump; Id may be null when Action is a named + // action that requires no relationship (firstslide, lastslide, nextslide, previousslide). + private readonly struct HyperlinkTarget + { + public string? Id { get; init; } + public string? Action { get; init; } + public bool IsExternal { get; init; } + } + + /// + /// Resolve a user-supplied link string into a hyperlink target. Returns null to mean "remove". + /// Supports: + /// - Absolute URI (https://, mailto:, etc.) → external relationship + /// - slide[N] → internal slide jump (ppaction://hlinksldjump) + /// - firstslide/lastslide/nextslide/previousslide → named PowerPoint actions + /// + private static HyperlinkTarget? ResolveHyperlinkTarget(SlidePart slidePart, string url) + { + if (string.IsNullOrEmpty(url) || url.Equals("none", StringComparison.OrdinalIgnoreCase)) + return null; + + // Named slide-action shortcuts (no relationship required) + var lower = url.Trim().ToLowerInvariant(); + switch (lower) + { + case "firstslide": + return new HyperlinkTarget { Action = "ppaction://hlinkshowjump?jump=firstslide" }; + case "lastslide": + return new HyperlinkTarget { Action = "ppaction://hlinkshowjump?jump=lastslide" }; + case "nextslide": + return new HyperlinkTarget { Action = "ppaction://hlinkshowjump?jump=nextslide" }; + case "previousslide" or "prevslide": + return new HyperlinkTarget { Action = "ppaction://hlinkshowjump?jump=previousslide" }; + } + + // Explicit slide[N] jump + var m = Regex.Match(url.Trim(), @"^slide\[(\d+)\]$", RegexOptions.IgnoreCase); + if (m.Success) + { + var slideIdx = int.Parse(m.Groups[1].Value); + var pres = slidePart.OpenXmlPackage as PresentationDocument + ?? throw new InvalidOperationException("SlidePart is not in a PresentationDocument"); + var allSlides = pres.PresentationPart?.SlideParts.ToList() + ?? throw new InvalidOperationException("PresentationPart missing"); + if (slideIdx < 1 || slideIdx > allSlides.Count) + throw new ArgumentException($"Slide jump target out of range: slide[{slideIdx}] (total {allSlides.Count})."); + var targetSlide = allSlides[slideIdx - 1]; + + // Reuse an existing slide-to-slide relationship if present + string? relId = null; + foreach (var rel in slidePart.Parts) + { + if (ReferenceEquals(rel.OpenXmlPart, targetSlide)) + { + relId = rel.RelationshipId; + break; + } + } + if (relId == null) + relId = slidePart.CreateRelationshipToPart(targetSlide); + + return new HyperlinkTarget + { + Id = relId, + Action = "ppaction://hlinksldjump", + }; + } + + // Otherwise treat as external absolute URI + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + throw new ArgumentException( + $"Invalid hyperlink URL '{url}'. Expected an absolute URI (e.g. 'https://example.com'), " + + $"'slide[N]', or a named action (firstslide/lastslide/nextslide/previousslide)."); + var extRel = slidePart.AddHyperlinkRelationship(uri, isExternal: true); + return new HyperlinkTarget { Id = extRel.Id, IsExternal = true }; + } + + private static Drawing.HyperlinkOnClick BuildHyperlinkElement(HyperlinkTarget target, string? tooltip) + { + var hlink = new Drawing.HyperlinkOnClick(); + // r:id is required by schema — use empty string when no relationship exists (named actions). + hlink.Id = target.Id ?? ""; + if (!string.IsNullOrEmpty(target.Action)) + hlink.Action = target.Action; + if (!string.IsNullOrEmpty(tooltip)) + hlink.Tooltip = tooltip; + return hlink; + } + /// - /// Apply a hyperlink URL to all runs in a shape. Pass "none" or "" to remove. + /// Apply a hyperlink to a shape. Pass "none" or "" to remove. + /// Stores on nvSpPr/cNvPr (canonical OOXML shape-level location) and also on every run + /// (for Office compat: some readers rely on run-level hyperlinks to render the shape as clickable). /// - private static void ApplyShapeHyperlink(SlidePart slidePart, Shape shape, string url) + private static void ApplyShapeHyperlink(SlidePart slidePart, Shape shape, string url, string? tooltip = null) { + var nvDp = shape.NonVisualShapeProperties?.NonVisualDrawingProperties; var allRuns = shape.Descendants().ToList(); - if (allRuns.Count == 0) return; if (string.IsNullOrEmpty(url) || url.Equals("none", StringComparison.OrdinalIgnoreCase)) { + nvDp?.RemoveAllChildren(); foreach (var run in allRuns) run.RunProperties?.GetFirstChild()?.Remove(); return; } - if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) - throw new ArgumentException($"Invalid hyperlink URL '{url}'. Expected a valid absolute URI (e.g. 'https://example.com')."); - var rel = slidePart.AddHyperlinkRelationship(uri, isExternal: true); + var target = ResolveHyperlinkTarget(slidePart, url); + if (target == null) return; + + // Shape-level element on nvSpPr/cNvPr + if (nvDp != null) + { + nvDp.RemoveAllChildren(); + nvDp.AppendChild(BuildHyperlinkElement(target.Value, tooltip)); + } + + // Also mirror onto every run so in-text clicks work too foreach (var run in allRuns) { var rProps = run.RunProperties ?? (run.RunProperties = new Drawing.RunProperties()); rProps.RemoveAllChildren(); - rProps.InsertAt(new Drawing.HyperlinkOnClick { Id = rel.Id }, 0); + rProps.InsertAt(BuildHyperlinkElement(target.Value, tooltip), 0); } } /// - /// Apply a hyperlink URL to a single run. Pass "none" or "" to remove. + /// Apply a hyperlink to a single run. Pass "none" or "" to remove. /// - private static void ApplyRunHyperlink(SlidePart slidePart, Drawing.Run run, string url) + private static void ApplyRunHyperlink(SlidePart slidePart, Drawing.Run run, string url, string? tooltip = null) { var rProps = run.RunProperties ?? (run.RunProperties = new Drawing.RunProperties()); rProps.RemoveAllChildren(); - if (!string.IsNullOrEmpty(url) && !url.Equals("none", StringComparison.OrdinalIgnoreCase)) - { - if (!Uri.TryCreate(url, UriKind.Absolute, out var runUri)) - throw new ArgumentException($"Invalid hyperlink URL '{url}'. Expected a valid absolute URI (e.g. 'https://example.com')."); - var rel = slidePart.AddHyperlinkRelationship(runUri, isExternal: true); - rProps.InsertAt(new Drawing.HyperlinkOnClick { Id = rel.Id }, 0); - } + if (string.IsNullOrEmpty(url) || url.Equals("none", StringComparison.OrdinalIgnoreCase)) + return; + + var target = ResolveHyperlinkTarget(slidePart, url); + if (target == null) return; + rProps.InsertAt(BuildHyperlinkElement(target.Value, tooltip), 0); } /// @@ -59,12 +160,34 @@ private static void ApplyRunHyperlink(SlidePart slidePart, Drawing.Run run, stri /// private static string? ReadRunHyperlinkUrl(Drawing.Run run, OpenXmlPart part) { - var id = run.RunProperties?.GetFirstChild()?.Id?.Value; + var hlClick = run.RunProperties?.GetFirstChild(); + if (hlClick == null) return null; + var id = hlClick.Id?.Value; + var action = hlClick.Action?.Value; + + // Named actions (no relationship) → return action string directly for visibility + if (string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(action)) + return action; + if (id == null) return null; try { var rel = part.HyperlinkRelationships.FirstOrDefault(r => r.Id == id); - return rel?.Uri?.ToString(); + if (rel?.Uri != null) return rel.Uri.ToString(); + // Internal slide-jump: relationship is to another SlidePart, not a hyperlink relationship + if (part is SlidePart sp) + { + foreach (var irel in sp.Parts) + { + if (irel.RelationshipId == id && irel.OpenXmlPart is SlidePart target) + { + var pres = sp.OpenXmlPackage as PresentationDocument; + var idx = pres?.PresentationPart?.SlideParts.ToList().IndexOf(target) ?? -1; + if (idx >= 0) return $"slide[{idx + 1}]"; + } + } + } + return null; } catch { return null; } } diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Mutations.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Mutations.cs index e757b0785..25333d5c0 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$"); @@ -268,17 +269,59 @@ public partial class PowerPointHandler } zmAc.Remove(); } + else if (elementType is "ole" or "object" or "embed") + { + // Remove the GraphicFrame wrapper whose graphicData hosts a + // strong-typed p:oleObj. Index is 1-based among OLE frames on + // this slide. Also deletes the backing embedded part and the + // icon image part so the package doesn't bloat with orphaned + // binaries — same rationale as the picture-replacement quirk + // noted in CLAUDE.md. + var oleFrames = shapeTree.Elements() + .Where(gf => gf.Descendants().Any()) + .ToList(); + if (elementIdx < 1 || elementIdx > oleFrames.Count) + throw new ArgumentException($"OLE object {elementIdx} not found (total: {oleFrames.Count})"); + var oleFrame = oleFrames[elementIdx - 1]; + var oleObjEl = oleFrame.Descendants().First(); + // 1. Delete the embedded payload part by rel id. + if (oleObjEl.Id?.Value is string embedRel && !string.IsNullOrEmpty(embedRel)) + { + try { slidePart.DeletePart(embedRel); } catch { } + } + // 2. Delete the inner icon image part (Blip inside p:pic). + var iconBlip = oleObjEl.Descendants().FirstOrDefault(); + if (iconBlip?.Embed?.Value is string iconRel && !string.IsNullOrEmpty(iconRel)) + { + try { slidePart.DeletePart(iconRel); } catch { } + } + oleFrame.Remove(); + } else { - throw new ArgumentException($"Unknown element type: {elementType}. Supported: shape, picture, video, audio, table, chart, connector/connection, group, zoom, 3dmodel"); + throw new ArgumentException($"Unknown element type: {elementType}. Supported: shape, picture, video, audio, table, chart, connector/connection, group, zoom, 3dmodel, ole"); } GetSlide(slidePart).Save(); 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 +340,49 @@ 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}"); + } + + // Self-move guard: if the anchor is the slide being moved, the anchor's + // parent will be null after Remove() and InsertAfterSelf/InsertBeforeSelf + // will throw InvalidOperationException. Detect and no-op the move. + // CONSISTENCY(slide-move): same guard for both After and Before anchors. + if (ReferenceEquals(afterAnchor, slideId) || ReferenceEquals(beforeAnchor, slideId)) + { + // Moving a slide after/before itself is a no-op. + var sameNewSlideIds = slideIdList.Elements().ToList(); + var sameIdx = sameNewSlideIds.IndexOf(slideId) + 1; + return $"/slide[{sameIdx}]"; + } + 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,23 +432,84 @@ public string Move(string sourcePath, string? targetParentPath, int? index) ?? throw new InvalidOperationException("Slide has no shape tree"); } - // Copy relationships BEFORE removing from source (so rel IDs are still accessible) + // 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). + // For cross-slide moves, also capture the original rel ids so we can + // delete now-orphaned parts from the source slide after the move + // (e.g. OLE embedded payload + icon blip). Without this, Query("ole") + // on the source still surfaces the stray EmbeddedPackagePart as an + // "orphan" OLE node — see Ppt_MoveOleBetweenSlides_SucceedsOrErrorsClearly. + var oldSourceRelIds = new List(); + if (srcSlidePart != tgtSlidePart) + { + var rNsUri = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + foreach (var el in srcElement.Descendants().Prepend(srcElement)) + { + foreach (var attr in el.GetAttributes()) + { + if (attr.NamespaceUri == rNsUri && !string.IsNullOrEmpty(attr.Value)) + oldSourceRelIds.Add(attr.Value); + } + } 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) GetSlide(tgtSlidePart).Save(); + // Post-move cleanup: delete any source-slide rels the moved element + // used exclusively, otherwise they linger as "orphan" parts detected + // by Query("ole") and other listers. + if (srcSlidePart != tgtSlidePart && oldSourceRelIds.Count > 0) + { + var srcSlideXml = GetSlide(srcSlidePart).OuterXml; + foreach (var oldRelId in oldSourceRelIds.Distinct()) + { + // Keep rels still referenced anywhere else in the source slide XML. + if (srcSlideXml.Contains($"\"{oldRelId}\"")) continue; + try { srcSlidePart.DeletePart(oldRelId); } catch { } + } + } + return ComputeElementPath(effectiveParentPath, srcElement, tgtShapeTree); } 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,13 +595,18 @@ 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 / + // Whole-slide clone: --from /slide[N] to / (or null == "duplicate in + // place" at presentation root, i.e. append the clone after the source + // slide). var slideCloneMatch = Regex.Match(sourcePath, @"^/slide\[(\d+)\]$"); - if (slideCloneMatch.Success && (targetParentPath is "/" or "" or "/presentation")) + if (slideCloneMatch.Success && (targetParentPath is null or "/" or "" or "/presentation")) { return CloneSlide(slideCloneMatch, slideParts, index); } @@ -465,6 +614,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]"); @@ -674,6 +840,10 @@ private static void RemapRelationshipIds(OpenXmlElement root, Dictionary shapeTree.Elements() .Where(gf => gf.Descendants().Any()).ElementAtOrDefault(elementIdx - 1) ?? throw new ArgumentException($"Chart {elementIdx} not found"), + "ole" or "object" or "embed" => shapeTree.Elements() + .Where(gf => gf.Descendants().Any()) + .ElementAtOrDefault(elementIdx - 1) + ?? throw new ArgumentException($"OLE object {elementIdx} not found"), "group" => shapeTree.Elements().ElementAtOrDefault(elementIdx - 1) ?? throw new ArgumentException($"Group {elementIdx} not found"), _ => shapeTree.ChildElements @@ -826,6 +996,13 @@ private static string ComputeElementPath(string parentPath, OpenXmlElement eleme .Where(f => f.Descendants().Any()) .ToList().IndexOf(gf) + 1; } + else if (gf.Descendants().Any()) + { + typeName = "ole"; + typeIdx = shapeTree.Elements() + .Where(f => f.Descendants().Any()) + .ToList().IndexOf(gf) + 1; + } else { typeName = element.LocalName; @@ -841,6 +1018,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..8d2134418 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 @@ -749,6 +742,7 @@ private static DocumentNode ShapeToNode(Shape shape, int slideNum, int shapeIdx, : paraAlignVal == Drawing.TextAlignmentTypeValues.Justified ? "justify" : "left"; } + if (paraPProps?.Level?.HasValue == true) paraNode.Format["level"] = paraPProps.Level.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); if (paraPProps?.Indent?.HasValue == true) paraNode.Format["indent"] = FormatEmu(paraPProps.Indent.Value); if (paraPProps?.LeftMargin?.HasValue == true) paraNode.Format["marginLeft"] = FormatEmu(paraPProps.LeftMargin.Value); if (paraPProps?.RightMargin?.HasValue == true) paraNode.Format["marginRight"] = FormatEmu(paraPProps.RightMargin.Value); @@ -768,7 +762,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 +848,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 +1008,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 1918070b0..6b0a30ee8 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" }; @@ -30,7 +31,7 @@ public DocumentNode Get(string path, int depth = 1) { if (sldSz.Cx?.HasValue == true) node.Format["slideWidth"] = FormatEmu(sldSz.Cx.Value); if (sldSz.Cy?.HasValue == true) node.Format["slideHeight"] = FormatEmu(sldSz.Cy.Value); - if (sldSz.Type?.HasValue == true) node.Format["slideSize"] = sldSz.Type.InnerText.ToLowerInvariant() switch + if (sldSz.Type is { HasValue: true } sldType) node.Format["slideSize"] = sldType.InnerText!.ToLowerInvariant() switch { "screen16x9" => "widescreen", "screen4x3" => "standard", @@ -180,6 +181,22 @@ public DocumentNode Get(string path, int depth = 1) return layoutNode; } + // Try OLE path: /slide[N]/ole[M] + // CONSISTENCY(ole-alias): "oleobject" mirrors Add's case switch + var oleGetMatch = Regex.Match(path, @"^/slide\[(\d+)\]/(?:ole|oleobject|object|embed)\[(\d+)\]$", RegexOptions.IgnoreCase); + if (oleGetMatch.Success) + { + var oleSlideIdx = int.Parse(oleGetMatch.Groups[1].Value); + var oleNodeIdx = int.Parse(oleGetMatch.Groups[2].Value); + var slidePartsO = GetSlideParts().ToList(); + if (oleSlideIdx < 1 || oleSlideIdx > slidePartsO.Count) + throw new ArgumentException($"Slide {oleSlideIdx} not found (total: {slidePartsO.Count})"); + var oleNodes = CollectOleNodesForSlide(oleSlideIdx, slidePartsO[oleSlideIdx - 1]); + if (oleNodeIdx < 1 || oleNodeIdx > oleNodes.Count) + throw new ArgumentException($"OLE object {oleNodeIdx} not found at /slide[{oleSlideIdx}] (available: {oleNodes.Count})."); + return oleNodes[oleNodeIdx - 1]; + } + // Try notes path: /slide[N]/notes var notesGetMatch = Regex.Match(path, @"^/slide\[(\d+)\]/notes$"); if (notesGetMatch.Success) @@ -203,10 +220,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 +234,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,25 +244,26 @@ 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 }; var qParaPProps = para.ParagraphProperties; if (qParaPProps?.Alignment?.HasValue == true) paraNode.Format["align"] = NormalizeAlignment(qParaPProps.Alignment.InnerText!); + if (qParaPProps?.Level?.HasValue == true) paraNode.Format["level"] = qParaPProps.Level.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); if (qParaPProps?.Indent?.HasValue == true) paraNode.Format["indent"] = FormatEmu(qParaPProps.Indent.Value); if (qParaPProps?.LeftMargin?.HasValue == true) paraNode.Format["marginLeft"] = FormatEmu(qParaPProps.LeftMargin.Value); if (qParaPProps?.RightMargin?.HasValue == true) paraNode.Format["marginRight"] = FormatEmu(qParaPProps.RightMargin.Value); @@ -264,7 +284,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 +317,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 +411,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 +424,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 }; @@ -596,7 +619,7 @@ public DocumentNode Get(string path, int depth = 1) var csChartIdx = int.Parse(chartSeriesGetMatch.Groups[2].Value); var csSeriesIdx = int.Parse(chartSeriesGetMatch.Groups[3].Value); - var (csSlidePart, csChartGf, csChartPart) = ResolveChart(csSlideIdx, csChartIdx); + var (csSlidePart, csChartGf, csChartPart, _) = ResolveChart(csSlideIdx, csChartIdx); // Get the chart node with depth=1 to populate series children var chartNode = ChartToNode(csChartGf, csSlidePart, csSlideIdx, csChartIdx, 1); var seriesChildren = chartNode.Children.Where(c => c.Type == "series").ToList(); @@ -738,7 +761,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 +778,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 +804,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 +812,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); } } @@ -818,7 +842,20 @@ public List Query(string selector) var selectorForType = Regex.Replace(selector, @":(contains\([^)]*\)|empty|no-alt)", ""); // Also strip shorthand ":text" syntax so "shape:Find me" → "shape" selectorForType = Regex.Replace(selectorForType, @":(?![\[\(]).*$", ""); - var typeMatch = Regex.Match(selectorForType.Contains(']') ? selectorForType.Split(']').Last() : selectorForType, @"^(?:slide\[\d+\]\s*>?\s*)?([\w]+)"); + // Extract raw element type. If the selector starts with a slide + // prefix ("slide[1]>shape"), strip it first; otherwise parse from + // the beginning. Using Split(']').Last() on a selector that ENDS + // with ']' (e.g. "ole[progId=Excel.Sheet.12]") yields an empty + // string and the regex fails to capture — breaking the ole branch + // dispatch and silently returning empty results. + var typeSource = selectorForType; + // CONSISTENCY(query-slide-prefix): strip the optional leading '/' + // and the slide[N] prefix (with either '>' or '/' separator) so that + // both "slide[1]>ole" and "/slide[1]/ole" resolve rawType correctly. + var slidePrefixMatch = Regex.Match(typeSource, @"^\s*/?slide\[\d+\]\s*[>/]?\s*"); + if (slidePrefixMatch.Success) + typeSource = typeSource.Substring(slidePrefixMatch.Length); + var typeMatch = Regex.Match(typeSource, @"^([\w]+)"); var rawType = typeMatch.Success ? typeMatch.Groups[1].Value.ToLowerInvariant() : ""; bool isKnownType = string.IsNullOrEmpty(rawType) || rawType is "shape" or "textbox" or "title" or "picture" or "pic" @@ -829,6 +866,8 @@ public List Query(string selector) or "group" or "zoom" or "slidemaster" or "slidelayout" or "media" or "image" + // CONSISTENCY(ole-alias): "oleobject" mirrors Add's case switch + or "ole" or "oleobject" or "object" or "embed" or "tc" or "cell" or "tr" or "row"; if (!isKnownType) { @@ -917,7 +956,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); @@ -926,6 +965,37 @@ public List Query(string selector) return results; } + // OLE object query. In PPTX, embedded OLE lives inside a + // whose contains a + // element naming the progId + backing rel id. We also + // surface any orphan embedded parts the slide may have — same + // rationale as the Excel reader: forensics + zero silent loss. + // CONSISTENCY(ole-alias): "oleobject" mirrors Add's case switch + if (rawType is "ole" or "oleobject" or "object" or "embed") + { + int oleSlideNum = 0; + foreach (var slidePart in GetSlideParts()) + { + oleSlideNum++; + // CONSISTENCY(query-slide-scope): match the shape/picture/table + // branch below — apply parsed.SlideNum so that `slide[2]>ole` + // returns only slide 2's OLE objects instead of leaking all + // slides' results. + if (parsed.SlideNum.HasValue && parsed.SlideNum.Value != oleSlideNum) + continue; + var nodes = CollectOleNodesForSlide(oleSlideNum, slidePart); + foreach (var n in nodes) + { + // CONSISTENCY(query-attr-filter): match Word/Excel OLE query + // and the non-OLE PPT shape branch — apply generic attribute + // filter (e.g. progId=...) so users can narrow OLE results. + if (MatchesGenericAttributes(n, parsed.Attributes)) + results.Add(n); + } + } + return results; + } + // Notes query (notes live outside the shape tree in NotesSlidePart) if (rawType == "notes") { @@ -974,7 +1044,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 +1116,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 +1126,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 +1146,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 +1206,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.Resolve.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Resolve.cs new file mode 100644 index 000000000..8171c9b89 --- /dev/null +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Resolve.cs @@ -0,0 +1,492 @@ +// Copyright 2025 OfficeCli (officecli.ai) +// SPDX-License-Identifier: Apache-2.0 + +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; + +namespace OfficeCli.Handlers; + +public partial class PowerPointHandler +{ + private (SlidePart slidePart, Shape shape) ResolveShape(int slideIdx, int shapeIdx) + { + 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 + ?? throw new ArgumentException($"Slide {slideIdx} has no shapes"); + + var shapes = shapeTree.Elements().ToList(); + if (shapeIdx < 1 || shapeIdx > shapes.Count) + throw new ArgumentException($"Shape {shapeIdx} not found"); + + return (slidePart, shapes[shapeIdx - 1]); + } + + private (SlidePart slidePart, GraphicFrame gf, ChartPart? chartPart, ExtendedChartPart? extChartPart) ResolveChart(int slideIdx, int chartIdx) + { + 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 + ?? throw new ArgumentException($"Slide {slideIdx} has no shapes"); + + var chartFrames = shapeTree.Elements() + .Where(gf => gf.Descendants().Any() + || IsExtendedChartFrame(gf)) + .ToList(); + if (chartIdx < 1 || chartIdx > chartFrames.Count) + throw new ArgumentException($"Chart {chartIdx} not found (total: {chartFrames.Count})"); + + var gf = chartFrames[chartIdx - 1]; + + // Regular c:chart reference + var chartRef = gf.Descendants().FirstOrDefault(); + ChartPart? chartPart = null; + if (chartRef?.Id?.Value != null) + chartPart = (ChartPart)slidePart.GetPartById(chartRef.Id.Value); + + // cx:chart (extended) reference — note: the SDK has TWO classes that + // both serialize with LocalName "chart": + // CX.RelId — the reference stub inside a:graphicData (has r:id) + // CX.Chart — the content element inside cx:chartSpace (has plotArea) + // Loaded elements may pick the "wrong" CLR type, so Descendants() + // can miss them. Walk graphic → graphicData and grab the first child + // matching the cx namespace + "chart" local name instead. + ExtendedChartPart? extChartPart = null; + var graphicData = gf.Graphic?.GraphicData; + if (graphicData != null) + { + const string cxNs = "http://schemas.microsoft.com/office/drawing/2014/chartex"; + var cxChartRef = graphicData.ChildElements + .FirstOrDefault(e => e.LocalName == "chart" && e.NamespaceUri == cxNs); + if (cxChartRef != null) + { + // The r:id attribute lives in the relationships namespace. + const string rNs = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + var relIdAttr = cxChartRef.GetAttributes() + .FirstOrDefault(a => a.LocalName == "id" && a.NamespaceUri == rNs); + if (!string.IsNullOrEmpty(relIdAttr.Value)) + extChartPart = (ExtendedChartPart)slidePart.GetPartById(relIdAttr.Value); + } + } + + return (slidePart, gf, chartPart, extChartPart); + } + + private (SlidePart slidePart, Drawing.Table table) ResolveTable(int slideIdx, int tblIdx) + { + 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 + ?? throw new ArgumentException($"Slide {slideIdx} has no shapes"); + + var tables = shapeTree.Elements() + .Select(gf => gf.Descendants().FirstOrDefault()) + .Where(t => t != null).ToList(); + if (tblIdx < 1 || tblIdx > tables.Count) + throw new ArgumentException($"Table {tblIdx} not found (total: {tables.Count})"); + + return (slidePart, tables[tblIdx - 1]!); + } + + /// + /// Resolve a logical PPT path (e.g. /slide[1]/table[1]/tr[2]) to the actual OpenXML element. + /// Returns null if the path doesn't contain logical segments that need resolving. + /// + private (SlidePart slidePart, OpenXmlElement element)? ResolveLogicalPath(string path) + { + // /slide[N]/table[M]... + var tblPathMatch = Regex.Match(path, @"^/slide\[(\d+)\]/table\[(\d+)\](.*)$"); + if (tblPathMatch.Success) + { + var slideIdx = int.Parse(tblPathMatch.Groups[1].Value); + var tblIdx = int.Parse(tblPathMatch.Groups[2].Value); + var rest = tblPathMatch.Groups[3].Value; // e.g. /tr[1]/tc[2]/txBody + + var (slidePart, table) = ResolveTable(slideIdx, tblIdx); + OpenXmlElement current = table; + + if (!string.IsNullOrEmpty(rest)) + { + var segments = GenericXmlQuery.ParsePathSegments(rest); + var target = GenericXmlQuery.NavigateByPath(current, segments); + if (target != null) current = target; + else throw new ArgumentException($"Element not found: {path}. Resolved table[{tblIdx}] on slide[{slideIdx}] but sub-path '{rest}' does not exist. Available children: {DescribeChildren(current)}"); + } + return (slidePart, current); + } + + // /slide[N]/placeholder[X]... + var phPathMatch = Regex.Match(path, @"^/slide\[(\d+)\]/placeholder\[(\w+)\](.*)$"); + if (phPathMatch.Success) + { + var slideIdx = int.Parse(phPathMatch.Groups[1].Value); + var phId = phPathMatch.Groups[2].Value; + var rest = phPathMatch.Groups[3].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]; + OpenXmlElement current = ResolvePlaceholderShape(slidePart, phId); + + if (!string.IsNullOrEmpty(rest)) + { + var segments = GenericXmlQuery.ParsePathSegments(rest); + var target = GenericXmlQuery.NavigateByPath(current, segments); + if (target != null) current = target; + else throw new ArgumentException($"Element not found: {path}. Resolved placeholder[{phId}] on slide[{slideIdx}] but sub-path '{rest}' does not exist. Available children: {DescribeChildren(current)}"); + } + return (slidePart, current); + } + + return null; + } + + /// Summarize child element types for error messages. + private static string DescribeChildren(OpenXmlElement parent) + { + var groups = parent.ChildElements + .GroupBy(e => e.LocalName) + .Select(g => g.Count() > 1 ? $"{g.Key}[1..{g.Count()}]" : g.Key) + .Take(10) + .ToList(); + return groups.Count > 0 ? string.Join(", ", groups) : "(empty)"; + } + + /// Summarize slide contents for error messages (e.g. "3 shapes, 1 table, 2 pictures"). + private static string DescribeSlideInventory(ShapeTree? shapeTree) + { + if (shapeTree == null) return "(empty slide)"; + var parts = new List(); + var shapes = shapeTree.Elements().Count(); + var tables = shapeTree.Elements().Count(gf => gf.Descendants().Any()); + var charts = shapeTree.Elements().Count(gf => gf.Descendants().Any()); + var pics = shapeTree.Elements().Count(); + var connectors = shapeTree.Elements().Count(); + var groups = shapeTree.Elements().Count(); + if (shapes > 0) parts.Add($"{shapes} shape(s)"); + if (tables > 0) parts.Add($"{tables} table(s)"); + if (charts > 0) parts.Add($"{charts} chart(s)"); + if (pics > 0) parts.Add($"{pics} picture(s)"); + if (connectors > 0) parts.Add($"{connectors} connector(s)"); + if (groups > 0) parts.Add($"{groups} group(s)"); + return parts.Count > 0 ? string.Join(", ", parts) : "(empty slide)"; + } + + private static PlaceholderValues? ParsePlaceholderType(string name) + { + return name.ToLowerInvariant() switch + { + "title" => PlaceholderValues.Title, + "centertitle" or "centeredtitle" or "ctitle" => PlaceholderValues.CenteredTitle, + "body" or "content" => PlaceholderValues.Body, + "subtitle" or "sub" => PlaceholderValues.SubTitle, + "date" or "datetime" or "dt" => PlaceholderValues.DateAndTime, + "footer" => PlaceholderValues.Footer, + "slidenum" or "slidenumber" or "sldnum" => PlaceholderValues.SlideNumber, + "object" or "obj" => PlaceholderValues.Object, + "chart" => PlaceholderValues.Chart, + "table" => PlaceholderValues.Table, + "clipart" => PlaceholderValues.ClipArt, + "diagram" or "dgm" => PlaceholderValues.Diagram, + "media" => PlaceholderValues.Media, + "picture" or "pic" => PlaceholderValues.Picture, + "header" => PlaceholderValues.Header, + _ => null + }; + } + + private Shape ResolvePlaceholderShape(SlidePart slidePart, string phId) + { + var shapeTree = GetSlide(slidePart).CommonSlideData?.ShapeTree + ?? throw new ArgumentException("Slide has no shape tree"); + + // Try numeric index first + if (int.TryParse(phId, out var numIdx)) + { + // Match by placeholder index + var byIndex = shapeTree.Elements() + .FirstOrDefault(s => + { + var ph = s.NonVisualShapeProperties?.ApplicationNonVisualDrawingProperties + ?.GetFirstChild(); + return ph?.Index?.Value == (uint)numIdx; + }); + if (byIndex != null) return byIndex; + + // Also try as 1-based ordinal of all placeholders + var allPh = shapeTree.Elements() + .Where(s => s.NonVisualShapeProperties?.ApplicationNonVisualDrawingProperties + ?.GetFirstChild() != null).ToList(); + if (numIdx >= 1 && numIdx <= allPh.Count) + return allPh[numIdx - 1]; + + throw new ArgumentException($"Placeholder index {numIdx} not found"); + } + + // Try by type name + var phType = ParsePlaceholderType(phId) + ?? throw new ArgumentException($"Unknown placeholder type: '{phId}'. " + + "Known types: title, body, subtitle, date, footer, slidenum, object, picture, centerTitle"); + + var byType = shapeTree.Elements() + .FirstOrDefault(s => + { + var ph = s.NonVisualShapeProperties?.ApplicationNonVisualDrawingProperties + ?.GetFirstChild(); + return ph?.Type?.Value == phType; + }); + + if (byType != null) return byType; + + // Check layout for inherited placeholders and create one on the slide + var layoutPart = slidePart.SlideLayoutPart; + if (layoutPart?.SlideLayout?.CommonSlideData?.ShapeTree != null) + { + var layoutShape = layoutPart.SlideLayout.CommonSlideData.ShapeTree.Elements() + .FirstOrDefault(s => + { + var ph = s.NonVisualShapeProperties?.ApplicationNonVisualDrawingProperties + ?.GetFirstChild(); + return ph?.Type?.Value == phType; + }); + + if (layoutShape != null) + { + // Clone from layout and add to slide + var newShape = (Shape)layoutShape.CloneNode(true); + // Clear any text content from layout placeholder + if (newShape.TextBody != null) + { + newShape.TextBody.RemoveAllChildren(); + newShape.TextBody.Append(new Drawing.Paragraph( + new Drawing.EndParagraphRunProperties { Language = "en-US" })); + } + shapeTree.AppendChild(newShape); + return newShape; + } + } + + throw new ArgumentException($"Placeholder '{phId}' not found on slide or its layout"); + } + + private DocumentNode GetPlaceholderNode(SlidePart slidePart, int slideIdx, int phIdx, int depth) + { + var shapeTree = GetSlide(slidePart).CommonSlideData?.ShapeTree + ?? throw new ArgumentException("Slide has no shape tree"); + + // Get all placeholders on slide + var placeholders = shapeTree.Elements() + .Where(s => s.NonVisualShapeProperties?.ApplicationNonVisualDrawingProperties + ?.GetFirstChild() != null).ToList(); + + if (phIdx < 1 || phIdx > placeholders.Count) + throw new ArgumentException($"Placeholder {phIdx} not found (total: {placeholders.Count})"); + + var shape = placeholders[phIdx - 1]; + var ph = shape.NonVisualShapeProperties!.ApplicationNonVisualDrawingProperties! + .GetFirstChild()!; + + var node = ShapeToNode(shape, slideIdx, phIdx, depth); + node.Path = $"/slide[{slideIdx}]/placeholder[{phIdx}]"; + node.Type = "placeholder"; + if (ph.Type?.HasValue == true) node.Format["phType"] = ph.Type.InnerText; + if (ph.Index?.HasValue == true) node.Format["phIndex"] = ph.Index.Value; + return node; + } + + // ==================== Media Timing Lookup ==================== + + /// + /// Find the CommonMediaNode in the timing tree for a given shape ID. + /// + private static CommonMediaNode? FindMediaTimingNode(SlidePart slidePart, uint shapeId) + { + var timing = GetSlide(slidePart).GetFirstChild(); + if (timing == null) return null; + + foreach (var mediaNode in timing.Descendants()) + { + var target = mediaNode.TargetElement?.GetFirstChild(); + if (target?.ShapeId?.Value == shapeId.ToString()) + return mediaNode; + } + return null; + } + + // ==================== Cleanup (POI-style reference counting) ==================== + + /// + /// Remove a Picture element with proper cleanup of relationships and media parts. + /// Follows Apache POI's pattern: reference-count blipIds, only delete parts when + /// no other shapes reference the same media. + /// + private static void RemovePictureWithCleanup(SlidePart slidePart, ShapeTree shapeTree, Picture pic) + { + // Collect all relationship IDs referenced by this picture + var relIdsToClean = new HashSet(); + + // BlipFill → Blip.Embed (poster/image) + var blipEmbed = pic.BlipFill?.GetFirstChild()?.Embed?.Value; + if (blipEmbed != null) relIdsToClean.Add(blipEmbed); + + // VideoFromFile.Link or AudioFromFile.Link + var nvPr = pic.NonVisualPictureProperties?.ApplicationNonVisualDrawingProperties; + var videoLink = nvPr?.GetFirstChild()?.Link?.Value; + if (videoLink != null) relIdsToClean.Add(videoLink); + var audioLink = nvPr?.GetFirstChild()?.Link?.Value; + if (audioLink != null) relIdsToClean.Add(audioLink); + + // p14:media.Embed (MediaReferenceRelationship) + var p14Media = nvPr?.Descendants().FirstOrDefault(); + var mediaEmbed = p14Media?.Embed?.Value; + if (mediaEmbed != null) relIdsToClean.Add(mediaEmbed); + + // Reference count: check all OTHER pictures on the same slide for shared relIds + var sharedRelIds = new HashSet(); + foreach (var otherPic in shapeTree.Elements()) + { + if (otherPic == pic) continue; // skip the one being removed + + var otherBlip = otherPic.BlipFill?.GetFirstChild()?.Embed?.Value; + if (otherBlip != null && relIdsToClean.Contains(otherBlip)) sharedRelIds.Add(otherBlip); + + var otherNvPr = otherPic.NonVisualPictureProperties?.ApplicationNonVisualDrawingProperties; + var otherVid = otherNvPr?.GetFirstChild()?.Link?.Value; + if (otherVid != null && relIdsToClean.Contains(otherVid)) sharedRelIds.Add(otherVid); + var otherAud = otherNvPr?.GetFirstChild()?.Link?.Value; + if (otherAud != null && relIdsToClean.Contains(otherAud)) sharedRelIds.Add(otherAud); + + var otherMedia = otherNvPr?.Descendants().FirstOrDefault()?.Embed?.Value; + if (otherMedia != null && relIdsToClean.Contains(otherMedia)) sharedRelIds.Add(otherMedia); + } + + // Remove the XML element first + pic.Remove(); + + // Clean up relationships that are no longer referenced + foreach (var relId in relIdsToClean) + { + if (sharedRelIds.Contains(relId)) continue; // still referenced by another shape + + try { slidePart.DeletePart(relId); } catch (ArgumentException) { } + // Also try removing data part relationships (video/audio/media) + try + { + foreach (var dpr in slidePart.DataPartReferenceRelationships.Where(r => r.Id == relId).ToList()) + slidePart.DeleteReferenceRelationship(dpr); + } + catch (ArgumentException) { } + } + } + + // ==================== Layout ==================== + + /// + /// Resolve a SlideLayoutPart by name, type, or index. + /// If layoutHint is null, returns the first layout. + /// Matching order: exact name → layout type → numeric index → first layout. + /// + private static SlideLayoutPart? ResolveSlideLayout(PresentationPart presentationPart, string? layoutHint) + { + var allLayouts = presentationPart.SlideMasterParts + .SelectMany(m => m.SlideLayoutParts).ToList(); + if (allLayouts.Count == 0) return null; + + if (string.IsNullOrEmpty(layoutHint)) + return allLayouts.FirstOrDefault(); + + // 1. Match by layout name (CommonSlideData.Name or SlideLayout.MatchingName) + var byName = allLayouts.FirstOrDefault(lp => + { + var sl = lp.SlideLayout; + var csdName = sl?.CommonSlideData?.Name?.Value; + var matchName = sl?.MatchingName?.Value; + return string.Equals(csdName, layoutHint, StringComparison.OrdinalIgnoreCase) + || string.Equals(matchName, layoutHint, StringComparison.OrdinalIgnoreCase); + }); + if (byName != null) return byName; + + // 2. Match by layout type keyword + var layoutType = layoutHint.ToLowerInvariant() switch + { + "title" => SlideLayoutValues.Title, + "titleonly" or "title_only" => SlideLayoutValues.TitleOnly, + "blank" => SlideLayoutValues.Blank, + "twocontent" or "two_content" or "twocol" => SlideLayoutValues.TwoColumnText, + "titlecontent" or "title_content" => SlideLayoutValues.ObjectText, + "section" or "sectionheader" => SlideLayoutValues.SectionHeader, + "comparison" => SlideLayoutValues.TwoTextAndTwoObjects, + "contentwithcaption" or "caption" => SlideLayoutValues.ObjectAndText, + "picturewithcaption" or "pictxt" => SlideLayoutValues.PictureText, + "custom" => SlideLayoutValues.Custom, + _ => (SlideLayoutValues?)null + }; + if (layoutType.HasValue) + { + var byType = allLayouts.FirstOrDefault(lp => + lp.SlideLayout?.Type?.HasValue == true && + lp.SlideLayout.Type.Value == layoutType.Value); + if (byType != null) return byType; + } + + // 3. Match by 1-based numeric index + if (int.TryParse(layoutHint, out var idx) && idx >= 1 && idx <= allLayouts.Count) + return allLayouts[idx - 1]; + + // 4. Fuzzy match: layout name contains the hint (case-insensitive) + var fuzzy = allLayouts.FirstOrDefault(lp => + { + var csdName = lp.SlideLayout?.CommonSlideData?.Name?.Value; + return csdName != null && csdName.Contains(layoutHint, StringComparison.OrdinalIgnoreCase); + }); + if (fuzzy != null) return fuzzy; + + throw new ArgumentException( + $"Layout '{layoutHint}' not found. Available layouts: " + + string.Join(", ", allLayouts.Select((lp, i) => + { + var name = lp.SlideLayout?.CommonSlideData?.Name?.Value ?? "(unnamed)"; + var type = lp.SlideLayout?.Type?.HasValue == true ? lp.SlideLayout.Type.InnerText : "?"; + return $"[{i + 1}] {name} ({type})"; + }))); + } + + /// + /// Get the layout name for a slide part. + /// Falls back to type name if no explicit name is set. + /// + private static string? GetSlideLayoutName(SlidePart slidePart) + { + var layoutPart = slidePart.SlideLayoutPart; + if (layoutPart?.SlideLayout == null) return null; + return layoutPart.SlideLayout.CommonSlideData?.Name?.Value + ?? layoutPart.SlideLayout.MatchingName?.Value + ?? (layoutPart.SlideLayout.Type?.HasValue == true + ? layoutPart.SlideLayout.Type.InnerText : null); + } + + /// + /// Get the layout type for a slide part. + /// + private static string? GetSlideLayoutType(SlidePart slidePart) + { + var layoutPart = slidePart.SlideLayoutPart; + if (layoutPart?.SlideLayout?.Type?.HasValue != true) return null; + return layoutPart.SlideLayout.Type.InnerText; + } +} diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Selector.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Selector.cs index 17ff922e3..689d9218d 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Selector.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Selector.cs @@ -29,7 +29,9 @@ private static ShapeSelector ParseShapeSelector(string selector) if (slideMatch.Success) { slideNum = int.Parse(slideMatch.Groups[1].Value); - selector = slideMatch.Groups[2].Value.TrimStart('>', ' '); + // CONSISTENCY(query-slide-prefix): strip '>', '/', or ' ' separators + // so both "slide[1]>ole" and "/slide[1]/ole" resolve the element type. + selector = slideMatch.Groups[2].Value.TrimStart('>', '/', ' '); } // Element type @@ -168,17 +170,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.Presentation.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Set.Presentation.cs index 01b2e8ab2..a211149f1 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Set.Presentation.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Set.Presentation.cs @@ -20,28 +20,28 @@ private bool TrySetPresentationSetting(string key, string value) // ==================== Presentation Attributes ==================== case "firstslidenum" or "firstslidenumber": { - var pres = _doc.PresentationPart!.Presentation; + var pres = _doc.PresentationPart!.Presentation!; pres.FirstSlideNum = ParseHelpers.SafeParseInt(value, "firstSlideNum"); pres.Save(); return true; } case "rtl": { - var pres = _doc.PresentationPart!.Presentation; + var pres = _doc.PresentationPart!.Presentation!; pres.RightToLeft = IsTruthy(value); pres.Save(); return true; } case "compatmode" or "compatibilitymode": { - var pres = _doc.PresentationPart!.Presentation; + var pres = _doc.PresentationPart!.Presentation!; pres.CompatibilityMode = IsTruthy(value); pres.Save(); return true; } case "removepersonalinfoonsave" or "removepersonalinfo": { - var pres = _doc.PresentationPart!.Presentation; + var pres = _doc.PresentationPart!.Presentation!; pres.RemovePersonalInfoOnSave = IsTruthy(value); pres.Save(); return true; diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Set.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Set.cs index bb82a138a..73e472d74 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"); @@ -293,11 +307,13 @@ public List Set(string path, Dictionary properties) var targetRun = allRuns[runIdx - 1]; var linkValRun = properties.GetValueOrDefault("link"); + var tooltipValRun = properties.GetValueOrDefault("tooltip"); var runOnlyProps = properties - .Where(kv => !kv.Key.Equals("link", StringComparison.OrdinalIgnoreCase)) + .Where(kv => !kv.Key.Equals("link", StringComparison.OrdinalIgnoreCase) + && !kv.Key.Equals("tooltip", StringComparison.OrdinalIgnoreCase)) .ToDictionary(kv => kv.Key, kv => kv.Value); var unsupported = SetRunOrShapeProperties(runOnlyProps, new List { targetRun }, shape, slidePart); - if (linkValRun != null) ApplyRunHyperlink(slidePart, targetRun, linkValRun); + if (linkValRun != null) ApplyRunHyperlink(slidePart, targetRun, linkValRun, tooltipValRun); GetSlide(slidePart).Save(); return unsupported; } @@ -324,11 +340,13 @@ public List Set(string path, Dictionary properties) var targetRun = paraRuns[runIdx - 1]; var linkVal = properties.GetValueOrDefault("link"); + var tooltipVal = properties.GetValueOrDefault("tooltip"); var runOnlyProps = properties - .Where(kv => !kv.Key.Equals("link", StringComparison.OrdinalIgnoreCase)) + .Where(kv => !kv.Key.Equals("link", StringComparison.OrdinalIgnoreCase) + && !kv.Key.Equals("tooltip", StringComparison.OrdinalIgnoreCase)) .ToDictionary(kv => kv.Key, kv => kv.Value); var unsupported = SetRunOrShapeProperties(runOnlyProps, new List { targetRun }, shape, slidePart); - if (linkVal != null) ApplyRunHyperlink(slidePart, targetRun, linkVal); + if (linkVal != null) ApplyRunHyperlink(slidePart, targetRun, linkVal, tooltipVal); GetSlide(slidePart).Save(); return unsupported; } @@ -367,6 +385,14 @@ public List Set(string path, Dictionary properties) pProps.Indent = (int)ParseEmu(value); break; } + case "level": + { + var pProps = para.ParagraphProperties ?? (para.ParagraphProperties = new Drawing.ParagraphProperties()); + if (!int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var lvl) || lvl < 0 || lvl > 8) + throw new ArgumentException($"Invalid 'level' value: '{value}'. Expected an integer between 0 and 8 (OOXML a:pPr/@lvl)."); + pProps.Level = lvl; + break; + } case "marginleft" or "marl": { var pProps = para.ParagraphProperties ?? (para.ParagraphProperties = new Drawing.ParagraphProperties()); @@ -407,8 +433,14 @@ public List Set(string path, Dictionary properties) break; } case "link": + { + var paraTooltip = properties.GetValueOrDefault("tooltip"); foreach (var r in paraRuns) - ApplyRunHyperlink(slidePart, r, value); + ApplyRunHyperlink(slidePart, r, value, paraTooltip); + break; + } + case "tooltip": + // handled in tandem with "link"; standalone tooltip change is not supported here break; default: // Apply run-level properties to all runs in this paragraph @@ -431,7 +463,7 @@ public List Set(string path, Dictionary properties) var chartIdx = int.Parse(chartSetMatch.Groups[2].Value); var seriesIdx = chartSetMatch.Groups[3].Success ? int.Parse(chartSetMatch.Groups[3].Value) : 0; - var (slidePart, chartGf, chartPart) = ResolveChart(slideIdx, chartIdx); + var (slidePart, chartGf, chartPart, extChartPart) = ResolveChart(slideIdx, chartIdx); // If series sub-path, prefix all properties with series{N}. for ChartSetter var chartProps = new Dictionary(); @@ -477,9 +509,14 @@ public List Set(string path, Dictionary properties) { unsupported = ChartHelper.SetChartProperties(chartPart, chartProps); } + else if (extChartPart != null) + { + // cx:chart — delegates to ChartExBuilder.SetChartProperties. + // Same shared implementation as Excel/Word. + unsupported = ChartExBuilder.SetChartProperties(extChartPart, chartProps); + } else { - // cx:chart (extended chart) — chart-internal properties are not supported unsupported = chartProps.Keys.ToList(); } GetSlide(slidePart).Save(); @@ -758,6 +795,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 +824,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 { @@ -932,6 +963,100 @@ public List Set(string path, Dictionary properties) } // Try picture path: /slide[N]/picture[M] or /slide[N]/pic[M] + // OLE set path: /slide[N]/ole[M] + // Replace backing embedded part + refresh ProgID automatically + // when the extension changes. Cleans up the old part to avoid + // storage bloat (mirrors picture path clean-up). + var oleSetMatch = Regex.Match(path, @"^/slide\[(\d+)\]/(?:ole|object|embed)\[(\d+)\]$"); + if (oleSetMatch.Success) + { + var oleSlideIdx = int.Parse(oleSetMatch.Groups[1].Value); + var oleEntryIdx = int.Parse(oleSetMatch.Groups[2].Value); + var oleSlideParts = GetSlideParts().ToList(); + if (oleSlideIdx < 1 || oleSlideIdx > oleSlideParts.Count) + throw new ArgumentException($"Slide {oleSlideIdx} not found (total: {oleSlideParts.Count})"); + var oleSlidePart = oleSlideParts[oleSlideIdx - 1]; + var oleShapeTree = GetSlide(oleSlidePart).CommonSlideData?.ShapeTree + ?? throw new ArgumentException("Slide has no shape tree"); + var oleFrames = oleShapeTree.Elements() + .Where(gf => gf.Descendants().Any()) + .ToList(); + if (oleEntryIdx < 1 || oleEntryIdx > oleFrames.Count) + throw new ArgumentException($"OLE object {oleEntryIdx} not found (total: {oleFrames.Count})"); + var oleFrame = oleFrames[oleEntryIdx - 1]; + var oleEl = oleFrame.Descendants().First(); + var oleUnsupported = new List(); + foreach (var (key, value) in properties) + { + switch (key.ToLowerInvariant()) + { + case "path" or "src": + { + // Delete old payload part and attach the new one. + if (oleEl.Id?.Value is string oldRel && !string.IsNullOrEmpty(oldRel)) + { + try { oleSlidePart.DeletePart(oldRel); } catch { } + } + var (newRel, _) = OfficeCli.Core.OleHelper.AddEmbeddedPart(oleSlidePart, value, _filePath); + oleEl.Id = newRel; + // Auto-refresh progId from the new extension unless + // the caller explicitly pinned one in the same call. + if (!properties.ContainsKey("progId") && !properties.ContainsKey("progid")) + { + var autoProgId = OfficeCli.Core.OleHelper.DetectProgId(value); + OfficeCli.Core.OleHelper.ValidateProgId(autoProgId); + oleEl.ProgId = autoProgId; + } + break; + } + case "progid": + OfficeCli.Core.OleHelper.ValidateProgId(value); + oleEl.ProgId = value; + break; + case "name": + oleEl.Name = value; + break; + case "display": + { + // Strict: only "icon" or "content" are accepted — + // see OleHelper.NormalizeOleDisplay. + var oleDisp = OfficeCli.Core.OleHelper.NormalizeOleDisplay(value); + oleEl.ShowAsIcon = oleDisp != "content"; + break; + } + case "x" or "y" or "width" or "height": + { + var xfrm = oleFrame.Transform ?? (oleFrame.Transform = new Transform()); + var off = xfrm.Offset ?? (xfrm.Offset = new Drawing.Offset { X = 0, Y = 0 }); + var ext = xfrm.Extents ?? (xfrm.Extents = new Drawing.Extents { Cx = 0, Cy = 0 }); + var emu = ParseEmu(value); + var k = key.ToLowerInvariant(); + // CONSISTENCY(ole-nonnegative-size): width/height are + // OOXML positive-sized types (ST_PositiveCoordinate). + // Silently storing a negative EMU breaks the shape + // frame and opens unpredictably in PowerPoint. Reject + // it explicitly; x/y may legitimately be negative + // (off-slide anchors) so they pass through. + if ((k == "width" || k == "height") && emu < 0) + throw new ArgumentException($"{k} must be non-negative"); + switch (k) + { + case "x": off.X = emu; break; + case "y": off.Y = emu; break; + case "width": ext.Cx = emu; break; + case "height": ext.Cy = emu; break; + } + break; + } + default: + oleUnsupported.Add(key); + break; + } + } + GetSlide(oleSlidePart).Save(); + return oleUnsupported; + } + var picSetMatch = Regex.Match(path, @"^/slide\[(\d+)\]/(?:picture|pic)\[(\d+)\]$"); if (picSetMatch.Success) { @@ -976,15 +1101,57 @@ public List Set(string path, Dictionary properties) if (blip == null) { unsupported.Add(key); break; } var (imgStream, imgType) = OfficeCli.Core.ImageSource.Resolve(value); using var imgStreamDispose2 = imgStream; - // Remove old image part to avoid storage bloat + // Remove old image part(s) to avoid storage bloat, + // including the asvg:svgBlip-referenced SVG part + // when the previous image was SVG. var oldEmbedId = blip.Embed?.Value; if (oldEmbedId != null) { try { slidePart.DeletePart(oldEmbedId); } catch { } } - var newImgPart = slidePart.AddImagePart(imgType); - newImgPart.FeedData(imgStream); - blip.Embed = slidePart.GetIdOfPart(newImgPart); + var oldPicSvgRelId = OfficeCli.Core.SvgImageHelper.GetSvgRelId(blip); + if (oldPicSvgRelId != null) + { + try { slidePart.DeletePart(oldPicSvgRelId); } catch { } + } + + if (imgType == ImagePartType.Svg) + { + using var newSvgBuf = new MemoryStream(); + imgStream.CopyTo(newSvgBuf); + newSvgBuf.Position = 0; + var newSvgPart = slidePart.AddImagePart(ImagePartType.Svg); + newSvgPart.FeedData(newSvgBuf); + var newPicSvgRelId = slidePart.GetIdOfPart(newSvgPart); + + var pngFb = slidePart.AddImagePart(ImagePartType.Png); + pngFb.FeedData(new MemoryStream( + OfficeCli.Core.SvgImageHelper.TransparentPng1x1, writable: false)); + blip.Embed = slidePart.GetIdOfPart(pngFb); + OfficeCli.Core.SvgImageHelper.AppendSvgExtension(blip, newPicSvgRelId); + } + else + { + var newImgPart = slidePart.AddImagePart(imgType); + newImgPart.FeedData(imgStream); + blip.Embed = slidePart.GetIdOfPart(newImgPart); + if (oldPicSvgRelId != null) + { + var extLst = blip.GetFirstChild(); + if (extLst != null) + { + foreach (var ext in extLst.Elements().ToList()) + { + if (string.Equals(ext.Uri?.Value, + OfficeCli.Core.SvgImageHelper.SvgExtensionUri, + StringComparison.OrdinalIgnoreCase)) + ext.Remove(); + } + if (!extLst.Elements().Any()) + extLst.Remove(); + } + } + } break; } case "rotation" or "rotate": @@ -998,8 +1165,20 @@ public List Set(string path, Dictionary properties) { var blipFill = pic.BlipFill; if (blipFill == null) { unsupported.Add(key); break; } - var srcRect = blipFill.GetFirstChild() - ?? blipFill.AppendChild(new Drawing.SourceRectangle()); + var srcRect = blipFill.GetFirstChild(); + if (srcRect == null) + { + srcRect = new Drawing.SourceRectangle(); + // CONSISTENCY(ooxml-element-order): in CT_BlipFillProperties + // srcRect must precede the fill-mode element (stretch/tile). + // PowerPoint silently ignores out-of-order srcRect. + var fillMode = (OpenXmlElement?)blipFill.GetFirstChild() + ?? blipFill.GetFirstChild(); + if (fillMode != null) + blipFill.InsertBefore(srcRect, fillMode); + else + blipFill.AppendChild(srcRect); + } if (key.Equals("crop", StringComparison.OrdinalIgnoreCase)) { @@ -1058,6 +1237,14 @@ public List Set(string path, Dictionary properties) case "cropbottom": srcRect.Bottom = pct; break; } } + // Reset semantics: if all four sides are zero (or unset), + // drop the srcRect entirely so the XML is clean. + int L = srcRect.Left?.Value ?? 0; + int T = srcRect.Top?.Value ?? 0; + int R = srcRect.Right?.Value ?? 0; + int B = srcRect.Bottom?.Value ?? 0; + if (L == 0 && T == 0 && R == 0 && B == 0) + srcRect.Remove(); break; } case "opacity": @@ -1145,6 +1332,32 @@ public List Set(string path, Dictionary properties) } case "targets": break; // consumed by align/distribute + case "showfooter": + case "showslidenumber": + case "showdate": + case "showheader": + { + // Toggle header/footer visibility flags on the slide. + // Emits as a + // direct child of . The OpenXml SDK models this + // via DocumentFormat.OpenXml.Presentation.HeaderFooter + // (local name "hf"). Although CT_Slide's published + // schema does not list hf, PowerPoint itself writes it + // on slides when the "Insert > Header & Footer" dialog + // toggles per-slide overrides — we mirror that. + var hf = slide2.GetFirstChild() ?? new HeaderFooter(); + bool isNew = hf.Parent == null; + bool flag = IsTruthy(value); + switch (key.ToLowerInvariant()) + { + case "showfooter": hf.Footer = flag; break; + case "showslidenumber": hf.SlideNumber = flag; break; + case "showdate": hf.DateTime = flag; break; + case "showheader": hf.Header = flag; break; + } + if (isNew) slide2.AppendChild(hf); + break; + } case "layout": { // Change slide layout @@ -1172,7 +1385,7 @@ public List Set(string path, Dictionary properties) if (!GenericXmlQuery.SetGenericAttribute(slide2, key, value)) { if (unsupported.Count == 0) - unsupported.Add($"{key} (valid slide props: background, layout, transition, name, align, distribute, targets)"); + unsupported.Add($"{key} (valid slide props: background, layout, transition, name, align, distribute, targets, showFooter, showSlideNumber, showDate, showHeader)"); else unsupported.Add(key); } @@ -1481,8 +1694,9 @@ public List Set(string path, Dictionary properties) var motionPathValue = properties.GetValueOrDefault("motionpath") ?? properties.GetValueOrDefault("motionPath"); var linkValue = properties.GetValueOrDefault("link"); + var tooltipValue = properties.GetValueOrDefault("tooltip"); var excludeKeys = new HashSet(StringComparer.OrdinalIgnoreCase) - { "animation", "animate", "motionpath", "motionPath", "link", "zorder", "z-order", "order" }; + { "animation", "animate", "motionpath", "motionPath", "link", "tooltip", "zorder", "z-order", "order" }; var shapeProps = properties .Where(kv => !excludeKeys.Contains(kv.Key)) .ToDictionary(kv => kv.Key, kv => kv.Value); @@ -1500,7 +1714,7 @@ public List Set(string path, Dictionary properties) if (motionPathValue != null) ApplyMotionPathAnimation(slidePart, shape, motionPathValue); if (linkValue != null) - ApplyShapeHyperlink(slidePart, shape, linkValue); + ApplyShapeHyperlink(slidePart, shape, linkValue, tooltipValue); GetSlide(slidePart).Save(); return unsupported; @@ -1753,9 +1967,25 @@ public List Set(string path, Dictionary properties) { var grpSpPr = grp.GroupShapeProperties ?? (grp.GroupShapeProperties = new GroupShapeProperties()); var xfrm = grpSpPr.TransformGroup ?? (grpSpPr.TransformGroup = new Drawing.TransformGroup()); - TryApplyPositionSize(key.ToLowerInvariant(), value, - xfrm.Offset ?? (xfrm.Offset = new Drawing.Offset()), - xfrm.Extents ?? (xfrm.Extents = new Drawing.Extents())); + var off = xfrm.Offset ?? (xfrm.Offset = new Drawing.Offset()); + var ext = xfrm.Extents ?? (xfrm.Extents = new Drawing.Extents()); + var keyLower = key.ToLowerInvariant(); + // CONSISTENCY(group-scale-baseline): group scaling needs / + // as a child-coordinate baseline. Before we mutate ext/off, snapshot the + // current ext/off into chExt/chOff if they aren't already present — that + // way the first Set of width/height captures the "before" as the logical + // child coordinate space, so shrinking ext shrinks the rendered children. + if (keyLower is "x" or "y") + { + if (xfrm.ChildOffset == null) + xfrm.ChildOffset = new Drawing.ChildOffset { X = off.X ?? 0, Y = off.Y ?? 0 }; + } + else // width or height + { + if (xfrm.ChildExtents == null) + xfrm.ChildExtents = new Drawing.ChildExtents { Cx = ext.Cx ?? 0, Cy = ext.Cy ?? 0 }; + } + TryApplyPositionSize(keyLower, value, off, ext); break; } case "rotation" or "rotate": diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.ShapeProperties.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.ShapeProperties.cs index c54ae5ccf..04408920f 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.ShapeProperties.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.ShapeProperties.cs @@ -229,6 +229,14 @@ private static List SetRunOrShapeProperties( break; } + case "pattern": + { + var spPr = shape.ShapeProperties; + if (spPr == null) { unsupported.Add(key); break; } + ApplyPatternFill(spPr, value); + break; + } + case "liststyle" or "list": { foreach (var para in shape.TextBody?.Elements() ?? Enumerable.Empty()) @@ -666,6 +674,14 @@ private static List SetRunOrShapeProperties( break; } + case "blur": + { + var spPr = shape.ShapeProperties; + if (spPr == null) { unsupported.Add(key); break; } + ApplyBlur(spPr, value); + break; + } + case "fliph": { var spPr = shape.ShapeProperties; @@ -870,6 +886,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 +1006,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 +1586,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..74a5b8305 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 @@ -1263,7 +1265,18 @@ private static void RenderTextBodyFO(StringBuilder sb, OpenXmlElement textBody, var font = rp?.GetFirstChild()?.Typeface?.Value ?? rp?.GetFirstChild()?.Typeface?.Value; if (font != null && !font.StartsWith("+", StringComparison.Ordinal)) - styles.Add($"font-family:'{HtmlEncode(font)}'"); + { + // foreignObject renders this span as live HTML, so the + // font-family value sits inside an inline CSS string. + // HtmlEncode only protects the HTML attribute layer + // (turns ' into ' which the parser unescapes back + // into ' inside CSS), letting a crafted theme typeface + // close the CSS string and inject rules. Use the same + // allowlist CssSanitize as the HtmlPreview path. + var safe = CssSanitize(font); + if (!string.IsNullOrEmpty(safe)) + styles.Add($"font-family:'{safe}'"); + } // Size — resolve per-paragraph from placeholder inheritance chain int? paraDefaultFontSize = null; @@ -1292,7 +1305,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 @@ -1349,7 +1363,9 @@ private static void RenderTextBodyFO(StringBuilder sb, OpenXmlElement textBody, // Stars — inner radius from adj (default varies by star type) "star4" => BuildStar(4, w, h, ReadAdjValue(presetGeom, 0, 50000) / 100000.0), - "star5" => BuildStar(5, w, h, ReadAdjValue(presetGeom, 0, 19098) / 100000.0), + // CONSISTENCY(star5-adj-scale): OOXML adj for star5 is fraction * 50000 (default 19098 → inner ratio ~0.382). + // Matches Star5Polygon in PowerPointHandler.HtmlPreview.Css.cs. + "star5" => BuildStar(5, w, h, ReadAdjValue(presetGeom, 0, 19098) / 50000.0), "star6" => BuildStar(6, w, h, ReadAdjValue(presetGeom, 0, 28868) / 100000.0), "star8" => BuildStar(8, w, h, ReadAdjValue(presetGeom, 0, 38268) / 100000.0), "star10" => BuildStar(10, w, h, ReadAdjValue(presetGeom, 0, 38268) / 100000.0), diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.View.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.View.cs index 905dd5d46..166f6f4ea 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.View.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.View.cs @@ -145,10 +145,12 @@ public string ViewAsOutline() int textBoxes = shapes.Count(s => !IsTitle(s) && !string.IsNullOrWhiteSpace(GetShapeText(s))); int pictures = GetSlide(slidePart).CommonSlideData?.ShapeTree?.Elements().Count() ?? 0; + int oleObjects = CountSlideOleObjects(slidePart); var details = new List(); if (textBoxes > 0) details.Add($"{textBoxes} text box(es)"); if (pictures > 0) details.Add($"{pictures} picture(s)"); + if (oleObjects > 0) details.Add($"{oleObjects} ole object(s)"); var detailStr = details.Count > 0 ? $" - {string.Join(", ", details)}" : ""; sb.AppendLine($"\u251c\u2500\u2500 Slide {slideNum}: \"{title}\"{detailStr}"); @@ -157,6 +159,28 @@ public string ViewAsOutline() return sb.ToString().TrimEnd(); } + // CONSISTENCY(ole-stats): per-slide OLE counter shared by outline and + // outlineJson. Same dedup rule as ViewAsStats — shapeTree oleObject + // elements count once, orphan embedded/package parts add extras. + private int CountSlideOleObjects(SlidePart slidePart) + { + int count = 0; + var shapeTree = GetSlide(slidePart).CommonSlideData?.ShapeTree; + var referenced = new HashSet(StringComparer.OrdinalIgnoreCase); + if (shapeTree != null) + { + foreach (var oleEl in shapeTree.Descendants()) + { + count++; + if (oleEl.Id?.Value is string rid && !string.IsNullOrEmpty(rid)) + referenced.Add(rid); + } + } + count += slidePart.EmbeddedObjectParts.Count(p => !referenced.Contains(slidePart.GetIdOfPart(p))); + count += slidePart.EmbeddedPackageParts.Count(p => !referenced.Contains(slidePart.GetIdOfPart(p))); + return count; + } + public string ViewAsStats() { var sb = new StringBuilder(); @@ -208,10 +232,32 @@ public string ViewAsStats() } } + // OLE count = oleObj elements + any orphan embedded parts not + // referenced by one. Mirrors how CollectOleNodesForSlide builds + // its result so summary == visible query rows. + int totalOleObjects = 0; + foreach (var slidePart in slideParts) + { + var shapeTree = GetSlide(slidePart).CommonSlideData?.ShapeTree; + var referenced = new HashSet(StringComparer.OrdinalIgnoreCase); + if (shapeTree != null) + { + foreach (var oleEl in shapeTree.Descendants()) + { + totalOleObjects++; + if (oleEl.Id?.Value is string rid && !string.IsNullOrEmpty(rid)) + referenced.Add(rid); + } + } + totalOleObjects += slidePart.EmbeddedObjectParts.Count(p => !referenced.Contains(slidePart.GetIdOfPart(p))); + totalOleObjects += slidePart.EmbeddedPackageParts.Count(p => !referenced.Contains(slidePart.GetIdOfPart(p))); + } + sb.AppendLine($"Slides: {slideParts.Count}"); sb.AppendLine($"Total shapes: {totalShapes}"); sb.AppendLine($"Text boxes: {totalTextBoxes}"); sb.AppendLine($"Pictures: {totalPictures}"); + if (totalOleObjects > 0) sb.AppendLine($"OLE Objects: {totalOleObjects}"); sb.AppendLine($"Words: {totalWords}"); sb.AppendLine($"Slides without title: {slidesWithoutTitle}"); sb.AppendLine($"Pictures without alt text: {picturesWithoutAlt}"); @@ -266,12 +312,32 @@ public JsonNode ViewAsStatsJson() } } + // Mirror the same OLE counting logic as ViewAsStats. + int jsonOleObjects = 0; + foreach (var slidePart in slideParts) + { + var shapeTree = GetSlide(slidePart).CommonSlideData?.ShapeTree; + var referenced = new HashSet(StringComparer.OrdinalIgnoreCase); + if (shapeTree != null) + { + foreach (var oleEl in shapeTree.Descendants()) + { + jsonOleObjects++; + if (oleEl.Id?.Value is string rid && !string.IsNullOrEmpty(rid)) + referenced.Add(rid); + } + } + jsonOleObjects += slidePart.EmbeddedObjectParts.Count(p => !referenced.Contains(slidePart.GetIdOfPart(p))); + jsonOleObjects += slidePart.EmbeddedPackageParts.Count(p => !referenced.Contains(slidePart.GetIdOfPart(p))); + } + var result = new JsonObject { ["slides"] = slideParts.Count, ["totalShapes"] = totalShapes, ["textBoxes"] = totalTextBoxes, ["pictures"] = totalPictures, + ["oleObjects"] = jsonOleObjects, ["words"] = totalWords, ["slidesWithoutTitle"] = slidesWithoutTitle, ["picturesWithoutAlt"] = picturesWithoutAlt @@ -302,12 +368,14 @@ public JsonNode ViewAsOutlineJson() int textBoxes = shapes.Count(s => !IsTitle(s) && !string.IsNullOrWhiteSpace(GetShapeText(s))); int pictures = GetSlide(slidePart).CommonSlideData?.ShapeTree?.Elements().Count() ?? 0; + int oleObjects = CountSlideOleObjects(slidePart); var slide = new JsonObject { ["index"] = slideNum, ["title"] = title, ["textBoxes"] = textBoxes, - ["pictures"] = pictures + ["pictures"] = pictures, + ["oleObjects"] = oleObjects }; slidesArray.Add((JsonNode)slide); } @@ -389,8 +457,25 @@ public List ViewAsIssues(string? issueType = null, int? limit = n int shapeIdx = 0; foreach (var shape in shapes) { + shapeIdx++; + var shapePath = $"/slide[{slideNum}]/{BuildElementPathSegment("shape", shape, shapeIdx)}"; + + // CONSISTENCY(text-overflow-check): merged in from former `check` command. + var overflow = CheckTextOverflow(shape); + if (overflow != null) + { + issues.Add(new DocumentIssue + { + Id = $"O{++issueNum}", + Type = IssueType.Format, + Severity = IssueSeverity.Warning, + Path = shapePath, + Message = overflow + }); + } + var runs = shape.Descendants().ToList(); - if (runs.Count <= 1) { shapeIdx++; continue; } + if (runs.Count <= 1) continue; var fonts = runs.Select(r => r.RunProperties?.GetFirstChild()?.Typeface @@ -404,11 +489,10 @@ 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 = shapePath, Message = $"Inconsistent fonts in text box: {string.Join(", ", fonts)}" }); } - shapeIdx++; } foreach (var pic in shapeTree.Elements()) diff --git a/src/officecli/Handlers/Word/WordHandler.Add.Media.cs b/src/officecli/Handlers/Word/WordHandler.Add.Media.cs index ef43c096f..c68082738 100644 --- a/src/officecli/Handlers/Word/WordHandler.Add.Media.cs +++ b/src/officecli/Handlers/Word/WordHandler.Add.Media.cs @@ -78,6 +78,7 @@ private string AddChart(OpenXmlElement parent, string parentPath, int? index, Di else { cxPara = new Paragraph(cxRun); + AssignParaId(cxPara); AppendToParent(parent, cxPara); } @@ -92,7 +93,7 @@ private string AddChart(OpenXmlElement parent, string parentPath, int? index, Di // Apply deferred properties (axisTitle, dataLabels, etc.) via SetChartProperties // Must be called BEFORE Save() so the in-memory DOM is still available var deferredProps = properties - .Where(kv => Core.ChartHelper.DeferredAddKeys.Contains(kv.Key)) + .Where(kv => Core.ChartHelper.IsDeferredKey(kv.Key)) .ToDictionary(kv => kv.Key, kv => kv.Value); if (deferredProps.Count > 0) Core.ChartHelper.SetChartProperties(chartPart, deferredProps); @@ -131,6 +132,7 @@ private string AddChart(OpenXmlElement parent, string parentPath, int? index, Di else { chartPara = new Paragraph(chartRun); + AssignParaId(chartPara); AppendToParent(parent, chartPara); } @@ -141,23 +143,84 @@ private string AddChart(OpenXmlElement parent, string parentPath, int? index, Di private string AddPicture(OpenXmlElement parent, string parentPath, int? index, Dictionary properties) { if (!properties.TryGetValue("path", out var imgPath) && !properties.TryGetValue("src", out imgPath)) - throw new ArgumentException("'path' (or 'src') property is required for picture type"); + throw new ArgumentException("'src' property is required for picture type"); - var (imgStream, imgPartType) = OfficeCli.Core.ImageSource.Resolve(imgPath); - using var imgStreamDispose = imgStream; + // Buffer the image bytes so we can both feed the image part and sniff + // the native pixel dimensions for auto aspect-ratio calculations. + var (rawStream, imgPartType) = OfficeCli.Core.ImageSource.Resolve(imgPath); + using var rawStreamDispose = rawStream; + using var imgStream = new MemoryStream(); + rawStream.CopyTo(imgStream); + imgStream.Position = 0; var mainPart = _doc.MainDocumentPart!; - var imagePart = mainPart.AddImagePart(imgPartType); - imagePart.FeedData(imgStream); - var relId = mainPart.GetIdOfPart(imagePart); - - // Determine dimensions (default: 6 inches wide, auto height) - long cxEmu = 5486400; // 6 inches in EMUs (914400 * 6) - long cyEmu = 3657600; // 4 inches default - if (properties.TryGetValue("width", out var widthStr)) - cxEmu = ParseEmu(widthStr); - if (properties.TryGetValue("height", out var heightStr)) - cyEmu = ParseEmu(heightStr); + string relId; + string? svgRelId = null; + Stream? fallbackDimStream = null; // source for TryGetDimensions when raster is the fallback + if (imgPartType == ImagePartType.Svg) + { + // OOXML SVG embedding: main blip points to a PNG fallback, and + // a:blip/a:extLst carries an asvg:svgBlip referencing the SVG + // part. Modern Office picks up the SVG; older versions render + // the PNG. See SvgImageHelper for namespace/URI details. + var svgPart = mainPart.AddImagePart(ImagePartType.Svg); + svgPart.FeedData(imgStream); + imgStream.Position = 0; + svgRelId = mainPart.GetIdOfPart(svgPart); + + MemoryStream pngStream; + if (properties.TryGetValue("fallback", out var fallbackPath) && !string.IsNullOrWhiteSpace(fallbackPath)) + { + var (fbRaw, fbType) = OfficeCli.Core.ImageSource.Resolve(fallbackPath); + using var fbDispose = fbRaw; + pngStream = new MemoryStream(); + fbRaw.CopyTo(pngStream); + pngStream.Position = 0; + var fbPart = mainPart.AddImagePart(fbType); + fbPart.FeedData(pngStream); + pngStream.Position = 0; + relId = mainPart.GetIdOfPart(fbPart); + } + else + { + var pngPart = mainPart.AddImagePart(ImagePartType.Png); + pngPart.FeedData(new MemoryStream(OfficeCli.Core.SvgImageHelper.TransparentPng1x1, writable: false)); + relId = mainPart.GetIdOfPart(pngPart); + pngStream = new MemoryStream(OfficeCli.Core.SvgImageHelper.TransparentPng1x1, writable: false); + } + fallbackDimStream = pngStream; + } + else + { + var imagePart = mainPart.AddImagePart(imgPartType); + imagePart.FeedData(imgStream); + imgStream.Position = 0; + relId = mainPart.GetIdOfPart(imagePart); + } + + // Determine dimensions. When only one axis is supplied, compute the + // other from the image's native pixel aspect ratio. When neither is + // supplied, width defaults to 6 inches and height follows the aspect + // ratio (or a 4 inch fallback when the image header cannot be read). + bool hasWidth = properties.TryGetValue("width", out var widthStr); + bool hasHeight = properties.TryGetValue("height", out var heightStr); + long cxEmu = hasWidth ? ParseEmu(widthStr!) : 5486400; // 6 inches fallback + long cyEmu = hasHeight ? ParseEmu(heightStr!) : 3657600; // 4 inches fallback + + if (!hasWidth || !hasHeight) + { + var dims = OfficeCli.Core.ImageSource.TryGetDimensions(imgStream); + if (dims is { Width: > 0, Height: > 0 } d) + { + double ratio = (double)d.Height / d.Width; + if (hasWidth && !hasHeight) + cyEmu = (long)(cxEmu * ratio); + else if (!hasWidth && hasHeight) + cxEmu = (long)(cyEmu / ratio); + else + cyEmu = (long)(cxEmu * ratio); + } + } var altText = properties.GetValueOrDefault("alt", Path.GetFileName(imgPath)); @@ -182,14 +245,35 @@ private string AddPicture(OpenXmlElement parent, string parentPath, int? index, imgRun = CreateImageRun(relId, cxEmu, cyEmu, altText, imgDocPropId); } + // Wire the asvg:svgBlip extension after the run is built. Walking + // the Drawing to find the Blip keeps CreateImageRun / + // CreateAnchorImageRun signature-stable for non-SVG callers. + if (svgRelId != null) + { + var addedBlip = imgRun.Descendants().FirstOrDefault(); + if (addedBlip != null) + OfficeCli.Core.SvgImageHelper.AppendSvgExtension(addedBlip, svgRelId); + } + string resultPath; Paragraph imgPara; if (parent is Paragraph existingPara) { - existingPara.AppendChild(imgRun); + // When --index N is supplied, insert before the Nth existing run + // instead of always appending. Matches AddRun's index semantics. + var runCount = existingPara.Elements().Count(); + if (index.HasValue && index.Value < runCount) + { + var refRun = existingPara.Elements().ElementAt(index.Value); + existingPara.InsertBefore(imgRun, refRun); + } + else + { + existingPara.AppendChild(imgRun); + } imgPara = existingPara; - var imgRunCount = existingPara.Elements().Count(); - resultPath = $"{parentPath}/r[{imgRunCount}]"; + var imgRunIdx = existingPara.Elements().ToList().IndexOf(imgRun) + 1; + resultPath = $"{parentPath}/r[{imgRunIdx}]"; } else if (parent is TableCell imgCell) { @@ -203,26 +287,305 @@ private string AddPicture(OpenXmlElement parent, string parentPath, int? index, else { imgPara = new Paragraph(imgRun); + AssignParaId(imgPara); + // Prevent fixed line spacing (inherited from Normal style) from + // clipping the image to the text line height. + imgPara.PrependChild(new ParagraphProperties( + new SpacingBetweenLines { Line = "240", LineRule = LineSpacingRuleValues.Auto })); imgCell.AppendChild(imgPara); } var imgPIdx = imgCell.Elements().ToList().IndexOf(imgPara) + 1; - resultPath = $"{parentPath}/p[{imgPIdx}]"; + resultPath = $"{parentPath}/{BuildParaPathSegment(imgPara, imgPIdx)}"; } else { imgPara = new Paragraph(imgRun); - var imgParaCount = parent.Elements().Count(); - if (index.HasValue && index.Value < imgParaCount) + AssignParaId(imgPara); + // Prevent fixed line spacing (inherited from Normal style) from + // clipping the image to the text line height. + imgPara.PrependChild(new ParagraphProperties( + new SpacingBetweenLines { Line = "240", LineRule = LineSpacingRuleValues.Auto })); + + // Use ChildElements for index lookup so that tables and sectPr + // siblings do not shift the effective insertion position. This + // matches ResolveAnchorPosition, which computes anchor indices + // against ChildElements. + var allChildren = parent.ChildElements.ToList(); + if (index.HasValue && index.Value < allChildren.Count) { - var refPara = parent.Elements().ElementAt(index.Value); - parent.InsertBefore(imgPara, refPara); - resultPath = $"{parentPath}/p[{index.Value + 1}]"; + var refElement = allChildren[index.Value]; + parent.InsertBefore(imgPara, refElement); + var imgPIdx = parent.Elements().ToList().IndexOf(imgPara) + 1; + resultPath = $"{parentPath}/{BuildParaPathSegment(imgPara, imgPIdx)}"; } else { AppendToParent(parent, imgPara); - resultPath = $"{parentPath}/p[{imgParaCount + 1}]"; + var imgPIdx = parent.Elements().Count(); + resultPath = $"{parentPath}/{BuildParaPathSegment(imgPara, imgPIdx)}"; + } + } + return resultPath; + } + + // ==================== OLE Object Insertion ==================== + // + // Inserts an wrapper containing: + // 1. VML shapetype _x0000_t75 (picture frame, well-known shape ID) + // 2. VML v:shape bound to an icon preview ImagePart + // 3. o:OLEObject naming the ProgID and referencing an + // EmbeddedObjectPart / EmbeddedPackagePart (the binary payload) + // + // Defaults are tuned so callers can just say `--type ole --prop src=...`: + // - ProgID auto-detected from src extension (via OleHelper) + // - Backing part kind auto-chosen (Package for .docx/.xlsx/.pptx, Object otherwise) + // - Icon preview = tiny PNG placeholder + // - Dimensions default to 2in × 0.75in (matches Office's show-as-icon frame) + // + // Caller can override: progId, width, height, icon (png/jpg/emf file path), + // display (icon|content). display=content flips DrawAspect to "Content". + private string AddOle(OpenXmlElement parent, string parentPath, int? index, Dictionary properties) + { + properties ??= new Dictionary(); + var srcPath = OfficeCli.Core.OleHelper.RequireSource(properties); + OfficeCli.Core.OleHelper.WarnOnUnknownOleProps(properties); + + var mainPart = _doc.MainDocumentPart!; + + // Determine the host part that owns the parent element. + // For /header[N] or /footer[N], the parent lives inside a + // HeaderPart/FooterPart, so the embedded payload AND icon ImagePart + // relationships must be attached to that part — not to + // MainDocumentPart — otherwise OpenXmlValidator rejects the + // cross-part r:id with a NullReferenceException. + OpenXmlPart hostPart = mainPart; + { + var headerAncestor = parent as Header ?? parent.Ancestors
    ().FirstOrDefault(); + if (headerAncestor != null) + { + var hp = mainPart.HeaderParts.FirstOrDefault(p => ReferenceEquals(p.Header, headerAncestor)); + if (hp != null) hostPart = hp; + } + else + { + var footerAncestor = parent as Footer ?? parent.Ancestors
    ().FirstOrDefault(); + if (footerAncestor != null) + { + var fp = mainPart.FooterParts.FirstOrDefault(p => ReferenceEquals(p.Footer, footerAncestor)); + if (fp != null) hostPart = fp; + } + } + } + + // 1. Create the embedded binary payload part and rel id on the host part. + var (embedRelId, _) = OfficeCli.Core.OleHelper.AddEmbeddedPart(hostPart, srcPath, _filePath); + + // 2. Resolve ProgID (explicit > auto-detected from extension). + var progId = OfficeCli.Core.OleHelper.ResolveProgId(properties, srcPath); + + // 3. Create the icon preview ImagePart on the host part (same part + // that owns the OLE element itself). Attaching to MainDocumentPart + // when the OLE lives in a header/footer would produce a dangling + // cross-part relationship — see host part resolution above. + var (_, iconRelId) = OfficeCli.Core.OleHelper.CreateIconPart(hostPart, properties); + + // 4. Dimensions. Word VML shapes take points in their style string. + // Defaults match OleHelper's 2in × 0.75in icon frame. + long cxEmu = properties.TryGetValue("width", out var wStr) + ? ParseEmu(wStr) : OfficeCli.Core.OleHelper.DefaultOleWidthEmu; + long cyEmu = properties.TryGetValue("height", out var hStr) + ? ParseEmu(hStr) : OfficeCli.Core.OleHelper.DefaultOleHeightEmu; + // EMU → points (914400 EMU/inch, 72 points/inch). + double cxPt = cxEmu / 12700.0; + double cyPt = cyEmu / 12700.0; + // Twips for w:dxaOrig/w:dyaOrig (20 twips/point). + long cxTwips = (long)(cxPt * 20); + long cyTwips = (long)(cyPt * 20); + + // 5. DrawAspect: "Icon" (default) or "Content" (live preview). + // Strict validation: unknown values throw rather than silently + // falling back to Icon — see OleHelper.NormalizeOleDisplay. + var display = OfficeCli.Core.OleHelper.NormalizeOleDisplay( + properties.GetValueOrDefault("display", "icon")); + var drawAspect = display == "content" ? "Content" : "Icon"; + + // 6. ObjectID: VML requires a unique "_nnnnnnnnnn" token. + // Count existing OLE objects and assign a monotonic id so two + // OLEs added within the same wallclock second don't collide + // (the old scheme used ToUnixTimeSeconds()). + var existingOleCount = mainPart.Document?.Body?.Descendants().Count() ?? 0; + var oleSeq = existingOleCount + 1; + var objectId = "_" + (1000000000 + oleSeq); + + // 7. Build the w:object XML. The shapetype + shape + OLEObject + // triple is the canonical form Word itself writes for OLE. + // ShapeID must also be unique per OLE in the document — base it + // on the OLE sequence (not NextDocPropId, which is shared with + // Drawing DocProperties and can collide). D4 gives 9999 slots. + var shapeId = $"_x0000_i1{oleSeq:D4}"; + + // Optional friendly name → v:shape alt="..." attribute. + // CONSISTENCY(ole-name): the VML CT_OleObject complex type has no + // Name attribute (valid attrs: Type/ProgID/ShapeID/DrawAspect/ + // ObjectID/r:id/UpdateMode/LinkType/LockedField/FieldCodes — see + // DocumentFormat.OpenXml.Vml.Office.OleObject). Writing Name= on + // o:OLEObject produces a schema validation error. Use the + // surrounding v:shape element's "alt" attribute (Alternate Text, + // closest semantic match in VML) for the friendly name. Get reads + // it back from the same place, preserving Format["name"] round-trip. + var shapeAltAttr = ""; + if (properties.TryGetValue("name", out var oleName) && !string.IsNullOrEmpty(oleName)) + shapeAltAttr = $" alt=\"{System.Security.SecurityElement.Escape(oleName)}\""; + + // CONSISTENCY(ole-shapetype-dedup): v:shapetype id="_x0000_t75" must be + // unique across the whole document.xml — OOXML validation rejects + // duplicate shapetype ids. If the document already has an + // _x0000_t75 shapetype (left over from a prior picture/OLE insert), + // skip re-emitting it and reference the existing one from v:shape. + var shapetypeAlreadyExists = false; + foreach (var existingObj in mainPart.Document?.Body?.Descendants() ?? Enumerable.Empty()) + { + foreach (var st in existingObj.Descendants().Where(e => e.LocalName == "shapetype")) + { + var idAttr = st.GetAttributes().FirstOrDefault(a => a.LocalName == "id"); + if (idAttr.Value == "_x0000_t75") { shapetypeAlreadyExists = true; break; } + } + if (shapetypeAlreadyExists) break; + } + + var shapetypeXml = shapetypeAlreadyExists ? "" : """ + + + + + + + + + + + + + + + + + + + +"""; + + var oleXml = $""" + +{shapetypeXml} + + + + +"""; + var oleObject = new EmbeddedObject(oleXml); + + // 8. Wrap in a Run and insert it, mirroring the AddPicture positional logic. + var oleRun = new Run(oleObject); + + // If the parent is a block-level SDT, insert into its SdtContentBlock + // (creating it if missing) instead of appending directly to the SdtBlock. + // Direct SdtBlock child paragraphs violate the schema and get silently + // stripped by Word on reload — which previously broke OLE persistence + // across reopen when added inside an SDT container. See + // OleTestTeamRound6.Word_OleInsideSdt_QueryFindsOle. + if (parent is SdtBlock sdtBlockParent) + { + var contentBlock = sdtBlockParent.GetFirstChild(); + if (contentBlock == null) + { + contentBlock = new SdtContentBlock(); + sdtBlockParent.AppendChild(contentBlock); + } + parent = contentBlock; + } + // Inline SDT runs live inside a w:p parent: route the OLE to that + // surrounding paragraph so insertion follows the normal run path. + else if (parent is SdtRun sdtRunParent) + { + var contentRun = sdtRunParent.GetFirstChild(); + if (contentRun != null) + contentRun.AppendChild(oleRun); + else + sdtRunParent.AppendChild(new SdtContentRun(oleRun)); + var parentParaInline = sdtRunParent.Ancestors().FirstOrDefault(); + if (parentParaInline != null) + { + var runs = GetAllRuns(parentParaInline); + var runIdxInline = runs.IndexOf(oleRun) + 1; + return $"{parentPath}/r[{runIdxInline}]"; + } + return parentPath + "/r[1]"; + } + + string resultPath; + if (parent is Paragraph existingPara) + { + var runCount = existingPara.Elements().Count(); + if (index.HasValue && index.Value < runCount) + { + var refRun = existingPara.Elements().ElementAt(index.Value); + existingPara.InsertBefore(oleRun, refRun); + } + else + { + existingPara.AppendChild(oleRun); + } + var olePIdx = 1; + foreach (var para in parent.Parent?.Elements() ?? Enumerable.Empty()) + { + if (ReferenceEquals(para, existingPara)) break; + olePIdx++; + } + var oleRunIdx = existingPara.Elements().ToList().IndexOf(oleRun) + 1; + resultPath = $"{parentPath}/r[{oleRunIdx}]"; + } + else if (parent is TableCell oleCell) + { + var firstCellPara = oleCell.Elements().FirstOrDefault(); + Paragraph olePara; + if (firstCellPara != null && !firstCellPara.Elements().Any()) + { + firstCellPara.AppendChild(oleRun); + olePara = firstCellPara; + } + else + { + olePara = new Paragraph(oleRun); + AssignParaId(olePara); + oleCell.AppendChild(olePara); + } + var olePIdx = oleCell.Elements().ToList().IndexOf(olePara) + 1; + // CONSISTENCY(ole-run-path): same /r[1] suffix as the else branch + // below — the OLE run is the addressable target, not the paragraph. + var oleCellRunIdx = olePara.Elements().ToList().IndexOf(oleRun) + 1; + resultPath = $"{parentPath}/{BuildParaPathSegment(olePara, olePIdx)}/r[{oleCellRunIdx}]"; + } + else + { + var olePara = new Paragraph(oleRun); + AssignParaId(olePara); + var allChildren = parent.ChildElements.ToList(); + if (index.HasValue && index.Value < allChildren.Count) + { + var refElement = allChildren[index.Value]; + parent.InsertBefore(olePara, refElement); + } + else + { + AppendToParent(parent, olePara); } + var olePIdx = parent.Elements().ToList().IndexOf(olePara) + 1; + // Return the /r[1] address so callers can Set/Get/Remove the + // OLE run directly. Picture's Add returns a paragraph-level + // path because the paragraph Set is meaningful (font, style); + // for OLE, the only interesting target is the run itself. + resultPath = $"{parentPath}/{BuildParaPathSegment(olePara, olePIdx)}/r[1]"; } return resultPath; } diff --git a/src/officecli/Handlers/Word/WordHandler.Add.Misc.cs b/src/officecli/Handlers/Word/WordHandler.Add.Misc.cs index d967d2548..ca3537bcb 100644 --- a/src/officecli/Handlers/Word/WordHandler.Add.Misc.cs +++ b/src/officecli/Handlers/Word/WordHandler.Add.Misc.cs @@ -89,9 +89,16 @@ private string AddBookmark(OpenXmlElement parent, string parentPath, int? index, if (properties.TryGetValue("text", out var bkText)) { - parent.AppendChild(bookmarkStart); - parent.AppendChild(new Run(new Text(bkText) { Space = SpaceProcessingModeValues.Preserve })); - parent.AppendChild(bookmarkEnd); + // Try to find existing runs whose concatenated text contains the bookmark text + var runs = parent.Elements().ToList(); + var wrapped = TryWrapExistingRunsWithBookmark(parent, runs, bkText, bookmarkStart, bookmarkEnd); + if (!wrapped) + { + // No matching text found — create a new run as fallback + parent.AppendChild(bookmarkStart); + parent.AppendChild(new Run(new Text(bkText) { Space = SpaceProcessingModeValues.Preserve })); + parent.AppendChild(bookmarkEnd); + } } else { @@ -103,6 +110,99 @@ private string AddBookmark(OpenXmlElement parent, string parentPath, int? index, return resultPath; } + /// + /// Tries to wrap existing runs whose concatenated text contains + /// with bookmarkStart/bookmarkEnd tags. Returns true if wrapping succeeded. + /// + private static bool TryWrapExistingRunsWithBookmark( + OpenXmlElement parent, List runs, string targetText, + BookmarkStart bookmarkStart, BookmarkEnd bookmarkEnd) + { + if (runs.Count == 0 || string.IsNullOrEmpty(targetText)) + return false; + + // Build a map: for each run, track the cumulative start offset and its text + var runTexts = new List<(Run Run, int Start, string Text)>(); + var offset = 0; + foreach (var run in runs) + { + var t = string.Concat(run.Elements().Select(x => x.Text)); + runTexts.Add((run, offset, t)); + offset += t.Length; + } + var fullText = string.Concat(runTexts.Select(r => r.Text)); + + var matchIndex = fullText.IndexOf(targetText, StringComparison.Ordinal); + if (matchIndex < 0) + return false; + + var matchEnd = matchIndex + targetText.Length; + + // Find runs that overlap with [matchIndex, matchEnd) + var firstRunIdx = -1; + var lastRunIdx = -1; + for (var i = 0; i < runTexts.Count; i++) + { + var runStart = runTexts[i].Start; + var runEnd = runStart + runTexts[i].Text.Length; + if (runEnd <= matchIndex) continue; + if (runStart >= matchEnd) break; + if (firstRunIdx < 0) firstRunIdx = i; + lastRunIdx = i; + } + + if (firstRunIdx < 0) return false; + + // Handle partial overlap at the start: split the first run if needed + var firstRunInfo = runTexts[firstRunIdx]; + if (matchIndex > firstRunInfo.Start) + { + var splitPos = matchIndex - firstRunInfo.Start; + var beforeText = firstRunInfo.Text[..splitPos]; + var afterText = firstRunInfo.Text[splitPos..]; + + var beforeRun = (Run)firstRunInfo.Run.CloneNode(true); + SetRunText(beforeRun, beforeText); + parent.InsertBefore(beforeRun, firstRunInfo.Run); + + SetRunText(firstRunInfo.Run, afterText); + // Update info + runTexts[firstRunIdx] = (firstRunInfo.Run, matchIndex, afterText); + } + + // Handle partial overlap at the end: split the last run if needed + var lastRunInfo = runTexts[lastRunIdx]; + var lastRunEnd = lastRunInfo.Start + lastRunInfo.Text.Length; + if (matchEnd < lastRunEnd) + { + var splitPos = matchEnd - lastRunInfo.Start; + var keepText = lastRunInfo.Text[..splitPos]; + var tailText = lastRunInfo.Text[splitPos..]; + + var tailRun = (Run)lastRunInfo.Run.CloneNode(true); + SetRunText(tailRun, tailText); + parent.InsertAfter(tailRun, lastRunInfo.Run); + + SetRunText(lastRunInfo.Run, keepText); + runTexts[lastRunIdx] = (lastRunInfo.Run, lastRunInfo.Start, keepText); + } + + // Insert bookmarkStart before the first matched run + parent.InsertBefore(bookmarkStart, runTexts[firstRunIdx].Run); + + // Insert bookmarkEnd after the last matched run + parent.InsertAfter(bookmarkEnd, runTexts[lastRunIdx].Run); + + return true; + } + + private static void SetRunText(Run run, string text) + { + var existing = run.Elements().ToList(); + foreach (var t in existing) t.Remove(); + run.AppendChild(new Text(text) { Space = SpaceProcessingModeValues.Preserve }); + } + private string AddHyperlink(OpenXmlElement parent, string parentPath, int? index, Dictionary properties) { var hasUrl = properties.TryGetValue("url", out var hlUrl) || properties.TryGetValue("href", out hlUrl); @@ -162,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"); @@ -178,16 +279,88 @@ private string AddField(OpenXmlElement parent, string parentPath, int? index, Di ?? properties.GetValueOrDefault("type"); if (ft != null) effectiveType = ft.ToLowerInvariant(); } + // Extract named parameters for field types that require them + string? mergeFieldName = null; + string? refBookmarkName = null; + string? seqIdentifier = null; + + if (effectiveType == "mergefield") + { + mergeFieldName = properties.GetValueOrDefault("fieldName") + ?? properties.GetValueOrDefault("fieldname") + ?? properties.GetValueOrDefault("name"); + if (string.IsNullOrWhiteSpace(mergeFieldName)) + throw new ArgumentException("MERGEFIELD requires a 'fieldName' property (e.g. --prop fieldName=CustomerName)."); + } + else if (effectiveType is "ref" or "pageref" or "noteref") + { + refBookmarkName = properties.GetValueOrDefault("bookmarkName") + ?? properties.GetValueOrDefault("bookmarkname") + ?? properties.GetValueOrDefault("bookmark") + ?? properties.GetValueOrDefault("name"); + if (string.IsNullOrWhiteSpace(refBookmarkName)) + throw new ArgumentException($"{effectiveType.ToUpperInvariant()} requires a 'bookmarkName' property (e.g. --prop bookmarkName=MyBookmark)."); + } + else if (effectiveType == "seq") + { + seqIdentifier = properties.GetValueOrDefault("identifier") + ?? properties.GetValueOrDefault("name") + ?? properties.GetValueOrDefault("id"); + if (string.IsNullOrWhiteSpace(seqIdentifier)) + throw new ArgumentException("SEQ requires an 'identifier' property (e.g. --prop identifier=Figure)."); + } + + // For STYLEREF and DOCPROPERTY, extract the required name parameter + string? styleRefName = null; + if (effectiveType == "styleref") + { + styleRefName = properties.GetValueOrDefault("styleName") + ?? properties.GetValueOrDefault("stylename") + ?? properties.GetValueOrDefault("name"); + if (string.IsNullOrWhiteSpace(styleRefName)) + throw new ArgumentException("STYLEREF requires a 'styleName' property (e.g. --prop styleName=\"Heading 1\")."); + } + string? docPropertyName = null; + if (effectiveType == "docproperty") + { + docPropertyName = properties.GetValueOrDefault("propertyName") + ?? properties.GetValueOrDefault("propertyname") + ?? properties.GetValueOrDefault("name"); + if (string.IsNullOrWhiteSpace(docPropertyName)) + throw new ArgumentException("DOCPROPERTY requires a 'propertyName' property (e.g. --prop propertyName=Department)."); + } + var fieldInstr = effectiveType switch { "pagenum" or "pagenumber" or "page" => " PAGE ", "numpages" => " NUMPAGES ", + "sectionpages" => " SECTIONPAGES ", + "section" => " SECTION ", "date" => " DATE \\@ \"yyyy-MM-dd\" ", + "createdate" => " CREATEDATE \\@ \"yyyy-MM-dd\" ", + "savedate" => " SAVEDATE \\@ \"yyyy-MM-dd\" ", + "printdate" => " PRINTDATE \\@ \"yyyy-MM-dd\" ", + "edittime" => " EDITTIME ", "author" => " AUTHOR ", + "lastsavedby" => " LASTSAVEDBY ", "title" => " TITLE ", "subject" => " SUBJECT ", "filename" => " FILENAME ", "time" => " TIME ", + "numwords" => " NUMWORDS ", + "numchars" => " NUMCHARS ", + "revnum" => " REVNUM ", + "template" => " TEMPLATE ", + "comments" or "doccomments" => " COMMENTS ", + "keywords" => " KEYWORDS ", + "mergefield" => $" MERGEFIELD {mergeFieldName} ", + "ref" => $" REF {refBookmarkName}{(IsTruthy(properties.GetValueOrDefault("hyperlink")) ? " \\h" : "")} ", + "pageref" => $" PAGEREF {refBookmarkName}{(IsTruthy(properties.GetValueOrDefault("hyperlink")) ? " \\h" : "")} ", + "noteref" => $" NOTEREF {refBookmarkName}{(IsTruthy(properties.GetValueOrDefault("hyperlink")) ? " \\h" : "")} ", + "seq" => $" SEQ {seqIdentifier} ", + "styleref" => $" STYLEREF \"{styleRefName}\" ", + "docproperty" => $" DOCPROPERTY \"{docPropertyName}\" ", + "if" => BuildIfFieldInstruction(properties), _ => properties.ContainsKey("instruction") ? properties["instruction"] : throw new ArgumentException($"Unknown field type '{effectiveType}'. Provide a known type or an 'instruction' property.") @@ -196,7 +369,17 @@ private string AddField(OpenXmlElement parent, string parentPath, int? index, Di if (properties.TryGetValue("instruction", out var instr)) fieldInstr = instr.StartsWith(" ") ? instr : $" {instr} "; - var fieldPlaceholder = properties.GetValueOrDefault("text", "1"); + var fieldPlaceholder = properties.ContainsKey("text") + ? properties["text"] + : effectiveType switch + { + "mergefield" => $"\u00AB{mergeFieldName}\u00BB", + "ref" or "noteref" => $"\u00AB{refBookmarkName}\u00BB", + "styleref" => $"\u00AB{styleRefName}\u00BB", + "docproperty" => $"\u00AB{docPropertyName}\u00BB", + "if" => properties.GetValueOrDefault("trueText", ""), + _ => "1" + }; // Build complex field: fldChar(begin) + instrText + fldChar(separate) + result + fldChar(end) var fieldRunBegin = new Run(new FieldChar { FieldCharType = FieldCharValues.Begin }); @@ -257,11 +440,22 @@ 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; } + private static string BuildIfFieldInstruction(Dictionary properties) + { + var expression = properties.GetValueOrDefault("expression") + ?? properties.GetValueOrDefault("condition"); + if (string.IsNullOrWhiteSpace(expression)) + throw new ArgumentException("IF requires an 'expression' property (e.g. --prop expression=\"MERGEFIELD Gender = \\\"Male\\\"\")."); + var trueText = properties.GetValueOrDefault("trueText", properties.GetValueOrDefault("truetext", "")); + var falseText = properties.GetValueOrDefault("falseText", properties.GetValueOrDefault("falsetext", "")); + return $" IF {expression} \"{trueText}\" \"{falseText}\" "; + } + private string AddBreak(OpenXmlElement parent, string parentPath, int? index, Dictionary properties, string type) { var body = _doc.MainDocumentPart?.Document?.Body @@ -293,7 +487,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 { @@ -301,7 +495,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; } @@ -332,7 +526,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 }); @@ -407,8 +602,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 { @@ -416,7 +615,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 }); @@ -611,7 +810,7 @@ private string AddDefault(OpenXmlElement parent, string parentPath, int? index, var created = GenericXmlQuery.TryCreateTypedElement(parent, type, properties, index); if (created == null) throw new ArgumentException($"Unknown element type '{type}' for {parentPath}. " + - "Valid types: paragraph (p), run (r), table (tbl), row, cell, picture, chart, equation, comment, section, footnote, endnote, toc, style, watermark, bookmark, hyperlink, field, break, sdt, header, footer. " + + "Valid types: paragraph (p), run (r), table (tbl), row, cell, picture, chart, ole (object, embed), equation, comment, section, footnote, endnote, toc, style, watermark, bookmark, hyperlink, field, break, sdt, header, footer. " + "Use 'officecli docx add' for details."); var siblings = parent.ChildElements.Where(e => e.LocalName == created.LocalName).ToList(); diff --git a/src/officecli/Handlers/Word/WordHandler.Add.Structure.cs b/src/officecli/Handlers/Word/WordHandler.Add.Structure.cs index 5cdf00a94..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)) @@ -346,6 +369,7 @@ private string AddHeader(OpenXmlElement parent, string parentPath, int? index, D var headerPart = mainPartH.AddNewPart(); var hPara = new Paragraph(); + AssignParaId(hPara); var hPProps = new ParagraphProperties(); if (properties.TryGetValue("alignment", out var hAlign) || properties.TryGetValue("align", out hAlign)) @@ -454,6 +478,7 @@ private string AddFooter(OpenXmlElement parent, string parentPath, int? index, D var footerPart = mainPartF.AddNewPart(); var fPara = new Paragraph(); + AssignParaId(fPara); var fPProps = new ParagraphProperties(); if (properties.TryGetValue("alignment", out var fAlign) || properties.TryGetValue("align", out fAlign)) diff --git a/src/officecli/Handlers/Word/WordHandler.Add.Table.cs b/src/officecli/Handlers/Word/WordHandler.Add.Table.cs index 032604559..664845886 100644 --- a/src/officecli/Handlers/Word/WordHandler.Add.Table.cs +++ b/src/officecli/Handlers/Word/WordHandler.Add.Table.cs @@ -37,12 +37,12 @@ private string AddTable(OpenXmlElement parent, string parentPath, int? index, Di ApplyTableBorders(tblProps, bk, bv); } - // Parse data if provided: "H1,H2;R1C1,R1C2;R2C1,R2C2" or CSV file path + // Parse data if provided: "H1,H2;R1C1,R1C2;R2C1,R2C2" or CSV file/URL/data-URI string[][]? tableData = null; if (properties.TryGetValue("data", out var dataStr)) { - if (File.Exists(dataStr)) - tableData = File.ReadAllLines(dataStr) + if (OfficeCli.Core.FileSource.IsResolvable(dataStr)) + tableData = OfficeCli.Core.FileSource.ResolveLines(dataStr) .Where(l => !string.IsNullOrWhiteSpace(l)) .Select(l => l.Split(',').Select(c => c.Trim()).ToArray()) .ToArray(); @@ -167,6 +167,7 @@ private string AddTable(OpenXmlElement parent, string parentPath, int? index, Di ? tableData[r][c] : (properties.TryGetValue($"r{r + 1}c{c + 1}", out var rc) ? rc : ""); var cellPara = new Paragraph(new ParagraphProperties( new SpacingBetweenLines { After = "0", Line = "240", LineRule = LineSpacingRuleValues.Auto })); + AssignParaId(cellPara); if (!string.IsNullOrEmpty(cellText)) cellPara.AppendChild(new Run(new Text(cellText) { Space = SpaceProcessingModeValues.Preserve })); var cell = new TableCell(cellPara); @@ -177,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
    . The becomes position:relative + // only when diagonals are actually present to minimize CSS + // regression surface. + var borderTlBr = tcPr?.GetFirstChild(); + var borderBlTr = tcPr?.GetFirstChild(); + var tlBrCss = TableBorderToCss(borderTlBr, themeColors); + var blTrCss = TableBorderToCss(borderBlTr, themeColors); + bool hasDiag = (tlBrCss != null && tlBrCss != "none") + || (blTrCss != null && blTrCss != "none"); + if (hasDiag) + cellStyles.Add("position:relative"); // Cell margins/padding var marL = tcPr?.LeftMargin?.Value; @@ -191,7 +204,24 @@ private static void RenderTable(StringBuilder sb, GraphicFrame gf, Dictionary 1) skipCols = (int)gridSpan - 1; - sb.AppendLine($" {HtmlEncode(cellText)}
    ().Count(); return $"{parentPath}/tbl[{tblCount}]"; } @@ -216,6 +220,7 @@ private string AddRow(OpenXmlElement parent, string parentPath, int? index, Dict { var cellText = properties.TryGetValue($"c{c + 1}", out var ct) ? ct : ""; var cellPara = new Paragraph(); + AssignParaId(cellPara); if (!string.IsNullOrEmpty(cellText)) cellPara.AppendChild(new Run(new Text(cellText) { Space = SpaceProcessingModeValues.Preserve })); newRow.AppendChild(new TableCell(cellPara)); @@ -244,6 +249,7 @@ private string AddCell(OpenXmlElement parent, string parentPath, int? index, Dic throw new ArgumentException("Cells can only be added to a table row: /body/tbl[N]/tr[M]"); var cellParagraph = new Paragraph(); + AssignParaId(cellParagraph); if (properties.TryGetValue("text", out var cellTxt)) cellParagraph.AppendChild(new Run(new Text(cellTxt) { Space = SpaceProcessingModeValues.Preserve })); diff --git a/src/officecli/Handlers/Word/WordHandler.Add.Text.cs b/src/officecli/Handlers/Word/WordHandler.Add.Text.cs index b4c743065..ed30ad57a 100644 --- a/src/officecli/Handlers/Word/WordHandler.Add.Text.cs +++ b/src/officecli/Handlers/Word/WordHandler.Add.Text.cs @@ -19,6 +19,7 @@ private string AddParagraph(OpenXmlElement parent, string parentPath, int? index { string resultPath; var para = new Paragraph(); + AssignParaId(para); var pProps = new ParagraphProperties(); if (properties.TryGetValue("style", out var style)) @@ -27,12 +28,14 @@ private string AddParagraph(OpenXmlElement parent, string parentPath, int? index pProps.Justification = new Justification { Val = ParseJustification(alignment) }; if (properties.TryGetValue("firstlineindent", out var indent) || properties.TryGetValue("firstLineIndent", out indent)) { - // Validate range — OOXML stores as StringValue but must fit within reasonable twip range - if (long.TryParse(indent, out var indentLong) && (indentLong < 0 || indentLong > 31680)) + // Lenient input: accept "2cm", "0.5in", "18pt", or bare twips (backward compat). + // SpacingConverter.ParseWordSpacing treats bare numbers as twips. + var indentTwips = SpacingConverter.ParseWordSpacing(indent); + if (indentTwips > 31680) throw new OverflowException($"First line indent value out of range (0-31680 twips): {indent}"); pProps.Indentation = new Indentation { - FirstLine = indent // raw twips, consistent with Set and Get + FirstLine = indentTwips.ToString() // raw twips, consistent with Set and Get }; } if (properties.TryGetValue("spacebefore", out var sb4) || properties.TryGetValue("spaceBefore", out sb4)) @@ -132,7 +135,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 @@ -162,7 +165,7 @@ private string AddParagraph(OpenXmlElement parent, string parentPath, int? index rProps.Color = new Color { Val = SanitizeHex(pColor) }; if (properties.TryGetValue("underline", out var pUnderline)) { - var ulVal = pUnderline.ToLowerInvariant() switch { "true" => "single", "false" or "none" => "none", _ => pUnderline }; + var ulVal = NormalizeUnderlineValue(pUnderline); rProps.Underline = new Underline { Val = new UnderlineValues(ulVal) }; } if ((properties.TryGetValue("strike", out var pStrike) || properties.TryGetValue("strikethrough", out pStrike)) && IsTruthy(pStrike)) @@ -177,6 +180,20 @@ private string AddParagraph(OpenXmlElement parent, string parentPath, int? index } if (properties.TryGetValue("dstrike", out var pDstrike) && IsTruthy(pDstrike)) rProps.DoubleStrike = new DoubleStrike(); + if (properties.TryGetValue("vanish", out var pVanish) && IsTruthy(pVanish)) + rProps.Vanish = new Vanish(); + if (properties.TryGetValue("outline", out var pOutline) && IsTruthy(pOutline)) + rProps.Outline = new Outline(); + if (properties.TryGetValue("shadow", out var pShadow) && IsTruthy(pShadow)) + rProps.Shadow = new Shadow(); + if (properties.TryGetValue("emboss", out var pEmboss) && IsTruthy(pEmboss)) + rProps.Emboss = new Emboss(); + if (properties.TryGetValue("imprint", out var pImprint) && IsTruthy(pImprint)) + rProps.Imprint = new Imprint(); + if (properties.TryGetValue("noproof", out var pNoProof) && IsTruthy(pNoProof)) + rProps.NoProof = new NoProof(); + if (properties.TryGetValue("rtl", out var pRtl) && IsTruthy(pRtl)) + rProps.RightToLeftText = new RightToLeftText(); if (properties.TryGetValue("vertAlign", out var pVertAlign) || properties.TryGetValue("vertalign", out pVertAlign)) { rProps.VerticalTextAlignment = new VerticalTextAlignment @@ -234,17 +251,23 @@ private string AddParagraph(OpenXmlElement parent, string parentPath, int? index para.AppendChild(run); } - var paraCount = parent.Elements().Count(); - if (index.HasValue && index.Value < paraCount) + // Use ChildElements for index lookup so that tables and sectPr + // siblings do not shift the effective insertion position. This + // matches ResolveAnchorPosition, which computes anchor indices + // against ChildElements. + var allChildren = parent.ChildElements.ToList(); + if (index.HasValue && index.Value < allChildren.Count) { - var refElement = parent.Elements().ElementAt(index.Value); + var refElement = allChildren[index.Value]; parent.InsertBefore(para, refElement); - resultPath = $"{parentPath}/p[{index.Value + 1}]"; + var paraPosIdx = parent.Elements().ToList().IndexOf(para) + 1; + resultPath = $"{parentPath}/{BuildParaPathSegment(para, paraPosIdx)}"; } else { AppendToParent(parent, para); - resultPath = $"{parentPath}/p[{paraCount + 1}]"; + var paraCount = parent.Elements().Count(); + resultPath = $"{parentPath}/{BuildParaPathSegment(para, paraCount)}"; } return resultPath; } @@ -282,24 +305,40 @@ private string AddEquation(OpenXmlElement parent, string parentPath, int? index, var mathPara = new M.Paragraph(oMath); - if (parent is Body || parent is SdtBlock) + // Display equation must be a direct child of Body (wrapped in w:p). + // If parent is a Paragraph, insert after that paragraph as a sibling. + var insertTarget = parent; + OpenXmlElement? insertAfter = null; + if (parent is Paragraph parentPara) + { + insertTarget = parentPara.Parent ?? parent; + insertAfter = parentPara; + } + + if (insertTarget is Body || insertTarget is SdtBlock) { // Wrap m:oMathPara in w:p for schema validity var wrapPara = new Paragraph(mathPara); - var mathParaCount = parent.Descendants().Count(); - if (index.HasValue) + AssignParaId(wrapPara); + if (insertAfter != null) { - var children = parent.ChildElements.ToList(); + insertTarget.InsertAfter(wrapPara, insertAfter); + } + else if (index.HasValue) + { + var children = insertTarget.ChildElements.ToList(); if (index.Value < children.Count) - parent.InsertBefore(wrapPara, children[index.Value]); + insertTarget.InsertBefore(wrapPara, children[index.Value]); else - AppendToParent(parent, wrapPara); + AppendToParent(insertTarget, wrapPara); } else { - AppendToParent(parent, wrapPara); + AppendToParent(insertTarget, wrapPara); } - resultPath = $"{parentPath}/oMathPara[{mathParaCount + 1}]"; + var mathParaCount = insertTarget.Descendants().Count(); + var bodyPath = insertAfter != null ? parentPath.Substring(0, parentPath.LastIndexOf('/')) : parentPath; + resultPath = $"{bodyPath}/oMathPara[{mathParaCount}]"; } else { @@ -332,7 +371,7 @@ private string AddRun(OpenXmlElement parent, string parentPath, int? index, Dict newRProps.Color = new Color { Val = SanitizeHex(rColor) }; if (properties.TryGetValue("underline", out var rUnderline)) { - var ulVal = rUnderline.ToLowerInvariant() switch { "true" => "single", "false" or "none" => "none", _ => rUnderline }; + var ulVal = NormalizeUnderlineValue(rUnderline); newRProps.Underline = new Underline { Val = new UnderlineValues(ulVal) }; } if ((properties.TryGetValue("strike", out var rStrike) || properties.TryGetValue("strikethrough", out rStrike)) && IsTruthy(rStrike)) @@ -458,6 +497,9 @@ private string AddRun(OpenXmlElement parent, string parentPath, int? index, Dict resultPath = $"{parentPath}/r[{runCount + 1}]"; } + // Refresh textId since paragraph content changed + targetPara.TextId = GenerateParaId(); + return resultPath; } } diff --git a/src/officecli/Handlers/Word/WordHandler.Add.cs b/src/officecli/Handlers/Word/WordHandler.Add.cs index 616237583..a1cdf7ab7 100644 --- a/src/officecli/Handlers/Word/WordHandler.Add.cs +++ b/src/officecli/Handlers/Word/WordHandler.Add.cs @@ -15,8 +15,17 @@ 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) { + // CONSISTENCY(prop-key-case): property keys are case-insensitive + // ("SRC"/"src"/"Src" all resolve the same). Normalize once at the + // dispatch entry so every AddXxx helper can rely on TryGetValue("src"). + properties = properties == null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : properties.Comparer == StringComparer.OrdinalIgnoreCase + ? properties + : new Dictionary(properties, StringComparer.OrdinalIgnoreCase); + var body = _doc.MainDocumentPart?.Document?.Body ?? throw new InvalidOperationException("Document body not found"); @@ -24,11 +33,10 @@ public string Add(string parentPath, string type, int? index, Dictionary(); stylesPart.Styles ??= new Styles(); @@ -41,6 +49,18 @@ public string Add(string parentPath, string type, int? index, Dictionary AddParagraph(parent, parentPath, index, properties), @@ -51,6 +71,7 @@ public string Add(string parentPath, string type, int? index, Dictionary AddCell(parent, parentPath, index, properties), "chart" => AddChart(parent, parentPath, index, properties), "picture" or "image" or "img" => AddPicture(parent, parentPath, index, properties), + "ole" or "oleobject" or "object" or "embed" => AddOle(parent, parentPath, index, properties), "comment" => AddComment(parent, parentPath, index, properties), "bookmark" => AddBookmark(parent, parentPath, index, properties), "hyperlink" or "link" => AddHyperlink(parent, parentPath, index, properties), @@ -61,7 +82,12 @@ public string Add(string parentPath, string type, int? index, Dictionary AddStyle(parent, parentPath, index, properties), "header" => AddHeader(parent, parentPath, index, properties), "footer" => AddFooter(parent, parentPath, index, properties), - "field" or "pagenum" or "pagenumber" or "page" or "numpages" or "date" or "author" => AddField(parent, parentPath, index, properties, type), + "field" or "pagenum" or "pagenumber" or "page" or "numpages" or "sectionpages" or "section" + or "date" or "createdate" or "savedate" or "printdate" or "edittime" or "time" + or "author" or "lastsavedby" or "title" or "subject" or "filename" + or "numwords" or "numchars" or "revnum" or "template" or "comments" or "doccomments" or "keywords" + or "mergefield" or "ref" or "pageref" or "noteref" or "seq" or "styleref" or "docproperty" or "if" + => AddField(parent, parentPath, index, properties, type), "pagebreak" or "columnbreak" or "break" => AddBreak(parent, parentPath, index, properties, type), "sdt" or "contentcontrol" => AddSdt(parent, parentPath, index, properties), "watermark" => AddWatermark(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 17d4f0dbf..19a8e9253 100644 --- a/src/officecli/Handlers/Word/WordHandler.Helpers.cs +++ b/src/officecli/Handlers/Word/WordHandler.Helpers.cs @@ -30,6 +30,38 @@ private static string FormatTwipsToCm(uint twips) private static bool IsTruthy(string? value) => ParseHelpers.IsTruthy(value); + /// + /// Normalize a user-provided underline token to a valid Word OOXML UnderlineValues enum string. + /// Accepts common aliases (wavy → wave, dashdot → dotDash, etc.) plus truthy/none. + /// + internal static string NormalizeUnderlineValue(string value) + { + var v = (value ?? "").Trim(); + return v.ToLowerInvariant() switch + { + "true" or "single" or "1" => "single", + "false" or "none" or "0" or "" => "none", + "double" => "double", + "thick" => "thick", + "dotted" => "dotted", + "dottedheavy" or "dotted-heavy" or "dotted_heavy" => "dottedHeavy", + "dash" or "dashed" => "dash", + "dashedheavy" or "dashheavy" => "dashedHeavy", + "dashlong" or "longdash" => "dashLong", + "dashlongheavy" or "longdashheavy" => "dashLongHeavy", + // Word uses "dotDash" and "dashDotHeavy" (note asymmetric casing in OOXML spec). + "dotdash" or "dashdot" => "dotDash", + "dotdashheavy" or "dashdotheavy" => "dashDotHeavy", + "dotdotdash" or "dashdotdot" => "dotDotDash", + "dotdotdashheavy" or "dashdotdotheavy" => "dashDotDotHeavy", + "wave" or "wavy" => "wave", + "waveheavy" or "wavyheavy" => "wavyHeavy", + "wavedouble" or "wavydouble" or "doublewave" => "wavyDouble", + "words" or "word" => "words", + _ => v // pass-through for already-valid OOXML tokens + }; + } + private static JustificationValues ParseJustification(string value) => value.ToLowerInvariant() switch { @@ -98,9 +130,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(); } /// @@ -137,16 +189,30 @@ private static List FindMathElements(Paragraph para) /// private static IEnumerable GetBodyElements(Body body) { - foreach (var element in body.ChildElements) + foreach (var element in FlattenWrappers(body.ChildElements)) + yield return element; + } + + // Descend into SDT (structured document tag) and customXml transparent + // wrappers so their wrapped paragraphs/tables participate in the body + // element axis. Without this, docs emitted by e.g. Pages/Google Docs + // that wrap entire sections in produce an empty preview. + private static IEnumerable FlattenWrappers(IEnumerable elements) + { + foreach (var element in elements) { if (element is SdtBlock sdt) { var content = sdt.SdtContentBlock; if (content != null) - { - foreach (var child in content.ChildElements) + foreach (var child in FlattenWrappers(content.ChildElements)) yield return child; - } + } + else if (element.LocalName == "customXml" + && element.NamespaceUri == "http://schemas.openxmlformats.org/wordprocessingml/2006/main") + { + foreach (var child in FlattenWrappers(element.ChildElements)) + yield return child; } else { @@ -190,7 +256,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; } @@ -474,41 +540,135 @@ private static void ApplyRunFormatting(OpenXmlCompositeElement props, string key case "size": var existingFs = props.GetFirstChild(); if (existingFs != null) existingFs.Val = ((int)Math.Round(ParseFontSize(value) * 2, MidpointRounding.AwayFromZero)).ToString(); - else props.AppendChild(new FontSize { Val = ((int)Math.Round(ParseFontSize(value) * 2, MidpointRounding.AwayFromZero)).ToString() }); + else InsertRunPropInSchemaOrder(props, new FontSize { Val = ((int)Math.Round(ParseFontSize(value) * 2, MidpointRounding.AwayFromZero)).ToString() }); break; case "font": var existingRf = props.GetFirstChild(); if (existingRf != null) { existingRf.Ascii = value; existingRf.HighAnsi = value; existingRf.EastAsia = value; } - else props.AppendChild(new RunFonts { Ascii = value, HighAnsi = value, EastAsia = value }); + else InsertRunPropInSchemaOrder(props, new RunFonts { Ascii = value, HighAnsi = value, EastAsia = value }); 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) }); + var ulMapped = NormalizeUnderlineValue(value); + 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) + InsertRunPropInSchemaOrder(props, 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]); + InsertRunPropInSchemaOrder(props, shd); + } + break; + case "superscript": + props.RemoveAllChildren(); + if (IsTruthy(value)) + InsertRunPropInSchemaOrder(props, new VerticalTextAlignment { Val = VerticalPositionValues.Superscript }); + break; + case "subscript": + props.RemoveAllChildren(); + if (IsTruthy(value)) + InsertRunPropInSchemaOrder(props, 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 = 25, bdr = 26 + Shading => 27, + // fitText = 28 + VerticalTextAlignment => 29, + // rtl, cs, em, lang, ... + _ => 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) { var bkId = bkStart.Id?.Value; @@ -527,113 +687,623 @@ 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 { - foreach (var hp in mainPart?.HeaderParts ?? Enumerable.Empty()) - if (hp.Header != null) paragraphs.AddRange(hp.Header.Descendants()); + int idx = 0; + while ((idx = fullText.IndexOf(pattern, idx, StringComparison.Ordinal)) >= 0) + { + ranges.Add((idx, pattern.Length)); + idx += pattern.Length; + } } - if (scope is "all" or "footers") + 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()) { - foreach (var fp in mainPart?.FooterParts ?? Enumerable.Empty()) - if (fp.Footer != null) paragraphs.AddRange(fp.Footer.Descendants()); + 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) + { + if (rt.Start >= charStart && rt.End <= charEnd) + result.Add(rt.Run); + } + 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--) + { + 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) { - totalCount += ReplaceInParagraph(para, find, replace); + var count = ProcessFindInParagraph(para, pattern, isRegex, replace, formatProps.Count > 0 ? formatProps : null); + if (count > 0) + para.TextId = GenerateParaId(); + totalCount += count; } return totalCount; } /// - /// 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; - var fullText = string.Concat(runTexts.Select(rt => rt.TextElement.Text)); + 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", "sectionpages", "section", + "date", "createdate", "savedate", "printdate", "edittime", "time", + "author", "lastsavedby", "title", "subject", "filename", + "numwords", "numchars", "revnum", "template", "comments", "doccomments", "keywords", + "mergefield", "ref", "pageref", "noteref", "seq", "styleref", "docproperty", "if", + "pagebreak", "columnbreak", "break", "footnote", "endnote", + "equation", "formula", "math", "bookmark", "formfield" + }; - // Find all occurrences - var indices = new List(); - int idx = 0; - while ((idx = fullText.IndexOf(find, idx, StringComparison.Ordinal)) >= 0) + /// + /// 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) + { + // 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); + + // Resolve to a paragraph — either the parent itself, or the first + // descendant paragraph of a container (body/cell/sdt) whose text + // matches the pattern. + Paragraph para; + string paraPath; + if (parent is Paragraph p) { - indices.Add(idx); - idx += find.Length; + para = p; + paraPath = parentPath; } + else + { + var hit = FindParagraphContainingText(parent, parentPath, pattern, isRegex) + ?? throw new ArgumentException( + $"Text '{findValue}' not found in any paragraph under {parentPath}."); + para = hit.Para; + paraPath = hit.Path; + } + + 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; - if (indices.Count == 0) return 0; + bool isInline = InlineTypes.Contains(type); + + if (isInline) + { + return AddInlineAtSplitPoint(para, paraPath, splitPoint, type, position, properties); + } + else + { + return AddBlockAtSplitPoint(para, paraPath, splitPoint, type, position, properties); + } + } - // Process replacements from end to start to preserve positions - for (int i = indices.Count - 1; i >= 0; i--) + /// + /// Walk the child paragraphs of a container and return the first paragraph + /// (plus its constructed path) whose text matches the given pattern. + /// Used to let body-level find: anchors resolve without requiring the + /// caller to spell out a specific paragraph path. + /// + private (Paragraph Para, string Path)? FindParagraphContainingText( + OpenXmlElement container, string containerPath, string pattern, bool isRegex) + { + var paragraphs = container.Elements().ToList(); + for (int i = 0; i < paragraphs.Count; i++) { - var matchStart = indices[i]; - var matchEnd = matchStart + find.Length; + var candidate = paragraphs[i]; + var runTexts = BuildRunTexts(candidate); + if (runTexts.Count == 0) continue; - // Find which run-texts are affected - bool first = true; - foreach (var rt in runTexts) + var fullText = string.Concat(runTexts.Select(rt => rt.TextElement.Text)); + if (FindMatchRanges(fullText, pattern, isRegex).Count > 0) { - if (rt.End <= matchStart || rt.Start >= matchEnd) - continue; // not affected + var paraPath = $"{containerPath}/{BuildParaPathSegment(candidate, i + 1)}"; + return (candidate, paraPath); + } + } + return null; + } - var textStr = rt.TextElement.Text ?? ""; - var localStart = Math.Max(0, matchStart - rt.Start); - var localEnd = Math.Min(textStr.Length, matchEnd - rt.Start); + /// + /// 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; - if (first) + foreach (var rt in runTexts) + { + if (splitPoint >= rt.Start && splitPoint <= rt.End) + { + if (splitPoint == rt.Start) { - // 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 before this run — find previous run + insertAfterRun = rt.Run.PreviousSibling(); + } + else if (splitPoint == rt.End) + { + // 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; } } - return indices.Count; + // 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; + } + } + + // Move it between para and rightPara + if (addedElement != null) + { + addedElement.Remove(); + parentOfPara.InsertAfter(addedElement, para); + } + + _doc.MainDocumentPart?.Document?.Save(); + return result; } /// @@ -1030,6 +1700,12 @@ private class WordChartInfo { public ChartPart? StandardPart { get; set; } public ExtendedChartPart? ExtendedPart { get; set; } + public DW.DocProperties? DocProperties { get; set; } + /// + /// The wp:inline element that hosts this chart — needed by + /// chart position Set to mutate the wp:extent child. + /// + public DW.Inline? Inline { get; set; } public bool IsExtended => ExtendedPart != null; } @@ -1047,6 +1723,8 @@ private List GetAllWordCharts() var graphicData = inline.Descendants().FirstOrDefault(); if (graphicData == null) continue; + var docProps = inline.Descendants().FirstOrDefault(); + if (graphicData.Uri == WordChartUri) { // Standard chart @@ -1055,7 +1733,7 @@ private List GetAllWordCharts() try { var chartPart = (ChartPart)mainPart.GetPartById(chartRef.Id.Value); - result.Add(new WordChartInfo { StandardPart = chartPart }); + result.Add(new WordChartInfo { StandardPart = chartPart, DocProperties = docProps, Inline = inline }); } catch { /* skip invalid references */ } } @@ -1067,7 +1745,7 @@ private List GetAllWordCharts() try { var extPart = (ExtendedChartPart)mainPart.GetPartById(relId); - result.Add(new WordChartInfo { ExtendedPart = extPart }); + result.Add(new WordChartInfo { ExtendedPart = extPart, DocProperties = docProps, Inline = inline }); } catch { /* skip invalid references */ } } @@ -1076,6 +1754,47 @@ private List GetAllWordCharts() return result; } + /// + /// Apply width / height to a Word inline chart's + /// wp:extent. Accepts unit-qualified sizes (`6cm`, `2in`, + /// `720pt`) or raw EMU integers via EmuConverter. + /// + /// CONSISTENCY(chart-position-set): mirrors the PPTX and Excel path. + /// Word inline charts have no absolute x/y (they flow with text), so + /// those keys — if provided — are appended to + /// rather than silently dropped. + /// + private static void ApplyWordChartPositionSet( + DW.Inline inline, Dictionary properties, List unsupported) + { + var extent = inline.Extent; + if (extent == null) return; + + // x/y are meaningless for inline charts. + foreach (var k in new[] { "x", "y" }) + { + var matched = properties.Keys + .FirstOrDefault(key => key.Equals(k, StringComparison.OrdinalIgnoreCase)); + if (matched == null) continue; + unsupported.Add(matched); + Console.Error.WriteLine( + $"Warning: '{matched}' is ignored on Word inline charts — inline elements have no absolute position. " + + "For positioned charts, switch to anchor mode (not currently supported)."); + } + + if (properties.TryGetValue("width", out var wStr)) + { + try { extent.Cx = OfficeCli.Core.EmuConverter.ParseEmu(wStr); } + catch { unsupported.Add("width"); } + } + + if (properties.TryGetValue("height", out var hStr)) + { + try { extent.Cy = OfficeCli.Core.EmuConverter.ParseEmu(hStr); } + catch { unsupported.Add("height"); } + } + } + /// /// Get the relationship ID from an extended chart inline Drawing element. /// @@ -1143,4 +1862,180 @@ private bool IsSdtEditable(SdtProperties? sdtProps) // comments/trackedChanges → not typically editable return false; } + + /// + /// 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 string GenerateParaId() + { + 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 void AssignParaId(Paragraph para) + { + if (string.IsNullOrEmpty(para.ParagraphId?.Value)) + para.ParagraphId = GenerateParaId(); + if (string.IsNullOrEmpty(para.TextId?.Value)) + para.TextId = GenerateParaId(); + } + + /// + /// Ensure all paragraphs in the document have w14:paraId and w14:textId. + /// Called on document open. + /// + private void EnsureAllParaIds() + { + var mainPart = _doc.MainDocumentPart; + if (mainPart?.Document?.Body == null) return; + + _usedParaIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Collect all paragraphs from body + headers + footers + var allParagraphs = mainPart.Document.Body.Descendants().AsEnumerable(); + foreach (var headerPart in mainPart.HeaderParts) + if (headerPart.Header != null) + allParagraphs = allParagraphs.Concat(headerPart.Header.Descendants()); + foreach (var footerPart in mainPart.FooterParts) + if (footerPart.Footer != null) + allParagraphs = allParagraphs.Concat(footerPart.Footer.Descendants()); + + var paragraphs = allParagraphs.ToList(); + + // 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)) + { + 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)) + { + _usedParaIds.Add(para.TextId.Value); + if (int.TryParse(para.TextId.Value, System.Globalization.NumberStyles.HexNumber, null, out var numId) && numId > maxId) + maxId = numId; + } + } + + // 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)) + 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()) + { + 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) ==================== + + /// + /// Ensure all DocProperties in the document have unique IDs. + /// Called on document open. + /// + private void EnsureDocPropIds() + { + var mainPart = _doc.MainDocumentPart; + if (mainPart?.Document?.Body == null) return; + + var allDocProps = mainPart.Document.Body.Descendants().ToList(); + + foreach (var headerPart in mainPart.HeaderParts) + if (headerPart.Header != null) + allDocProps.AddRange(headerPart.Header.Descendants()); + foreach (var footerPart in mainPart.FooterParts) + if (footerPart.Footer != null) + allDocProps.AddRange(footerPart.Footer.Descendants()); + + var usedIds = new HashSet(); + var duplicates = new List(); + + foreach (var dp in allDocProps) + { + if (dp.Id?.HasValue == true && !usedIds.Add(dp.Id.Value)) + duplicates.Add(dp); + else if (dp.Id?.HasValue != true) + duplicates.Add(dp); + } + + foreach (var dp in duplicates) + { + uint newId = 1; + while (!usedIds.Add(newId)) newId++; + dp.Id = newId; + } + } } diff --git a/src/officecli/Handlers/Word/WordHandler.HtmlPreview.Charts.cs b/src/officecli/Handlers/Word/WordHandler.HtmlPreview.Charts.cs index b0802283d..737ab540e 100644 --- a/src/officecli/Handlers/Word/WordHandler.HtmlPreview.Charts.cs +++ b/src/officecli/Handlers/Word/WordHandler.HtmlPreview.Charts.cs @@ -2,14 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 using System.Text; -using System.Text.RegularExpressions; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using OfficeCli.Core; -using A = DocumentFormat.OpenXml.Drawing; using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing; -using M = DocumentFormat.OpenXml.Math; namespace OfficeCli.Handlers; @@ -24,128 +21,85 @@ private void RenderChartHtml(StringBuilder sb, Drawing drawing, OpenXmlElement c try { - var chartPart = _doc.MainDocumentPart?.GetPartById(relId) as DocumentFormat.OpenXml.Packaging.ChartPart; - if (chartPart?.ChartSpace == null) return; + // cx:chart (extended) path — different part type, different extractor. + var anyPart = _doc.MainDocumentPart?.GetPartById(relId); + if (anyPart is ExtendedChartPart extPart) + { + RenderChartExHtml(sb, drawing, extPart); + return; + } - var extent = drawing.Descendants().FirstOrDefault(); - int svgW = extent?.Cx?.Value > 0 ? (int)(extent.Cx.Value / 9525) : 500; - int svgH = extent?.Cy?.Value > 0 ? (int)(extent.Cy.Value / 9525) : 300; + var chartPart = anyPart as ChartPart; + if (chartPart?.ChartSpace == null) return; - // Use the shared ChartSvgRenderer - var chartSpace = chartPart.ChartSpace; - var chart = chartSpace.GetFirstChild(); + var chart = chartPart.ChartSpace.GetFirstChild(); if (chart == null) return; - var plotArea = chart.PlotArea; if (plotArea == null) return; - // Extract chart data using ChartHelper - var chartType = Core.ChartHelper.DetectChartType(plotArea) ?? "column"; - var categories = Core.ChartHelper.ReadCategories(plotArea) ?? []; - var seriesList = Core.ChartHelper.ReadAllSeries(plotArea); - if (seriesList.Count == 0) return; - - // Get title - var title = chart.Title; - string? titleText = null; - if (title != null) - { - var titleRuns = title.Descendants() - .SelectMany(ct => ct.Descendants()) - .Select(r => r.GetFirstChild()?.Text) - .Where(t => t != null); - titleText = string.Join("", titleRuns); - } + // Extract all chart metadata via shared helper + var info = ChartSvgRenderer.ExtractChartInfo(plotArea, chart); + if (info.Series.Count == 0) return; - // Read series colors: collect all ser elements from the specific chart type element - // (barChart/lineChart/pieChart etc.) to match order with ChartHelper.ReadAllSeries - var chartTypeEl = plotArea.Elements().FirstOrDefault(e => - e.LocalName is "barChart" or "bar3DChart" or "lineChart" or "line3DChart" - or "pieChart" or "pie3DChart" or "doughnutChart" or "areaChart" or "area3DChart" - or "scatterChart" or "radarChart" or "bubbleChart" or "ofPieChart"); - var serElements = chartTypeEl?.Elements().Where(e => e.LocalName == "ser").ToList() ?? []; - var colors = new List(); - for (int si = 0; si < seriesList.Count; si++) - { - string? seriesColor = null; - if (si < serElements.Count) - { - // Look for solidFill in the series' spPr - var spPr = serElements[si].Elements().FirstOrDefault(e => e.LocalName == "spPr"); - var solidFill = spPr?.Elements().FirstOrDefault(e => e.LocalName == "solidFill"); - if (solidFill != null) - { - var srgb = solidFill.Elements().FirstOrDefault(e => e.LocalName == "srgbClr"); - seriesColor = srgb?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value; - if (seriesColor != null) seriesColor = $"#{seriesColor}"; - } - } - colors.Add(seriesColor ?? Core.ChartSvgRenderer.DefaultColors[si % Core.ChartSvgRenderer.DefaultColors.Length]); - } + // Chart dimensions from drawing extent + var extent = drawing.Descendants().FirstOrDefault(); + int svgW = extent?.Cx?.Value > 0 ? (int)(extent.Cx.Value / 9525) : 500; + int svgH = extent?.Cy?.Value > 0 ? (int)(extent.Cy.Value / 9525) : 300; - // Render SVG chart (use dark label colors for white background) - var renderer = new Core.ChartSvgRenderer + // Renderer — use chart XML colors if available, else reasonable defaults + var renderer = new ChartSvgRenderer { - CatColor = "#333333", - AxisColor = "#555555", - ValueColor = "#444444" + ThemeAccentColors = ChartSvgRenderer.BuildThemeAccentColors(GetThemeColors()), + CatColor = (info.CatFontColor != null && IsHexColor(info.CatFontColor)) ? $"#{info.CatFontColor}" : "#333333", + AxisColor = (info.ValFontColor != null && IsHexColor(info.ValFontColor)) ? $"#{info.ValFontColor}" : "#555555", + ValueColor = (info.ValFontColor != null && IsHexColor(info.ValFontColor)) ? $"#{info.ValFontColor}" : "#444444", + GridColor = (info.GridlineColor != null && IsHexColor(info.GridlineColor)) ? $"#{info.GridlineColor}" : "#ddd", + AxisLineColor = (info.AxisLineColor != null && IsHexColor(info.AxisLineColor)) ? $"#{info.AxisLineColor}" : "#999", + ValFontPx = info.ValFontPx, + CatFontPx = info.CatFontPx }; - sb.Append($"
    "); - if (!string.IsNullOrEmpty(titleText)) - sb.Append($"
    {HtmlEncode(titleText)}
    "); + var titleH = string.IsNullOrEmpty(info.Title) ? 0 : 24; + // #7f: only reserve vertical room for the legend when it sits + // above or below the plot area. Right/left legends share the + // full SVG height. + var legendAbove = info.LegendPos == "t"; + var legendSide = info.LegendPos is "r" or "l" or "tr"; + // Any remaining value (including "ctr" overlay and unknown) or + // empty string → below, so HasLegend=true + ctr doesn't vanish. + var legendBelow = !legendAbove && !legendSide; + var legendH = info.HasLegend && (legendAbove || legendBelow) ? 24 : 0; + var chartSvgH = svgH - titleH - legendH; - sb.Append($""); + sb.Append($"
    "); + if (!string.IsNullOrEmpty(info.Title)) + sb.Append($"
    {HtmlEncode(info.Title)}
    "); - int margin = 40; - int plotW = svgW - margin * 2; - int plotH = svgH - margin * 2; - var seriesColors = colors; + // Top legend prints above the SVG, side legends share a flex row. + if (info.HasLegend && legendAbove) + renderer.RenderLegendHtml(sb, info, "#333"); - switch (chartType) + var bgStyle = info.ChartFillColor != null ? $"background:#{info.ChartFillColor};" : "background:white;"; + if (info.HasLegend && legendSide) { - case "bar": - renderer.RenderBarChartSvg(sb, seriesList, categories, seriesColors, margin, margin, plotW, plotH, true, true, false); - break; - case "column": - renderer.RenderBarChartSvg(sb, seriesList, categories, seriesColors, margin, margin, plotW, plotH, false, true, false); - break; - case "line": - renderer.RenderLineChartSvg(sb, seriesList, categories, seriesColors, margin, margin, plotW, plotH, false); - break; - case "pie": - case "doughnut": - renderer.RenderPieChartSvg(sb, seriesList, categories, seriesColors, svgW, svgH, chartType == "doughnut" ? 50 : 0, false); - break; - case "area": - renderer.RenderAreaChartSvg(sb, seriesList, categories, seriesColors, margin, margin, plotW, plotH, false); - break; - case "scatter": - // Scatter rendered as line chart with markers (closest available approximation) - renderer.RenderLineChartSvg(sb, seriesList, categories, seriesColors, margin, margin, plotW, plotH, true); - break; - case "radar": - renderer.RenderRadarChartSvg(sb, seriesList, categories, seriesColors, svgW, svgH, 30); - break; - default: - // Fallback: render as column chart - renderer.RenderBarChartSvg(sb, seriesList, categories, seriesColors, margin, margin, plotW, plotH, false, true, false); - break; + var flexDir = info.LegendPos == "l" ? "row-reverse" : "row"; + sb.Append($"
    "); } + sb.Append($""); + + renderer.RenderChartSvgContent(sb, info, svgW, chartSvgH); sb.Append(""); - // Render legend if multiple series - if (seriesList.Count > 1) + if (info.HasLegend && legendSide) { - sb.Append("
    "); - for (int li = 0; li < seriesList.Count; li++) - { - var lColor = li < seriesColors.Count ? seriesColors[li] : "#999"; - sb.Append($"{HtmlEncode(seriesList[li].name)}"); - } + renderer.RenderLegendHtml(sb, info, "#333"); sb.Append("
    "); } + else if (info.HasLegend && legendBelow) + { + renderer.RenderLegendHtml(sb, info, "#333"); + } sb.Append("
    "); } @@ -154,4 +108,57 @@ e.LocalName is "barChart" or "bar3DChart" or "lineChart" or "line3DChart" sb.Append($"
    [Chart: {HtmlEncode(ex.Message)}]
    "); } } + + /// + /// Render a cx:chart (Office 2016 extended chart — histogram, funnel, + /// treemap, sunburst, boxWhisker) inside a Word document. Mirrors the + /// regular-chart path in , but uses + /// and skips the + /// a:plotArea extraction (cx has its own PlotArea shape). + /// + private void RenderChartExHtml(StringBuilder sb, Drawing drawing, ExtendedChartPart extPart) + { + try + { + var chart = extPart.ChartSpace? + .GetFirstChild(); + if (chart == null) return; + + var info = ChartSvgRenderer.ExtractCxChartInfo(chart); + if (info.Series.Count == 0) return; + + // Chart dimensions from the drawing extent, same as regular charts. + var extent = drawing.Descendants().FirstOrDefault(); + int svgW = extent?.Cx?.Value > 0 ? (int)(extent.Cx.Value / 9525) : 500; + int svgH = extent?.Cy?.Value > 0 ? (int)(extent.Cy.Value / 9525) : 300; + + var renderer = new ChartSvgRenderer + { + ThemeAccentColors = ChartSvgRenderer.BuildThemeAccentColors(GetThemeColors()), + CatColor = (info.CatFontColor != null && IsHexColor(info.CatFontColor)) ? $"#{info.CatFontColor}" : "#333333", + AxisColor = (info.ValFontColor != null && IsHexColor(info.ValFontColor)) ? $"#{info.ValFontColor}" : "#555555", + ValueColor = (info.ValFontColor != null && IsHexColor(info.ValFontColor)) ? $"#{info.ValFontColor}" : "#444444", + GridColor = (info.GridlineColor != null && IsHexColor(info.GridlineColor)) ? $"#{info.GridlineColor}" : "#ddd", + AxisLineColor = (info.AxisLineColor != null && IsHexColor(info.AxisLineColor)) ? $"#{info.AxisLineColor}" : "#999", + ValFontPx = info.ValFontPx, + CatFontPx = info.CatFontPx, + }; + + var titleH = string.IsNullOrEmpty(info.Title) ? 0 : 24; + var chartSvgH = svgH - titleH; + if (chartSvgH < 80) return; + + sb.Append("
    "); + if (!string.IsNullOrEmpty(info.Title)) + sb.Append($"
    {HtmlEncode(info.Title)}
    "); + sb.Append($""); + renderer.RenderChartSvgContent(sb, info, svgW, chartSvgH); + sb.Append(""); + sb.Append("
    "); + } + catch (Exception ex) + { + sb.Append($"
    [cxChart: {HtmlEncode(ex.Message)}]
    "); + } + } } diff --git a/src/officecli/Handlers/Word/WordHandler.HtmlPreview.Css.cs b/src/officecli/Handlers/Word/WordHandler.HtmlPreview.Css.cs index c2f371aa5..414464276 100644 --- a/src/officecli/Handlers/Word/WordHandler.HtmlPreview.Css.cs +++ b/src/officecli/Handlers/Word/WordHandler.HtmlPreview.Css.cs @@ -17,12 +17,46 @@ public partial class WordHandler { private Dictionary? _themeColors; + // Microsoft Office default "Office" theme palette. When a document has + // no part (blank docs created via BlankDocCreator), Word + // applies this palette; our HTML preview now does the same so + // w:themeColor="accent1" resolves instead of silently dropping. + private static readonly Dictionary OfficeDefaultThemeColors = new(StringComparer.OrdinalIgnoreCase) + { + ["accent1"] = "4472C4", + ["accent2"] = "ED7D31", + ["accent3"] = "A5A5A5", + ["accent4"] = "FFC000", + ["accent5"] = "5B9BD5", + ["accent6"] = "70AD47", + ["dark1"] = "000000", ["tx1"] = "000000", ["dk1"] = "000000", ["text1"] = "000000", + ["dark2"] = "44546A", ["tx2"] = "44546A", ["dk2"] = "44546A", ["text2"] = "44546A", + ["light1"] = "FFFFFF", ["bg1"] = "FFFFFF", ["lt1"] = "FFFFFF", ["background1"] = "FFFFFF", + ["light2"] = "E7E6E6", ["bg2"] = "E7E6E6", ["lt2"] = "E7E6E6", ["background2"] = "E7E6E6", + ["hyperlink"] = "0563C1", + ["followedHyperlink"] = "954F72", + }; + private Dictionary GetThemeColors() { if (_themeColors != null) return _themeColors; - var colorScheme = _doc.MainDocumentPart?.ThemePart?.Theme?.ThemeElements?.ColorScheme; + // A malformed theme1.xml (any XML error) throws XmlException on + // lazy access deep inside the first reader. Fall back to the Office + // default palette rather than tainting the whole preview. Same + // approach used for styles/footnotes below. + DocumentFormat.OpenXml.Drawing.ColorScheme? colorScheme = null; + try { colorScheme = _doc.MainDocumentPart?.ThemePart?.Theme?.ThemeElements?.ColorScheme; } + catch (System.Xml.XmlException) { } _themeColors = ThemeColorResolver.BuildColorMap(colorScheme, includePptAliases: false); + + // Fill in any missing standard names from the Office default theme so + // themeColor references resolve even when the docx has no theme part. + foreach (var (name, hex) in OfficeDefaultThemeColors) + { + if (!_themeColors.ContainsKey(name)) + _themeColors[name] = hex; + } return _themeColors; } @@ -68,7 +102,7 @@ private string ResolveShapeFillCss(OpenXmlElement? spPr) if (rgb != null) { var val = rgb.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value; - if (val != null) return $"background-color:#{val}"; + if (val != null && IsHexColor(val)) return $"background-color:#{val}"; } var scheme = solidFill.Elements().FirstOrDefault(e => e.LocalName == "schemeClr"); if (scheme != null) @@ -78,6 +112,44 @@ private string ResolveShapeFillCss(OpenXmlElement? spPr) } } + // Gradient fill → CSS linear-gradient. OOXML stores stops as + // with each (in 1/1000 of a percent). Direction comes + // from (in 60000ths of a degree). + var gradFill = spPr.Elements().FirstOrDefault(e => e.LocalName == "gradFill"); + if (gradFill != null) + { + var gsLst = gradFill.Elements().FirstOrDefault(e => e.LocalName == "gsLst"); + if (gsLst != null) + { + var stops = new List(); + foreach (var gs in gsLst.Elements().Where(e => e.LocalName == "gs")) + { + var posAttr = gs.GetAttributes().FirstOrDefault(a => a.LocalName == "pos").Value; + double pct = int.TryParse(posAttr, out var posVal) ? posVal / 1000.0 : 0; + string? color = null; + var gsRgb = gs.Elements().FirstOrDefault(e => e.LocalName == "srgbClr"); + if (gsRgb != null) + color = "#" + gsRgb.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value; + var gsScheme = gs.Elements().FirstOrDefault(e => e.LocalName == "schemeClr"); + if (gsScheme != null) color = ResolveSchemeColor(gsScheme); + if (color != null) + stops.Add($"{color} {pct:0.##}%"); + } + if (stops.Count > 0) + { + // ang: 60000ths of a degree; CSS linear-gradient uses "to " or "" + // OOXML 0 = left→right; CSS 0deg = bottom→top. Convert OOXML → CSS: + // CSS angle = (OOXML angle / 60000 + 90) % 360 + var lin = gradFill.Elements().FirstOrDefault(e => e.LocalName == "lin"); + double cssAngleDeg = 90; + var angAttr = lin?.GetAttributes().FirstOrDefault(a => a.LocalName == "ang").Value; + if (long.TryParse(angAttr, out var angVal)) + cssAngleDeg = (angVal / 60000.0 + 90) % 360; + return $"background:linear-gradient({cssAngleDeg:0.##}deg,{string.Join(",", stops)})"; + } + } + } + return ""; } @@ -93,7 +165,10 @@ private string ResolveShapeBorderCss(OpenXmlElement? spPr) string? color = null; var rgb = solidFill.Elements().FirstOrDefault(e => e.LocalName == "srgbClr"); - if (rgb != null) color = $"#{rgb.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value}"; + if (rgb != null) { + var rv = rgb.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value; + if (rv != null && IsHexColor(rv)) color = $"#{rv}"; + } var scheme = solidFill.Elements().FirstOrDefault(e => e.LocalName == "schemeClr"); if (scheme != null) color = ResolveSchemeColor(scheme); @@ -182,7 +257,8 @@ private string GetParagraphInlineCss(Paragraph para, bool isListItem = false) if (jc == null) jc = ResolveJustificationFromStyle(styleId); if (jc != null) { - var align = jc.InnerText switch + var jcVal = jc.InnerText; + var align = jcVal switch { "center" => "center", "right" or "end" => "right", @@ -190,8 +266,18 @@ private string GetParagraphInlineCss(Paragraph para, bool isListItem = false) _ => (string?)null }; if (align != null) parts.Add($"text-align:{align}"); + // w:jc="distribute" stretches EVERY line (including single/last) + // to full width with inter-character spacing. Plain CSS justify + // leaves the last line unstretched, so add text-align-last + // and text-justify hints for closer fidelity. + if (jcVal == "distribute") + parts.Add("text-align-last:justify;text-justify:inter-character"); } + // Paragraph-level RTL (w:bidi) — flips the paragraph direction + if (pProps.BiDi != null && (pProps.BiDi.Val == null || pProps.BiDi.Val.Value)) + parts.Add("direction:rtl"); + // Drop cap detection — used to suppress text-indent var framePrForIndent = pProps.GetFirstChild(); var hasDropCap = framePrForIndent != null && @@ -208,16 +294,29 @@ private string GetParagraphInlineCss(Paragraph para, bool isListItem = false) var indFirstLine = directInd?.FirstLine?.Value ?? styleInd?.FirstLine?.Value; var indHanging = directInd?.Hanging?.Value ?? styleInd?.Hanging?.Value; + // Hanging indent needs left padding/margin equal to the hanging + // amount to produce the visual effect (first line at 0, follow + // lines indented). When only `hanging` is set without `left`, + // use hanging as the left margin too. + double? hangPt = null; + if (indHanging is string hpTwips && hpTwips != "0") + hangPt = Units.TwipsToPt(hpTwips); + double leftPt = 0; if (indLeft is string leftTwips && leftTwips != "0") - parts.Add($"margin-left:{Units.TwipsToPt(leftTwips):0.##}pt"); + leftPt = Units.TwipsToPt(leftTwips); + // When hanging is set and left is 0, promote hanging into left + // margin so subsequent lines visibly indent. + if (hangPt.HasValue && leftPt == 0) leftPt = hangPt.Value; + if (leftPt != 0) + parts.Add($"margin-left:{leftPt:0.##}pt"); if (indRight is string rightTwips && rightTwips != "0") parts.Add($"margin-right:{Units.TwipsToPt(rightTwips):0.##}pt"); if (!hasDropCap) { if (indFirstLine is string firstLineTwips && firstLineTwips != "0") parts.Add($"text-indent:{Units.TwipsToPt(firstLineTwips):0.##}pt"); - if (indHanging is string hangTwips && hangTwips != "0") - parts.Add($"text-indent:-{Units.TwipsToPt(hangTwips):0.##}pt"); + if (hangPt.HasValue) + parts.Add($"text-indent:-{hangPt.Value:0.##}pt"); } } @@ -235,13 +334,51 @@ private string GetParagraphInlineCss(Paragraph para, bool isListItem = false) if (spacing != null) { + // contextualSpacing: when enabled and adjacent paragraph has the same style, + // spaceBefore/spaceAfter between them is suppressed (set to zero). + var hasContextualSpacing = pProps.ContextualSpacing != null + || ResolveContextualSpacingFromStyle(styleId); + var prevPara = para.PreviousSibling(); + var nextPara = para.NextSibling(); + var prevStyleId = prevPara?.ParagraphProperties?.ParagraphStyleId?.Val?.Value; + var nextStyleId = nextPara?.ParagraphProperties?.ParagraphStyleId?.Val?.Value; + bool suppressBefore = hasContextualSpacing && prevPara != null + && (prevStyleId ?? "") == (styleId ?? ""); + bool suppressAfter = hasContextualSpacing && nextPara != null + && (nextStyleId ?? "") == (styleId ?? ""); + // Before: try direct, then style fallback (before in twips, beforeLines in hundredths of a line) var beforeVal = pProps.SpacingBetweenLines?.Before?.Value ?? styleSpacing?.Before?.Value; var beforeLinesVal = pProps.SpacingBetweenLines?.BeforeLines?.Value ?? styleSpacing?.BeforeLines?.Value; - if (beforeVal is string beforeTwips) - parts.Add($"{vSpacingPropBefore}:{Units.TwipsToPt(beforeTwips):0.##}pt"); + + // Word collapses adjacent spaceBefore/spaceAfter: max(prev.after, cur.before) + // instead of adding them. CSS flexbox doesn't collapse margins, so we subtract + // the overlap from spaceBefore when the previous sibling has spaceAfter. + double prevSpaceAfterPt = 0; + if (prevPara != null && !suppressBefore) + { + var prevPProps = prevPara.ParagraphProperties; + var prevSId = prevPProps?.ParagraphStyleId?.Val?.Value; + var prevStyleSpacing = ResolveSpacingFromStyle(prevSId); + var prevAfter = prevPProps?.SpacingBetweenLines?.After?.Value + ?? prevStyleSpacing?.After?.Value; + if (prevAfter is string pa && int.TryParse(pa, out var paTwips)) + prevSpaceAfterPt = paTwips / 20.0; + } + + if (suppressBefore) + parts.Add($"{vSpacingPropBefore}:0"); + else if (beforeVal is string beforeTwips) + { + double beforePt = Units.TwipsToPt(beforeTwips); + // Collapse: effective spaceBefore = max(0, spaceBefore - prevSpaceAfter) + if (prevSpaceAfterPt > 0) + beforePt = Math.Max(0, beforePt - prevSpaceAfterPt); + if (beforePt > 0) + parts.Add($"{vSpacingPropBefore}:{beforePt:0.##}pt"); + } else if (beforeLinesVal is int beforeLines) parts.Add($"{vSpacingPropBefore}:{beforeLines / 100.0:0.##}em"); @@ -250,7 +387,9 @@ private string GetParagraphInlineCss(Paragraph para, bool isListItem = false) ?? styleSpacing?.After?.Value; var afterLinesVal = pProps.SpacingBetweenLines?.AfterLines?.Value ?? styleSpacing?.AfterLines?.Value; - if (afterVal is string afterTwips) + if (suppressAfter) + parts.Add($"{vSpacingPropAfter}:0"); + else if (afterVal is string afterTwips) parts.Add($"{vSpacingPropAfter}:{Units.TwipsToPt(afterTwips):0.##}pt"); else if (afterLinesVal is int afterLines) parts.Add($"{vSpacingPropAfter}:{afterLines / 100.0:0.##}em"); @@ -266,7 +405,7 @@ private string GetParagraphInlineCss(Paragraph para, bool isListItem = false) { if (int.TryParse(lv, out var lvNum)) { - // Correct for font metrics: Word uses (winAscent+winDescent)/UPM as base + // Correct for font metrics ratio var paraFont = ResolveParaFontForLineHeight(para); var ratio = FontMetricsReader.GetRatio(paraFont); parts.Add($"line-height:{lvNum / 240.0 * ratio:0.##}"); @@ -274,7 +413,26 @@ private string GetParagraphInlineCss(Paragraph para, bool isListItem = false) } else if (rule == "exact" || rule == "atLeast") { - parts.Add($"line-height:{Units.TwipsToPt(lv):0.##}pt"); + var linePt = Units.TwipsToPt(lv); + parts.Add($"line-height:{linePt:0.##}pt"); + // #7b0001: when lineRule=exact pins the line box below + // ~120% of the paragraph's font size, Word clips + // over-tall glyphs. Emit overflow:hidden so tall glyphs + // don't leak into neighboring lines. + if (rule == "exact") + { + var sizeStr = ResolveStyleFontSize( + para.ParagraphProperties?.ParagraphStyleId?.Val?.Value ?? "") + ?? $"{ReadDocDefaults().SizePt}pt"; + // ResolveStyleFontSize returns "Npt"; strip suffix. + if (sizeStr.EndsWith("pt", StringComparison.Ordinal) + && double.TryParse(sizeStr[..^2], + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out var runSizePt) + && runSizePt > 0 && linePt < runSizePt * 1.2) + parts.Add("overflow:hidden"); + } } } @@ -286,6 +444,40 @@ private string GetParagraphInlineCss(Paragraph para, bool isListItem = false) if (ratio > 1.01 || ratio < 0.99) // only if meaningfully different from 1.0 parts.Add($"line-height:{ratio:0.##}"); } + + } + + // docGrid snap: when type="lines" and paragraph doesn't opt out via snapToGrid=false, + // snap line-height to the nearest multiple of linePitch that fits the text. + { + var snapToGrid = pProps.SnapToGrid?.Val?.Value ?? true; + if (snapToGrid) + { + var sectPr = _doc.MainDocumentPart?.Document?.Body?.GetFirstChild(); + var dg = sectPr?.GetFirstChild(); + if ((dg?.Type?.Value == DocGridValues.Lines || dg?.Type?.Value == DocGridValues.LinesAndChars) + && dg.LinePitch?.Value is int lp && lp > 0) + { + double gridPitchPt = lp / 20.0; + var gFont = ResolveParaFontForLineHeight(para); + var gRatio = FontMetricsReader.GetRatio(gFont); + double gSizePt = 0; + var gFirstRun = para.Elements().FirstOrDefault(r => + r.ChildElements.Any(c => c is Text t && !string.IsNullOrEmpty(t.Text))); + if (gFirstRun != null) + { + var grProps = ResolveEffectiveRunProperties(gFirstRun, para); + if (grProps.FontSize?.Val?.Value is string gsz && int.TryParse(gsz, out var ghp)) + gSizePt = ghp / 2.0; + } + if (gSizePt <= 0) gSizePt = 12.0; + + double fontHeightPt = gSizePt * gRatio; + double snappedPt = Math.Ceiling(fontHeightPt / gridPitchPt) * gridPitchPt; + parts.RemoveAll(p => p.StartsWith("line-height")); + parts.Add($"line-height:{snappedPt:0.##}pt"); + } + } } // Shading / background (direct or from style) @@ -340,9 +532,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"); @@ -470,6 +662,28 @@ private string GetParagraphInlineCss(Paragraph para, bool isListItem = false) return null; } + /// Resolve contextualSpacing from the style chain. + private bool ResolveContextualSpacingFromStyle(string? styleId) + { + if (styleId == null) + { + var defaultStyle = _doc.MainDocumentPart?.StyleDefinitionsPart?.Styles + ?.Elements"); - // Load document fonts: local files > local() > Google Fonts + // Load document fonts: @font-face with metric overrides for all fonts, + // Google Fonts only for non-system fonts. var docFonts = CollectDocumentFonts(); if (docFonts.Count > 0) { @@ -88,9 +129,21 @@ public string ViewAsHtml(string? pageFilter = null) sb.Append(fontFaces); sb.AppendLine(""); } - var families = string.Join("&", docFonts.Select(f => - $"family={f.Replace(' ', '+')}:ital,wght@0,400;0,700;1,400;1,700")); - sb.AppendLine($""); + // Filter out system fonts for Google Fonts loading (they're already local) + var googleFonts = docFonts.Where(f => + !f.Equals("Arial", StringComparison.OrdinalIgnoreCase) + && !f.Equals("Times New Roman", StringComparison.OrdinalIgnoreCase) + && !f.Equals("Tahoma", StringComparison.OrdinalIgnoreCase) + && !f.Equals("Courier New", StringComparison.OrdinalIgnoreCase) + && !f.StartsWith("Symbol") && !f.StartsWith("Wingding")).ToList(); + if (googleFonts.Count > 0) + { + var families = string.Join("&", googleFonts + .Select(SanitizeFontName) + .Where(f => !string.IsNullOrEmpty(f)) + .Select(f => $"family={f.Replace(' ', '+')}:ital,wght@0,400;0,700;1,400;1,700")); + sb.AppendLine($""); + } } // KaTeX for math rendering (graceful degradation: shows raw LaTeX when offline) sb.AppendLine(""); @@ -99,20 +152,29 @@ public string ViewAsHtml(string? pageFilter = null) sb.AppendLine(""); // Render body into temporary buffer, then split on page breaks - var maxW = $"max-width:{pgLayout.WidthPt:0.#}pt"; + var maxW = $"width:{pgLayout.WidthPt:0.#}pt"; var bodySb = new StringBuilder(); _ctx.RenderingBody = true; RenderBodyHtml(bodySb, body); _ctx.RenderingBody = false; - // Render header/footer into reusable strings - var headerSb = new StringBuilder(); - RenderHeaderFooterHtml(headerSb, isHeader: true); - var headerHtml = headerSb.ToString(); - - var footerSb = new StringBuilder(); - RenderHeaderFooterHtml(footerSb, isHeader: false); - var footerHtml = footerSb.ToString(); + // #3: per-section header/footer bundles keyed by type. Resolved + // at this stage so the page-emit loop can pick the right variant + // per page (titlePg → first-page header; evenAndOddHeaders → + // parity-based; default otherwise). + var allSectionsForHf = CollectSections(body); + var sectionHeaders = BuildSectionHfBundles(allSectionsForHf, isHeader: true); + var sectionFooters = BuildSectionHfBundles(allSectionsForHf, isHeader: false); + var evenAndOddGlobal = _doc.MainDocumentPart?.DocumentSettingsPart? + .Settings?.GetFirstChild() != null; + // Legacy fallback for docs that didn't come through CollectSections' + // per-section resolution path (e.g. no headers at body level). + var fallbackHeaderSb = new StringBuilder(); + RenderHeaderFooterHtml(fallbackHeaderSb, isHeader: true); + var fallbackHeaderHtml = fallbackHeaderSb.ToString(); + var fallbackFooterSb = new StringBuilder(); + RenderHeaderFooterHtml(fallbackFooterSb, isHeader: false); + var footerHtml = fallbackFooterSb.ToString(); // Render footnotes/endnotes var footnotesSb = new StringBuilder(); @@ -179,15 +241,139 @@ public string ViewAsHtml(string? pageFilter = null) // Footer typically contains: 1 where "1" is the cached PAGE field value // We replace single-digit page numbers in the footer with a placeholder for per-page substitution var footerHasPageNum = footerHtml.Contains("PAGE") || !string.IsNullOrEmpty(footerHtml); - var pageNumPattern = new Regex(@"(]*>)\s*\d+\s*()"); - var footerTemplate = pageNumPattern.Replace(footerHtml, "$1$2", 1); - + // Match a single-digit-only run rendered as either or

    . + // The footer's PAGE field is typically a single run; the tag name + // depends on whether the run carries rPr styling. + // Wrap the matched digit run in a sentinel span so the per-page + // paginate JS can locate PAGE/NUMPAGES fields without clobbering + // unrelated digit-only content (e.g. "2026", "5 USD", chapter ids). + var pageNumPattern = new Regex(@"(<(?:span|p)[^>]*>)\s*\d+\s*()"); + var footerTemplate = pageNumPattern.Replace(footerHtml, + "$1$2", 1); + var footerTemplateWithTotal = pageNumPattern.Replace(footerTemplate, + "$1$2", 1); + footerTemplate = footerTemplateWithTotal; + + // Section-level multi-column layout: w:cols num=N sep=true + var sectCols = _doc.MainDocumentPart?.Document?.Body?.GetFirstChild()?.GetFirstChild(); + var colCount = sectCols?.ColumnCount?.Value ?? 1; + var colSep = sectCols?.Separator?.Value == true; + var colSpacing = sectCols?.Space?.Value; + // CSS columns need a bounded height to balance — min-height alone + // leaves the body unbounded so all content stacks in column 1 and + // overflows the page. Use the doc-level pgLayout body height. + var colBodyHeightPt = pgLayout.HeightPt - pgLayout.MarginTopPt - pgLayout.MarginBottomPt; + var colBodyStyle = colCount > 1 + ? $" style=\"column-count:{colCount}" + + $";height:{colBodyHeightPt.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture)}pt" + + (colSep ? ";column-rule:1px solid #000" : "") + + (int.TryParse(colSpacing, out var csp) && csp > 0 ? $";column-gap:{csp / 20.0:0.##}pt" : "") + + "\"" + : ""; + + // Per-section page layout (#7a00): each page carries one or more + // markers inserted by RenderBodyHtml. The last marker + // seen (inclusive of this page) decides the page's size/margins; + // pages with no marker inherit from the previous page. + var sections = CollectSections(body); + var sectRegex = new Regex(@""); + var activeLayout = pgLayout; + // #10: per-section pgNumType — w:start resets the displayed page + // counter at the section boundary; w:fmt swaps the number format + // (decimalZero, upperRoman, …) applied to PAGE/NUMPAGES substitutions. + int displayedPageNum = 0; + string displayedFmt = "decimal"; + int activeSectionIdx = 0; + int prevActiveSectionIdx = -1; for (int i = 0; i < pageList.Count; i++) { - sb.AppendLine("

    "); - sb.AppendLine($"
    "); - if (i == 0) sb.Append(headerHtml); - sb.Append("
    "); + var pgContent = pageList[i]; + var sectMatches = sectRegex.Matches(pgContent); + if (sectMatches.Count > 0) + { + var lastIdx = int.Parse(sectMatches[^1].Groups[1].Value); + if (lastIdx >= 0 && lastIdx < sections.Count) + { + activeLayout = GetPageLayoutFor(sections[lastIdx]); + activeSectionIdx = lastIdx; + var pgNumType = sections[lastIdx].GetFirstChild(); + if (pgNumType?.Start?.Value is int startVal) + displayedPageNum = startVal - 1; // will ++ below + // Open XML SDK v3+: Enum.ToString() returns a + // debug string like "NumberFormatValues { }"; use + // InnerText to get the XML-level token ("decimalZero"). + if (pgNumType?.Format?.InnerText is { Length: > 0 } fmtStr) + displayedFmt = fmtStr; + } + pgContent = sectRegex.Replace(pgContent, ""); + pageList[i] = pgContent; + } + displayedPageNum++; + var isFirstPageOfSection = activeSectionIdx != prevActiveSectionIdx; + prevActiveSectionIdx = activeSectionIdx; + // Per-page inline style carries full geometry (width / min-height + // / padding) so sections with different page sizes or margins + // override the base .page CSS rules. + var ci = System.Globalization.CultureInfo.InvariantCulture; + var pageStyle = + $"width:{activeLayout.WidthPt.ToString("0.#", ci)}pt;" + + $"min-height:{activeLayout.HeightPt.ToString("0.#", ci)}pt;" + + $"padding:{activeLayout.MarginTopPt.ToString("0.#", ci)}pt " + + $"{activeLayout.MarginRightPt.ToString("0.#", ci)}pt " + + $"{activeLayout.MarginBottomPt.ToString("0.#", ci)}pt " + + $"{activeLayout.MarginLeftPt.ToString("0.#", ci)}pt"; + // #1: lnNumType — read per-section line-number settings and + // expose them as data-* attributes so the JS paginator can + // inject line numbers after layout settles. Only applies when + // countBy > 0; absent element means "no line numbers". + string lineNumAttrs = ""; + if (activeSectionIdx >= 0 && activeSectionIdx < sections.Count) + { + var ln = sections[activeSectionIdx].GetFirstChild(); + // LineNumberType fields are Int16Value — malformed raw docs + // (huge/negative start, non-numeric countBy) throw on .Value + // access. Parse the raw InnerText ourselves and swallow. + short by = 0; + if (ln?.CountBy != null) + short.TryParse(ln.CountBy.InnerText, out by); + if (ln != null && by > 0) + { + short startN = 1; + if (ln.Start != null) short.TryParse(ln.Start.InnerText, out startN); + int distTwips = 0; + if (ln.Distance != null) int.TryParse(ln.Distance.InnerText, out distTwips); + var distPt = distTwips / 20.0; + var restart = ln.Restart?.InnerText ?? "newPage"; + lineNumAttrs = + $" data-line-num-by=\"{by}\"" + + $" data-line-num-start=\"{startN}\"" + + $" data-line-num-dist=\"{distPt.ToString("0.#", ci)}\"" + + $" data-line-num-restart=\"{restart}\""; + } + } + sb.AppendLine($"
    "); + sb.AppendLine($"
    "); + // #3: per-page header/footer selection. titlePg → first-page + // variant; evenAndOddHeaders + even-numbered page → even + // variant; otherwise default. The per-page header lands on + // every page (previously only page 0 got it). + var pageIsEven = (i + 1) % 2 == 0; + var hdrPageNumStr = OfficeCli.Core.WordNumFmtRenderer.Render(displayedPageNum, displayedFmt); + var perPageHeader = PickHeaderFooter( + sectionHeaders, sections, activeSectionIdx, + isFirstPageOfSection, pageIsEven, evenAndOddGlobal, fallbackHeaderHtml); + // Same PAGE/NUMPAGES substitution as the footer path so headers + // with field=page / field=numpages update per page instead of + // rendering the author-time cached literal "1". + var phdr = new Regex(@"(<(?:span|p)[^>]*>)\s*\d+\s*()"); + var perPageHeaderTemplate = phdr.Replace(perPageHeader, + "$1$2", 1); + perPageHeaderTemplate = phdr.Replace(perPageHeaderTemplate, + "$1$2", 1); + sb.Append(perPageHeaderTemplate + .Replace("", hdrPageNumStr) + .Replace("", pageList.Count.ToString())); + sb.Append($"
    "); sb.Append(pageList[i]); // Place footnotes on the page that contains the footnote reference if (!string.IsNullOrEmpty(footnotesHtml) && pageList[i].Contains("fn-ref")) @@ -196,7 +382,20 @@ public string ViewAsHtml(string? pageFilter = null) if (i == pageList.Count - 1 && !string.IsNullOrEmpty(endnotesHtml)) sb.Append(endnotesHtml); sb.Append("
    "); - sb.Append(footerTemplate.Replace("", (i + 1).ToString())); + var pageNumStr = OfficeCli.Core.WordNumFmtRenderer.Render(displayedPageNum, displayedFmt); + // #3: same picker as header — first/even/default footer variant. + var perPageFooter = PickHeaderFooter( + sectionFooters, sections, activeSectionIdx, + isFirstPageOfSection, pageIsEven, evenAndOddGlobal, footerHtml); + // Rebuild the PAGE field placeholder on the picked footer. + var pf = new Regex(@"(<(?:span|p)[^>]*>)\s*\d+\s*()"); + var perPageFooterTemplate = pf.Replace(perPageFooter, + "$1$2", 1); + perPageFooterTemplate = pf.Replace(perPageFooterTemplate, + "$1$2", 1); + sb.Append(perPageFooterTemplate + .Replace("", pageNumStr) + .Replace("", pageList.Count.ToString())); sb.AppendLine("
    "); sb.AppendLine("
    "); } @@ -240,6 +439,12 @@ public string ViewAsHtml(string? pageFilter = null) // Auto-pagination: measure content and split overflowing pages sb.AppendLine($" var maxBodyH={bodyHeightPt:0.#}*96/72;"); // pt to px (96dpi) sb.AppendLine(" var ftpl=" + JsStringLiteral(footerTemplate) + ";"); + // Header template cloned per paginated page. Capture the fallback + // header's PAGE/NUMPAGES placeholders so field updates work on + // every continuation page, not just page 1. + var headerTemplate = pageNumPattern.Replace(fallbackHeaderHtml, "$1$2", 1); + headerTemplate = pageNumPattern.Replace(headerTemplate, "$1$2", 1); + sb.AppendLine(" var htpl=" + JsStringLiteral(headerTemplate) + ";"); sb.AppendLine(@" function paginate(){ var pages=document.querySelectorAll('.page'); @@ -267,7 +472,59 @@ function paginate(){ var bot=children[ci].offsetTop+children[ci].offsetHeight-body.offsetTop; if(bot>availH){splitIdx=ci;break;} } - if(splitIdx<=0)continue; + if(splitIdx<0)continue; + // #7b00: when the overflowing child is a
    , split it at the + // row boundary and clone any rows carrying data-tbl-header=""1"" + // onto the continuation so long tables have repeating headers + // across pages the way Word renders them. + var firstOverflow=children[splitIdx]; + if(firstOverflow&&firstOverflow.tagName==='TABLE'){ + var table=firstOverflow; + var tableTop=table.offsetTop-body.offsetTop; + // Only top-level rows — querySelectorAll('tr') would also pick up + // nested subtable rows and mangle nested structures on page splits. + var trs=Array.from(table.querySelectorAll('tr')).filter(function(tr){ + return tr.closest('table')===table; + }); + var hdrRows=trs.filter(function(tr){return tr.getAttribute('data-tbl-header')==='1';}); + // Find first row whose bottom exceeds availH (relative to body). + var rowSplit=-1; + for(var ri=0;riavailH){rowSplit=ri;break;} + } + if(rowSplit>0){ + // Build continuation table; clone attributes + header rows. + var cont=table.cloneNode(false); + var tbodies=table.querySelectorAll('tbody'); + var contBody=tbodies.length?document.createElement('tbody'):cont; + if(tbodies.length)cont.appendChild(contBody); + hdrRows.forEach(function(h){contBody.appendChild(h.cloneNode(true));}); + for(var rj=rowSplit;rj',(pi+2).toString()); + if(nh.firstChild)np.appendChild(nh.firstChild); + } np.appendChild(nb); // Clone footer into new page var nf=document.createElement('div'); @@ -299,17 +558,14 @@ function paginate(){ allPages.forEach(function(p,i){ var nums=p.querySelectorAll('.page-num'); nums.forEach(function(n){n.textContent=(i+1);}); - var footer=p.querySelector('.doc-footer'); - if(footer){ - var spans=footer.querySelectorAll('span'); - spans.forEach(function(s){ - if(s.textContent.trim().match(/^\d+$/)){ - s.textContent=(i+1); - } - }); - } + // Only touch explicit PAGE/NUMPAGES sentinel spans — scanning every + // digit-only leaf silently rewrote years, prices, chapter ids etc. + p.querySelectorAll('.page-num-field').forEach(function(s){s.textContent=(i+1);}); + p.querySelectorAll('.num-pages-field').forEach(function(s){s.textContent=allPages.length;}); }); - // Recurse in case new pages also overflow + // Recurse in case new pages also overflow. A page is only eligible for + // another split when it has more than one visible child — otherwise the + // single element is irreducible and we would recurse forever. var again=false; document.querySelectorAll('.page').forEach(function(p){ var b=p.querySelector('.page-body'); @@ -317,15 +573,128 @@ function paginate(){ var f=b.querySelector('.footnotes'); var fh=f?f.offsetHeight:0; var ch=0; + var visibleCount=0; Array.from(b.children).forEach(function(c){ if(c.classList.contains('footnotes'))return; var bt=c.offsetTop+c.offsetHeight-b.offsetTop; if(bt>ch)ch=bt; + if(c.offsetHeight>0)visibleCount++; }); - if(ch>maxBodyH-fh+2)again=true; + if(ch>maxBodyH-fh+2 && visibleCount>1)again=true; }); if(again)setTimeout(paginate,0); - else{setTimeout(positionFootnotes,0);setTimeout(applyPageFilter,0);setTimeout(scalePages,0);} + else{setTimeout(positionFootnotes,0);setTimeout(wrapFloats,0);setTimeout(applyLineNumbers,0);setTimeout(applyPageFilter,0);setTimeout(function(){scalePages(false);},0);} + } + // #2 / #7b light approximation: a floating table whose CSS has float:* + // sits directly under .page-body (flex column) and has its float ignored. + // Wrap it + following prose siblings in a non-flex BFC div until either + // a heading, another table, or the wrap is tall enough for prose to + // have cleared the table. Re-run is idempotent. + function wrapFloats(){ + // Collect direct page-body children whose outer CSS or whose first + // child has float:*. Both cases need a BFC wrapper so the float + // can push following prose sideways. + var candidates=[]; + document.querySelectorAll('.page-body > *').forEach(function(el){ + if(el.parentElement && el.parentElement.classList.contains('float-wrap'))return; + var ownFloat=(el.style&&el.style.cssFloat)||''; + if(!ownFloat && el.getAttribute){ + var st=el.getAttribute('style')||''; + if(/float\s*:\s*(left|right)/.test(st))ownFloat='y'; + } + var innerImg=el.querySelector&&el.querySelector('img[style*=""float:""]'); + if(ownFloat||innerImg)candidates.push({el:el,anchor:innerImg||el}); + }); + candidates.forEach(function(c){ + var wrap=document.createElement('div'); + wrap.className='float-wrap'; + wrap.style.cssText='display:block;overflow:auto'; + c.el.parentNode.insertBefore(wrap,c.el); + wrap.appendChild(c.el); + var anchorH=c.anchor.offsetHeight||c.el.offsetHeight; + // Absorb following siblings until a hard break or clearance. + for(var guard=0;guard<50;guard++){ + var nxt=wrap.nextSibling; + if(!nxt)break; + if(nxt.nodeType===1){ + var tag=nxt.tagName; + if(tag==='TABLE'||(tag&&tag.length===2&&tag[0]==='H'))break; + if(nxt.classList&&nxt.classList.contains('footnotes'))break; + } + wrap.appendChild(nxt); + if(wrap.offsetHeight>anchorH+16)break; + } + }); + } + // #1: walk each page's text nodes, use Range.getClientRects() to find + // visual line rectangles, and inject absolute-positioned markers + // in the left margin. Honors countBy (show every Nth line), start + // (initial number), distance (offset from text), and restart semantics + // (newPage resets per-page; continuous keeps running). + function applyLineNumbers(){ + var wrappers=document.querySelectorAll('.page-wrapper[data-line-num-by]'); + if(!wrappers.length)return; + var runningNum=null; // continuous/newSection running counter across pages + var prevSection=null; + wrappers.forEach(function(wrap){ + var body=wrap.querySelector('.page-body'); + if(!body)return; + // Clear any previous markers before re-applying (keeps idempotent). + body.querySelectorAll('.line-number').forEach(function(m){m.remove();}); + var by=parseInt(wrap.dataset.lineNumBy||'1')||1; + var start=parseInt(wrap.dataset.lineNumStart||'1')||1; + var dist=parseFloat(wrap.dataset.lineNumDist||'0')||0; + var restart=wrap.dataset.lineNumRestart||'newPage'; + var sectionIdx=wrap.dataset.sectionIdx||'-1'; + var sectionChanged=prevSection!==null && prevSection!==sectionIdx; + var current; + if(restart==='newPage'||runningNum===null) current=start; + else if(restart==='newSection') current=sectionChanged?start:runningNum; + else current=runningNum; // continuous + prevSection=sectionIdx; + body.style.position='relative'; + var bodyRect=body.getBoundingClientRect(); + var seenY=Object.create(null); + var lineTops=[]; + var walker=document.createTreeWalker(body,NodeFilter.SHOW_TEXT,{ + acceptNode:function(n){ + if(!n.textContent.trim())return NodeFilter.FILTER_REJECT; + // Skip line numbers we just injected (idempotence), footers, etc. + var el=n.parentElement; + while(el && el!==body){ + if(el.classList && (el.classList.contains('line-number') + ||el.classList.contains('footnotes')))return NodeFilter.FILTER_REJECT; + el=el.parentElement; + } + return NodeFilter.FILTER_ACCEPT; + } + }); + var node; + while((node=walker.nextNode())){ + var range=document.createRange(); + range.selectNodeContents(node); + var rects=range.getClientRects(); + for(var i=0;i1 && n%by!==0)continue; + var marker=document.createElement('span'); + marker.className='line-number'; + marker.textContent=n; + marker.style.cssText='position:absolute;left:'+leftPt+'pt;' + +'font-size:8pt;color:#888;user-select:none;pointer-events:none;'; + marker.style.top=lineTops[li]+'px'; + body.appendChild(marker); + } + runningNum=current+lineTops.length; + }); } function positionFootnotes(){ document.querySelectorAll('.page').forEach(function(page){ @@ -352,29 +721,73 @@ function applyPageFilter(){ if(!rSet.has(i+1))p.style.display='none'; }); } + function renderNewContent(){ + if(typeof katex!=='undefined'){ + document.querySelectorAll('.katex-formula:not(.katex-rendered)').forEach(function(el){ + try{katex.render(el.dataset.formula,el,{throwOnError:false,displayMode:!!el.dataset.display});}catch(e){el.textContent=el.dataset.formula;} + el.classList.add('katex-rendered'); + }); + } + // CJK punctuation compression on new content + var cjkRe=/([\u3000-\u303F\uFF01-\uFF60\uFE30-\uFE4F\u2014\u2015\u2026\u2018\u2019\u201C\u201D])/; + document.querySelectorAll('.page-body').forEach(function(body){ + var tw=document.createTreeWalker(body,NodeFilter.SHOW_TEXT); + var nodes=[];while(tw.nextNode()){var n=tw.currentNode;if(!n.parentNode||!n.parentNode.classList||!n.parentNode.classList.contains('cjk-done'))nodes.push(n);} + nodes.forEach(function(nd){ + if(!cjkRe.test(nd.textContent))return; + var parts=nd.textContent.split(cjkRe); + if(parts.length<=1)return; + var frag=document.createDocumentFragment(); + for(var i=0;iavailW&&availW>0){ - var s=availW/pageW; - page.style.transform='scale('+s+')'; - wrapper.style.height=(page.offsetHeight*s)+'px'; - }else{ - page.style.transform=''; - wrapper.style.height=''; - } + var pageH=page.offsetHeight; + var s=Math.min(availW/pageW,1); + page.style.transform='scale('+s+')'; + wrapper.style.height=(pageH*s)+'px'; + wrapper.style.width=(pageW*s)+'px'; }); + if(!animate){ + document.body.offsetHeight; + document.querySelectorAll('.page-wrapper,.page').forEach(function(el){el.style.transition='';}); + } + if(window._pendingScrollTo){ + var _sel=window._pendingScrollTo; + var _beh=window._pendingScrollBehavior||'smooth'; + window._pendingScrollTo=null; + window._pendingScrollBehavior=null; + var _t; + if(_sel==='_last_page'){var _lb=document.querySelector('.page-wrapper:last-of-type .page-body');if(_lb){var _ck=Array.from(_lb.children).filter(function(c){return !c.classList.contains('footnotes')&&c.style.display!=='none'&&c.offsetHeight>0;});_t=_ck[_ck.length-1]||_lb;}if(!_t){var _ap=document.querySelectorAll('.page');_t=_ap[_ap.length-1];}} + else{_t=document.querySelector(_sel);if(!_t){var _ap=document.querySelectorAll('.page');_t=_ap[_ap.length-1];}} + if(_t)_t.scrollIntoView({behavior:_beh,block:'center'}); + } + var _frz=document.getElementById('_sse_freeze'); + if(_frz)_frz.remove(); } var _resizeTimer; window.addEventListener('resize',function(){ clearTimeout(_resizeTimer); - _resizeTimer=setTimeout(scalePages,100); + _resizeTimer=setTimeout(function(){scalePages(true);},100); });"); // Pass requested pages to JS for post-pagination filtering if (requestedPages != null && requestedPages.Count > 0) @@ -402,35 +815,91 @@ private PageLayout GetPageLayout() { if (_ctx?.CachedPageLayout != null) return _ctx.CachedPageLayout; var sectPr = _doc.MainDocumentPart?.Document?.Body?.GetFirstChild(); + var result = GetPageLayoutFor(sectPr); + if (_ctx != null) _ctx.CachedPageLayout = result; + return result; + } + + // OpenXML typed-value accessors throw on malformed raw attrs + // (e.g. negative on UInt32Value, overflow on Int16Value, non-numeric). + // These wrappers turn any access/parse exception into the fallback. + private static double SafeUIntTwips(Func read, double fallback) + { + try { return (double)(read() ?? (uint)fallback); } + catch { return fallback; } + } + + private static double SafeIntTwips(Func read, double fallback) + { + try { return (double)(read() ?? (int)fallback); } + catch { return fallback; } + } + + private static PageLayout GetPageLayoutFor(SectionProperties? sectPr) + { var pgSz = sectPr?.GetFirstChild(); var pgMar = sectPr?.GetFirstChild(); const double c = 2.54 / 1440.0; // twips → cm const double p = 1.0 / 20.0; // twips → pt (exact) - var wTwips = (double)(pgSz?.Width?.Value ?? 11906); - var hTwips = (double)(pgSz?.Height?.Value ?? 16838); - var tTwips = (double)(pgMar?.Top?.Value ?? 1440); - var bTwips = (double)(pgMar?.Bottom?.Value ?? 1440); - var lTwips = (double)(pgMar?.Left?.Value ?? 1440u); - var rTwips = (double)(pgMar?.Right?.Value ?? 1440u); - var hdTwips = (double)(pgMar?.Header?.Value ?? 851u); - var fdTwips = (double)(pgMar?.Footer?.Value ?? 992u); - var result = new PageLayout( + // OOXML schema types (UInt32Value) throw on .Value access when the + // raw attribute is malformed (negative, non-numeric). Tolerate it. + double wTwips = SafeUIntTwips(() => pgSz?.Width?.Value, 11906); + double hTwips = SafeUIntTwips(() => pgSz?.Height?.Value, 16838); + // Landscape: OOXML orient=landscape flips the width/height semantics. + // w:w/w:h already reflect the orientation in most real-world docs, + // but guard against the rare case where w:w < w:h but orient=landscape. + if (pgSz?.Orient?.Value == PageOrientationValues.Landscape && wTwips < hTwips) + (wTwips, hTwips) = (hTwips, wTwips); + // pgMar Top/Bottom are Int32Value, Left/Right/Header/Footer are + // UInt32Value — all throw on .Value access for malformed raw attrs. + // Wrap in the same swallow-to-fallback helper as pgSz. + double tTwips = SafeIntTwips(() => pgMar?.Top?.Value, 1440); + double bTwips = SafeIntTwips(() => pgMar?.Bottom?.Value, 1440); + double lTwips = SafeUIntTwips(() => pgMar?.Left?.Value, 1440); + double rTwips = SafeUIntTwips(() => pgMar?.Right?.Value, 1440); + double hdTwips = SafeUIntTwips(() => pgMar?.Header?.Value, 851); + double fdTwips = SafeUIntTwips(() => pgMar?.Footer?.Value, 992); + return new PageLayout( wTwips * c, hTwips * c, tTwips * c, bTwips * c, lTwips * c, rTwips * c, hdTwips * c, fdTwips * c, wTwips * p, hTwips * p, tTwips * p, bTwips * p, lTwips * p, rTwips * p, hdTwips * p, fdTwips * p); - if (_ctx != null) _ctx.CachedPageLayout = result; - return result; } - private record DocDef(string Font, double SizePt, double LineHeight, string Color, double GridLinePitchPt); + /// + /// Collect sectPrs in document order. Each paragraph's inline sectPr + /// (held in its pPr) terminates a section; the body's trailing sectPr + /// owns everything after the last inline one. + /// + private List CollectSections(Body body) + { + var list = new List(); + foreach (var p in body.Elements()) + { + var inline = p.ParagraphProperties?.GetFirstChild(); + if (inline != null) list.Add(inline); + } + var trailing = body.GetFirstChild(); + if (trailing != null) list.Add(trailing); + return list; + } + + private record DocDef(string Font, double SizePt, double LineHeight, string Color, double GridLinePitchPt, + double SpaceAfterPt = 0, string DefaultAlign = "left"); private DocDef ReadDocDefaults() { - var defs = _doc.MainDocumentPart?.StyleDefinitionsPart?.Styles?.DocDefaults; + // Malformed styles.xml — same fallback policy as theme1.xml: the + // preview should still render body content using system defaults + // rather than rejecting the entire doc. + DocDefaults? defs = null; + Style? defaultStyle = null; + try + { + defs = _doc.MainDocumentPart?.StyleDefinitionsPart?.Styles?.DocDefaults; + defaultStyle = _doc.MainDocumentPart?.StyleDefinitionsPart?.Styles + ?.Elements