mixdog 0.9.151 → 0.9.153

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/LICENSES/browser-import-NOTICE.txt +11 -0
  2. package/NOTICE.md +11 -0
  3. package/README.md +108 -68
  4. package/package.json +17 -4
  5. package/scripts/computer-task-cost-report.mjs +144 -0
  6. package/scripts/run-office-live-tests.mjs +32 -0
  7. package/scripts/tool-stress.mjs +0 -2
  8. package/src/agents/front-worker/AGENT.md +13 -0
  9. package/src/agents/front-worker/agent.json +6 -0
  10. package/src/agents/heavy-worker/AGENT.md +10 -11
  11. package/src/agents/heavy-worker/agent.json +1 -1
  12. package/src/agents/maintainer/AGENT.md +0 -2
  13. package/src/agents/reviewer/AGENT.md +9 -13
  14. package/src/agents/reviewer/agent.json +1 -1
  15. package/src/agents/security/AGENT.md +14 -0
  16. package/src/agents/security/agent.json +6 -0
  17. package/src/agents/worker/AGENT.md +8 -8
  18. package/src/agents/worker/agent.json +1 -1
  19. package/src/agents/writer/AGENT.md +14 -0
  20. package/src/agents/writer/agent.json +6 -0
  21. package/src/defaults/skills/setup/SKILL.md +18 -27
  22. package/src/headless-exec.mjs +3 -10
  23. package/src/headless-exec.test.mjs +2 -7
  24. package/src/rules/agent/00-common.md +4 -7
  25. package/src/rules/lead/01-general.md +0 -3
  26. package/src/rules/shared/10-tool-workflow.md +10 -2
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +183 -26
  28. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.test.mjs +378 -0
  29. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +4 -0
  30. package/src/runtime/agent/orchestrator/cache-break-trace.mjs +109 -0
  31. package/src/runtime/agent/orchestrator/cache-break-trace.test.mjs +115 -0
  32. package/src/runtime/agent/orchestrator/config.mjs +13 -68
  33. package/src/runtime/agent/orchestrator/context/collect-skills.test.mjs +153 -0
  34. package/src/runtime/agent/orchestrator/context/collect.mjs +49 -45
  35. package/src/runtime/agent/orchestrator/mcp/client.mjs +242 -15
  36. package/src/runtime/agent/orchestrator/mcp/features.test.mjs +66 -0
  37. package/src/runtime/agent/orchestrator/mcp/security.test.mjs +36 -20
  38. package/src/runtime/agent/orchestrator/providers/admission-scheduler-metrics.test.mjs +47 -0
  39. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +16 -3
  40. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +54 -33
  41. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -0
  42. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
  43. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +7 -0
  44. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +3 -8
  45. package/src/runtime/agent/orchestrator/providers/gemini.mjs +7 -0
  46. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +19 -3
  47. package/src/runtime/agent/orchestrator/providers/lib/provider-replay.mjs +50 -0
  48. package/src/runtime/agent/orchestrator/providers/lib/sse-framing.mjs +93 -0
  49. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +30 -9
  50. package/src/runtime/agent/orchestrator/providers/media-parity.test.mjs +59 -0
  51. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +3 -3
  52. package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +71 -0
  53. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +18 -26
  54. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +8 -0
  55. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -0
  56. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +25 -2
  57. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
  58. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +47 -2
  59. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +103 -12
  60. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +13 -0
  61. package/src/runtime/agent/orchestrator/providers/openai-turn-state.mjs +68 -0
  62. package/src/runtime/agent/orchestrator/providers/openai-ws-delta-parity.test.mjs +122 -6
  63. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +36 -11
  64. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +144 -93
  65. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +18 -13
  66. package/src/runtime/agent/orchestrator/providers/provider-replay.test.mjs +288 -0
  67. package/src/runtime/agent/orchestrator/providers/sse-framing.test.mjs +719 -0
  68. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +399 -39
  69. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +26 -0
  70. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +86 -7
  71. package/src/runtime/agent/orchestrator/session/agent-loop.test.mjs +222 -0
  72. package/src/runtime/agent/orchestrator/session/browser-snapshot-supersession.mjs +61 -0
  73. package/src/runtime/agent/orchestrator/session/browser-snapshot-supersession.test.mjs +83 -0
  74. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +13 -0
  75. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +26 -2
  76. package/src/runtime/agent/orchestrator/session/compact-policy.test.mjs +112 -0
  77. package/src/runtime/agent/orchestrator/session/compaction-read-reset.test.mjs +192 -0
  78. package/src/runtime/agent/orchestrator/session/context-usage-restart.test.mjs +72 -0
  79. package/src/runtime/agent/orchestrator/session/context-utils.mjs +100 -15
  80. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +17 -1
  81. package/src/runtime/agent/orchestrator/session/eager-dispatch.test.mjs +74 -0
  82. package/src/runtime/agent/orchestrator/session/image-strip-recovery.mjs +12 -8
  83. package/src/runtime/agent/orchestrator/session/image-strip-recovery.test.mjs +20 -1
  84. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +110 -2
  85. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +3 -16
  86. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.test.mjs +28 -0
  87. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -2
  88. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +6 -0
  89. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -1
  90. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -0
  91. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +111 -39
  92. package/src/runtime/agent/orchestrator/session/manager/ask-session.test.mjs +46 -0
  93. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +30 -6
  94. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +3 -0
  95. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +27 -3
  96. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +26 -54
  97. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.test.mjs +63 -1
  98. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +3 -0
  99. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -110
  100. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +580 -0
  101. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.test.mjs +593 -0
  102. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +119 -190
  103. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +115 -0
  104. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +43 -8
  105. package/src/runtime/agent/orchestrator/session/provider-prefix-guard.mjs +129 -12
  106. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +9 -2
  107. package/src/runtime/agent/orchestrator/session/store/listing.mjs +8 -1
  108. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +15 -1
  109. package/src/runtime/agent/orchestrator/session/store-live-publication.test.mjs +83 -0
  110. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +36 -2
  111. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +157 -30
  112. package/src/runtime/agent/orchestrator/session/store-summary-reader.test.mjs +64 -0
  113. package/src/runtime/agent/orchestrator/session/store-summary-visibility.mjs +57 -0
  114. package/src/runtime/agent/orchestrator/session/store-summary-visibility.test.mjs +219 -0
  115. package/src/runtime/agent/orchestrator/session/store.mjs +26 -2
  116. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +22 -0
  117. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool-cwd-env.test.mjs +14 -0
  118. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +19 -10
  119. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +30 -14
  120. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +15 -4
  121. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +73 -15
  122. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +76 -2
  123. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +24 -683
  124. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +64 -72
  125. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +2 -5
  126. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.mjs +75 -0
  127. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.test.mjs +24 -0
  128. package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +6 -6
  129. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +12 -0
  130. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +4 -1
  131. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +24 -5
  132. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.test.mjs +44 -0
  133. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +12 -2
  134. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +35 -21
  135. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +28 -13
  136. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.test.mjs +54 -0
  137. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  138. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  139. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +12 -0
  140. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  141. package/src/runtime/attachments/pdf-extract.mjs +3 -0
  142. package/src/runtime/attachments/pdfjs-runtime.mjs +13 -0
  143. package/src/runtime/bridge-clients.test.mjs +827 -0
  144. package/src/runtime/browser-bridge/action-schema.mjs +251 -0
  145. package/src/runtime/browser-bridge/client.mjs +152 -0
  146. package/src/runtime/browser-bridge/tool-defs.mjs +159 -0
  147. package/src/runtime/computer-bridge/action-schema.mjs +653 -0
  148. package/src/runtime/computer-bridge/client.mjs +231 -0
  149. package/src/runtime/computer-bridge/tool-defs.mjs +33 -0
  150. package/src/runtime/media/store.mjs +18 -8
  151. package/src/runtime/media/store.test.mjs +14 -0
  152. package/src/runtime/memory/index.mjs +1 -1
  153. package/src/runtime/memory/lib/transcript-ingest.mjs +8 -6
  154. package/src/runtime/memory/lib/transcript-ingest.test.mjs +52 -0
  155. package/src/runtime/office/assurance-benchmark.mjs +358 -0
  156. package/src/runtime/office/assurance.mjs +833 -0
  157. package/src/runtime/office/benchmark.mjs +496 -0
  158. package/src/runtime/office/capabilities.mjs +551 -0
  159. package/src/runtime/office/com-adapter.mjs +283 -0
  160. package/src/runtime/office/content-model.mjs +183 -0
  161. package/src/runtime/office/contract-benchmark.mjs +105 -0
  162. package/src/runtime/office/design-library.mjs +1562 -0
  163. package/src/runtime/office/design-system.mjs +1966 -0
  164. package/src/runtime/office/index.mjs +2413 -0
  165. package/src/runtime/office/journal.mjs +82 -0
  166. package/src/runtime/office/office-assurance.test.mjs +371 -0
  167. package/src/runtime/office/office-com-host.ps1 +3781 -0
  168. package/src/runtime/office/office-com-session-host.ps1 +855 -0
  169. package/src/runtime/office/office-live-runtime.test.mjs +819 -0
  170. package/src/runtime/office/office-runtime.test.mjs +2070 -0
  171. package/src/runtime/office/ooxml-validator.mjs +271 -0
  172. package/src/runtime/office/pagination.mjs +132 -0
  173. package/src/runtime/office/pdf-adapter.mjs +591 -0
  174. package/src/runtime/office/pdf-analysis.mjs +372 -0
  175. package/src/runtime/office/pdf-render.mjs +167 -0
  176. package/src/runtime/office/pdf-security.mjs +63 -0
  177. package/src/runtime/office/polish-benchmark.mjs +190 -0
  178. package/src/runtime/office/portable-ooxml.mjs +1867 -0
  179. package/src/runtime/office/quality-live-benchmark.mjs +486 -0
  180. package/src/runtime/office/quality-pipeline.mjs +136 -0
  181. package/src/runtime/office/tabular.mjs +302 -0
  182. package/src/runtime/office/templates/mixdog-executive.pptx +0 -0
  183. package/src/runtime/office/templates/mixdog-executive.pptx.mixdog.json +190 -0
  184. package/src/runtime/office/tool-defs.mjs +113 -0
  185. package/src/runtime/office/visual-diff.mjs +89 -0
  186. package/src/runtime/office/xlsx-assertions.mjs +172 -0
  187. package/src/runtime/office/xlsx-contract.mjs +147 -0
  188. package/src/runtime/shared/agent-route-config.mjs +9 -0
  189. package/src/runtime/shared/child-spawn-gate.mjs +17 -0
  190. package/src/runtime/shared/child-spawn-remote.mjs +69 -7
  191. package/src/runtime/shared/child-spawn-remote.test.mjs +124 -0
  192. package/src/runtime/shared/pristine-execution-contract.json +2 -1
  193. package/src/runtime/shared/provider-api-key.mjs +1 -0
  194. package/src/runtime/shared/session-runtime-health.mjs +91 -0
  195. package/src/runtime/shared/session-runtime-health.test.mjs +53 -0
  196. package/src/runtime/shared/skill-document.mjs +86 -0
  197. package/src/runtime/shared/skill-document.test.mjs +81 -0
  198. package/src/runtime/shared/tool-execution-contract.mjs +16 -0
  199. package/src/runtime/shared/tool-surface.mjs +27 -0
  200. package/src/runtime/shared/turn-snapshot-store.mjs +1 -0
  201. package/src/runtime/shared/turn-snapshot.mjs +32 -9
  202. package/src/runtime/shared/user-cwd.mjs +33 -14
  203. package/src/runtime/shared/user-cwd.test.mjs +43 -0
  204. package/src/session-runtime/agent-disable.test.mjs +20 -1
  205. package/src/session-runtime/context-status.mjs +9 -3
  206. package/src/session-runtime/cwd-plugins.mjs +26 -26
  207. package/src/session-runtime/cwd-plugins.test.mjs +75 -0
  208. package/src/session-runtime/cwd-tool-routing.test.mjs +5 -0
  209. package/src/session-runtime/deferred-tool-delta.mjs +104 -0
  210. package/src/session-runtime/deferred-tool-delta.test.mjs +40 -0
  211. package/src/session-runtime/global-extensions.mjs +26 -0
  212. package/src/session-runtime/global-extensions.test.mjs +28 -0
  213. package/src/session-runtime/global-settings-session-identity.test.mjs +76 -0
  214. package/src/session-runtime/goal-runtime.mjs +1141 -0
  215. package/src/session-runtime/goal-runtime.test.mjs +645 -0
  216. package/src/session-runtime/lifecycle-api.mjs +58 -11
  217. package/src/session-runtime/lifecycle-api.test.mjs +183 -0
  218. package/src/session-runtime/mcp-glue.mjs +49 -47
  219. package/src/session-runtime/plugin-mcp-standard.test.mjs +177 -0
  220. package/src/session-runtime/plugin-mcp.mjs +96 -22
  221. package/src/session-runtime/prewarm.mjs +1 -10
  222. package/src/session-runtime/provider-request-snapshot.mjs +33 -9
  223. package/src/session-runtime/provider-request-snapshot.test.mjs +27 -5
  224. package/src/session-runtime/resource-api-global.test.mjs +109 -0
  225. package/src/session-runtime/resource-api.mjs +168 -116
  226. package/src/session-runtime/runtime-core.mjs +208 -29
  227. package/src/session-runtime/runtime-tunables.mjs +6 -8
  228. package/src/session-runtime/session-lifecycle.mjs +34 -12
  229. package/src/session-runtime/session-title.mjs +2 -2
  230. package/src/session-runtime/session-turn-api.mjs +37 -25
  231. package/src/session-runtime/skills-api.mjs +79 -25
  232. package/src/session-runtime/skills-api.test.mjs +99 -0
  233. package/src/session-runtime/tool-catalog-data.mjs +1 -1
  234. package/src/session-runtime/tool-catalog-schema.mjs +0 -5
  235. package/src/session-runtime/tool-catalog.mjs +42 -112
  236. package/src/session-runtime/tool-defs.mjs +6 -1
  237. package/src/session-runtime/tool-policy-surface.test.mjs +93 -17
  238. package/src/session-runtime/tool-surface.mjs +30 -8
  239. package/src/session-runtime/workflow-agents-api.mjs +8 -25
  240. package/src/session-runtime/workflow.mjs +8 -6
  241. package/src/standalone/agent-dispatch-broker.mjs +5 -9
  242. package/src/standalone/agent-tool/helpers.mjs +2 -2
  243. package/src/standalone/agent-tool/job-views.mjs +3 -1
  244. package/src/standalone/agent-tool/notify.mjs +11 -9
  245. package/src/standalone/agent-tool/notify.test.mjs +37 -0
  246. package/src/standalone/agent-tool/render.mjs +3 -0
  247. package/src/standalone/agent-tool/spawn-flow.mjs +61 -16
  248. package/src/standalone/agent-tool/spawn-flow.test.mjs +41 -0
  249. package/src/standalone/agent-tool/tool-def.mjs +1 -1
  250. package/src/standalone/agent-tool/worker-index.mjs +123 -0
  251. package/src/standalone/agent-tool/worker-index.test.mjs +132 -0
  252. package/src/standalone/agent-tool/worker-rows.mjs +2 -1
  253. package/src/standalone/agent-tool.mjs +57 -10
  254. package/src/standalone/daemon.mjs +113 -15
  255. package/src/standalone/hook-bus/config.mjs +1 -0
  256. package/src/standalone/plugin-admin.mjs +17 -0
  257. package/src/standalone/plugin-admin.test.mjs +7 -0
  258. package/src/standalone/provider-admin.mjs +1 -0
  259. package/src/standalone/session-protocol.mjs +5 -0
  260. package/src/standalone/session-runtime-agent-control-client.mjs +133 -0
  261. package/src/standalone/session-runtime-agent-control-client.test.mjs +71 -0
  262. package/src/standalone/session-runtime-dispatch-cancel.test.mjs +139 -0
  263. package/src/standalone/session-runtime-host-factory.mjs +17 -0
  264. package/src/standalone/session-runtime-host-factory.test.mjs +31 -0
  265. package/src/standalone/session-runtime-host-health.test.mjs +5 -0
  266. package/src/standalone/session-runtime-host.mjs +709 -58
  267. package/src/standalone/session-runtime-inline-host.mjs +258 -0
  268. package/src/standalone/session-runtime-inline-host.test.mjs +385 -0
  269. package/src/standalone/session-runtime-provider-cooldown.mjs +78 -0
  270. package/src/standalone/session-runtime-provider-cooldown.test.mjs +80 -0
  271. package/src/standalone/session-runtime-shard-host.test.mjs +642 -0
  272. package/src/standalone/session-runtime-shard-router.mjs +149 -0
  273. package/src/standalone/session-runtime-shard-router.test.mjs +101 -0
  274. package/src/standalone/session-runtime-worker.mjs +302 -26
  275. package/src/standalone/session-service-agent-tree.test.mjs +984 -0
  276. package/src/standalone/session-service-goal-recovery.test.mjs +177 -0
  277. package/src/standalone/session-service.mjs +711 -11
  278. package/src/tui/app/app-view.jsx +1 -0
  279. package/src/tui/app/core-memory-picker.mjs +2 -0
  280. package/src/tui/app/extension-pickers.mjs +1 -1
  281. package/src/tui/app/maintenance-pickers.mjs +3 -3
  282. package/src/tui/app/model-options.mjs +2 -0
  283. package/src/tui/app/model-picker.mjs +14 -4
  284. package/src/tui/app/panel-handoff.test.mjs +311 -0
  285. package/src/tui/app/project-picker.mjs +1 -0
  286. package/src/tui/app/route-pickers.mjs +21 -6
  287. package/src/tui/app/settings-picker.mjs +26 -3
  288. package/src/tui/app/slash-commands.mjs +1 -0
  289. package/src/tui/app/slash-dispatch.mjs +73 -22
  290. package/src/tui/app/theme-effort-pickers.mjs +7 -2
  291. package/src/tui/components/Picker.jsx +10 -1
  292. package/src/tui/dist/index.mjs +187 -45
  293. package/src/tui/lib/voice-setup.mjs +4 -1
  294. package/src/tui/session/agent-message-source.test.mjs +37 -0
  295. package/src/tui/session/completion-card-restore.test.mjs +50 -0
  296. package/src/tui/session/context-state.mjs +3 -13
  297. package/src/tui/session/goal-continuation.mjs +141 -0
  298. package/src/tui/session/goal-continuation.test.mjs +203 -0
  299. package/src/tui/session/queue-helpers.mjs +5 -2
  300. package/src/tui/session/session-action-surface.test.mjs +190 -0
  301. package/src/tui/session/session-api-ext.mjs +48 -8
  302. package/src/tui/session/session-api.mjs +73 -1
  303. package/src/tui/session/session-flow.mjs +31 -1
  304. package/src/tui/session/turn.mjs +88 -15
  305. package/src/tui/session-local.mjs +39 -3
  306. package/src/vendor/statusline/src/gateway/route-meta.mjs +31 -0
  307. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +0 -301
@@ -0,0 +1,3781 @@
1
+ $ErrorActionPreference = 'Stop'
2
+ [Console]::InputEncoding = [System.Text.Encoding]::UTF8
3
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
4
+
5
+ function Emit-Json($value) {
6
+ [Console]::Out.WriteLine(($value | ConvertTo-Json -Depth 20 -Compress))
7
+ }
8
+
9
+ function ProgId-ForFormat([string]$format) {
10
+ switch ($format.ToLowerInvariant()) {
11
+ 'docx' { return 'Word.Application' }
12
+ 'xlsx' { return 'Excel.Application' }
13
+ 'pptx' { return 'PowerPoint.Application' }
14
+ default { return $null }
15
+ }
16
+ }
17
+
18
+ function Office-SaveFormatForPath([string]$format, [string]$path) {
19
+ $extension = [System.IO.Path]::GetExtension($path).TrimStart('.').ToLowerInvariant()
20
+ switch ($format) {
21
+ 'docx' {
22
+ switch ($extension) {
23
+ 'docm' { return 13 }
24
+ 'dotx' { return 14 }
25
+ 'dotm' { return 15 }
26
+ default { return 16 }
27
+ }
28
+ }
29
+ 'xlsx' {
30
+ switch ($extension) {
31
+ 'xlsm' { return 52 }
32
+ 'xltm' { return 53 }
33
+ 'xltx' { return 54 }
34
+ default { return 51 }
35
+ }
36
+ }
37
+ 'pptx' {
38
+ switch ($extension) {
39
+ 'pptm' { return 25 }
40
+ 'potx' { return 26 }
41
+ 'potm' { return 27 }
42
+ default { return 24 }
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+ function Collection-For($app, [string]$format) {
49
+ switch ($format) {
50
+ 'docx' { return $app.Documents }
51
+ 'xlsx' { return $app.Workbooks }
52
+ 'pptx' { return $app.Presentations }
53
+ }
54
+ }
55
+
56
+ function Find-OpenDocument($app, [string]$format, [string]$path) {
57
+ $full = [System.IO.Path]::GetFullPath($path)
58
+ foreach ($item in @(Collection-For $app $format)) {
59
+ try {
60
+ if ([string]::Equals([System.IO.Path]::GetFullPath([string]$item.FullName), $full, [System.StringComparison]::OrdinalIgnoreCase)) {
61
+ return $item
62
+ }
63
+ } catch {}
64
+ }
65
+ return $null
66
+ }
67
+
68
+ function Active-Application([string]$progId) {
69
+ try {
70
+ return [System.Runtime.InteropServices.Marshal]::GetActiveObject($progId)
71
+ } catch {
72
+ return $null
73
+ }
74
+ }
75
+
76
+ function Installed([string]$progId) {
77
+ if (-not $progId) { return $false }
78
+ try { return $null -ne [type]::GetTypeFromProgID($progId) } catch { return $false }
79
+ }
80
+
81
+ function New-HiddenApplication([string]$format, [string]$progId) {
82
+ $app = New-Object -ComObject $progId
83
+ try { $app.DisplayAlerts = 0 } catch {}
84
+ try { $app.AutomationSecurity = 3 } catch {}
85
+ if ($format -ne 'pptx') {
86
+ try { $app.Visible = $false } catch {}
87
+ }
88
+ return $app
89
+ }
90
+
91
+ function Open-BackgroundDocument($app, [string]$format, [string]$path) {
92
+ switch ($format) {
93
+ 'docx' { return $app.Documents.Open($path) }
94
+ 'xlsx' {
95
+ $book = $app.Workbooks.Open($path)
96
+ try { $app.CalculateFullRebuild() } catch {}
97
+ return $book
98
+ }
99
+ 'pptx' { return $app.Presentations.Open($path, $false, $false, $false) }
100
+ }
101
+ }
102
+
103
+ function Snapshot-Word($doc, $payload) {
104
+ $paragraphs = @()
105
+ $paragraphOffset = if ($payload.paged) { [Math]::Max(0, [int]$payload.offset) } else { 0 }
106
+ $paragraphLimit = if ($payload.paged) { [Math]::Max(1, [int]$payload.limit) } else { [int]$doc.Paragraphs.Count }
107
+ if ($payload.target -and [string]$payload.target -match '^/body/p\[(\d+)\]') {
108
+ $paragraphOffset = [int]$Matches[1] - 1
109
+ $paragraphLimit = 1
110
+ }
111
+ $paragraphEnd = [Math]::Min([int]$doc.Paragraphs.Count, $paragraphOffset + $paragraphLimit)
112
+ for ($index = $paragraphOffset + 1; $index -le $paragraphEnd; $index++) {
113
+ $p = $doc.Paragraphs.Item($index)
114
+ $text = ([string]$p.Range.Text).TrimEnd("`r", "`a")
115
+ if ($text.Length -gt 0) {
116
+ $style = $p.Range.Style
117
+ $styleName = try { [string]$style.NameLocal } catch {
118
+ try { [string]$style.Name } catch { [string]$style }
119
+ }
120
+ $tabStops = @()
121
+ try {
122
+ for ($tabIndex = 1; $tabIndex -le $p.Format.TabStops.Count; $tabIndex++) {
123
+ $tab = $p.Format.TabStops.Item($tabIndex)
124
+ $tabStops += [ordered]@{
125
+ position = [double]$tab.Position
126
+ alignment = [int]$tab.Alignment
127
+ leader = [int]$tab.Leader
128
+ }
129
+ }
130
+ } catch {}
131
+ $paragraphs += [ordered]@{
132
+ path = "/body/p[$index]"
133
+ index = $index
134
+ text = $text
135
+ style = $styleName
136
+ start = [int]$p.Range.Start
137
+ end = [int]$p.Range.End
138
+ pageStart = $(try { [int]$p.Range.Information(3) } catch { 0 })
139
+ pageEnd = $(try {
140
+ $endRange = $p.Range.Duplicate
141
+ $endRange.Collapse(0)
142
+ [int]$endRange.Information(3)
143
+ } catch { 0 })
144
+ format = [ordered]@{
145
+ alignment = [int]$p.Format.Alignment
146
+ spacingBefore = [double]$p.Format.SpaceBefore
147
+ spacingAfter = [double]$p.Format.SpaceAfter
148
+ lineSpacing = [double]$p.Format.LineSpacing
149
+ keepWithNext = [int]$p.Format.KeepWithNext
150
+ pageBreakBefore = [int]$p.Format.PageBreakBefore
151
+ tabStops = $tabStops
152
+ }
153
+ }
154
+ }
155
+ }
156
+ $tables = @()
157
+ for ($i = 1; $i -le $doc.Tables.Count; $i++) {
158
+ $table = $doc.Tables.Item($i)
159
+ $rows = @()
160
+ for ($r = 1; $r -le $table.Rows.Count; $r++) {
161
+ $cells = @()
162
+ for ($c = 1; $c -le $table.Columns.Count; $c++) {
163
+ try { $cells += ([string]$table.Cell($r, $c).Range.Text).TrimEnd("`r", "`a") } catch { $cells += $null }
164
+ }
165
+ $rows += ,$cells
166
+ }
167
+ $tableRows = @()
168
+ for ($r = 1; $r -le $rows.Count; $r++) {
169
+ $cells = @()
170
+ for ($c = 1; $c -le @($rows[$r - 1]).Count; $c++) {
171
+ $cells += [ordered]@{ path = "/body/tbl[$i]/row[$r]/cell[$c]"; index = $c; text = @($rows[$r - 1])[$c - 1] }
172
+ }
173
+ $tableRows += [ordered]@{ path = "/body/tbl[$i]/row[$r]"; index = $r; cells = $cells }
174
+ }
175
+ $columnWidths = @()
176
+ try {
177
+ for ($columnIndex = 1; $columnIndex -le $table.Columns.Count; $columnIndex++) {
178
+ $columnWidths += [double]$table.Columns.Item($columnIndex).Width
179
+ }
180
+ } catch {
181
+ try {
182
+ $firstRow = $table.Rows.Item(1)
183
+ for ($columnIndex = 1; $columnIndex -le $firstRow.Cells.Count; $columnIndex++) {
184
+ $columnWidths += [double]$firstRow.Cells.Item($columnIndex).Width
185
+ }
186
+ } catch {}
187
+ }
188
+ $tables += [ordered]@{
189
+ path = "/body/tbl[$i]"
190
+ index = $i
191
+ style = $(try { [string]$table.Style.NameLocal } catch { try { [string]$table.Style } catch { '' } })
192
+ alignment = $(try { [int]$table.Rows.Alignment } catch { 0 })
193
+ columnWidths = $columnWidths
194
+ rows = $tableRows
195
+ start = [int]$table.Range.Start
196
+ end = [int]$table.Range.End
197
+ pageStart = $(try {
198
+ $startRange = $table.Range.Duplicate
199
+ $startRange.Collapse(1)
200
+ [int]$startRange.Information(3)
201
+ } catch { 0 })
202
+ pageEnd = $(try {
203
+ $endRange = $table.Range.Duplicate
204
+ $endRange.Collapse(0)
205
+ [int]$endRange.Information(3)
206
+ } catch { 0 })
207
+ }
208
+ }
209
+ $blockOrder = @(
210
+ @($paragraphs | ForEach-Object {
211
+ [ordered]@{ type = 'paragraph'; index = [int]$_.index; path = [string]$_.path; start = [int]$_.start }
212
+ })
213
+ @($tables | ForEach-Object {
214
+ [ordered]@{ type = 'table'; index = [int]$_.index; path = [string]$_.path; start = [int]$_.start }
215
+ })
216
+ ) | Sort-Object start
217
+ $sections = @()
218
+ for ($sectionIndex = 1; $sectionIndex -le $doc.Sections.Count; $sectionIndex++) {
219
+ $section = $doc.Sections.Item($sectionIndex)
220
+ $stories = @()
221
+ foreach ($kind in @(
222
+ [ordered]@{ name = 'primary'; value = 1 },
223
+ [ordered]@{ name = 'first'; value = 2 },
224
+ [ordered]@{ name = 'even'; value = 3 }
225
+ )) {
226
+ foreach ($location in @('header', 'footer')) {
227
+ try {
228
+ $collection = if ($location -eq 'header') { $section.Headers } else { $section.Footers }
229
+ $item = $collection.Item([int]$kind.value)
230
+ $stories += [ordered]@{
231
+ path = "/section[$sectionIndex]/${location}[$($kind.name)]"
232
+ kind = [string]$kind.name
233
+ location = $location
234
+ text = ([string]$item.Range.Text).TrimEnd("`r", "`a")
235
+ linkToPrevious = [bool]$item.LinkToPrevious
236
+ }
237
+ } catch {}
238
+ }
239
+ }
240
+ $sections += [ordered]@{
241
+ path = "/section[$sectionIndex]"
242
+ index = $sectionIndex
243
+ orientation = [int]$section.PageSetup.Orientation
244
+ topMargin = [double]$section.PageSetup.TopMargin
245
+ bottomMargin = [double]$section.PageSetup.BottomMargin
246
+ leftMargin = [double]$section.PageSetup.LeftMargin
247
+ rightMargin = [double]$section.PageSetup.RightMargin
248
+ stories = $stories
249
+ }
250
+ }
251
+ $images = @()
252
+ for ($imageIndex = 1; $imageIndex -le $doc.InlineShapes.Count; $imageIndex++) {
253
+ $image = $doc.InlineShapes.Item($imageIndex)
254
+ $images += [ordered]@{
255
+ path = "/body/image[$imageIndex]"
256
+ index = $imageIndex
257
+ type = [int]$image.Type
258
+ width = [double]$image.Width
259
+ height = [double]$image.Height
260
+ altText = $(try { [string]$image.AlternativeText } catch { '' })
261
+ }
262
+ }
263
+ $comments = @()
264
+ for ($commentIndex = 1; $commentIndex -le $doc.Comments.Count; $commentIndex++) {
265
+ $comment = $doc.Comments.Item($commentIndex)
266
+ $isReply = $false
267
+ try { $isReply = $null -ne $comment.Ancestor } catch {}
268
+ if ($isReply) { continue }
269
+ $replies = @()
270
+ try {
271
+ for ($replyIndex = 1; $replyIndex -le $comment.Replies.Count; $replyIndex++) {
272
+ $reply = $comment.Replies.Item($replyIndex)
273
+ $replies += [ordered]@{
274
+ index = $replyIndex
275
+ author = $(try { [string]$reply.Author } catch { '' })
276
+ date = $(try { ([datetime]$reply.Date).ToUniversalTime().ToString('o') } catch { '' })
277
+ text = $(try { ([string]$reply.Range.Text).TrimEnd("`r", "`a") } catch { '' })
278
+ }
279
+ }
280
+ } catch {}
281
+ $comments += [ordered]@{
282
+ path = "/body/comment[$commentIndex]"
283
+ index = $commentIndex
284
+ author = $(try { [string]$comment.Author } catch { '' })
285
+ initials = $(try { [string]$comment.Initial } catch { '' })
286
+ date = $(try { ([datetime]$comment.Date).ToUniversalTime().ToString('o') } catch { '' })
287
+ text = $(try { ([string]$comment.Range.Text).TrimEnd("`r", "`a") } catch { '' })
288
+ anchoredText = $(try { ([string]$comment.Scope.Text).TrimEnd("`r", "`a") } catch { '' })
289
+ resolved = $(try { [bool]$comment.Done } catch { $false })
290
+ replies = $replies
291
+ }
292
+ }
293
+ $revisionTypes = @{
294
+ 1 = 'insertion'; 2 = 'deletion'; 3 = 'property'; 4 = 'paragraph_number'
295
+ 5 = 'display_field'; 6 = 'reconcile'; 7 = 'conflict'; 8 = 'style'
296
+ 9 = 'replacement'; 10 = 'paragraph_property'; 11 = 'table_property'
297
+ 12 = 'section_property'; 13 = 'style_definition'; 14 = 'moved_from'
298
+ 15 = 'moved_to'; 16 = 'cell_insertion'; 17 = 'cell_deletion'; 18 = 'cell_merge'
299
+ }
300
+ $revisions = @()
301
+ for ($revisionIndex = 1; $revisionIndex -le $doc.Revisions.Count; $revisionIndex++) {
302
+ $revision = $doc.Revisions.Item($revisionIndex)
303
+ $typeCode = [int]$revision.Type
304
+ $revisions += [ordered]@{
305
+ path = "/body/revision[$revisionIndex]"
306
+ index = $revisionIndex
307
+ type = $(if ($revisionTypes.ContainsKey($typeCode)) { [string]$revisionTypes[$typeCode] } else { 'unknown' })
308
+ typeCode = $typeCode
309
+ author = $(try { [string]$revision.Author } catch { '' })
310
+ date = $(try { ([datetime]$revision.Date).ToUniversalTime().ToString('o') } catch { '' })
311
+ text = $(try { ([string]$revision.Range.Text).TrimEnd("`r", "`a") } catch { '' })
312
+ }
313
+ }
314
+ $footnotes = @()
315
+ for ($noteIndex = 1; $noteIndex -le $doc.Footnotes.Count; $noteIndex++) {
316
+ $note = $doc.Footnotes.Item($noteIndex)
317
+ $footnotes += [ordered]@{
318
+ path = "/body/footnote[$noteIndex]"
319
+ index = $noteIndex
320
+ text = ([string]$note.Range.Text).TrimEnd("`r", "`a")
321
+ }
322
+ }
323
+ $endnotes = @()
324
+ for ($noteIndex = 1; $noteIndex -le $doc.Endnotes.Count; $noteIndex++) {
325
+ $note = $doc.Endnotes.Item($noteIndex)
326
+ $endnotes += [ordered]@{
327
+ path = "/body/endnote[$noteIndex]"
328
+ index = $noteIndex
329
+ text = ([string]$note.Range.Text).TrimEnd("`r", "`a")
330
+ }
331
+ }
332
+ $contentControls = @()
333
+ for ($controlIndex = 1; $controlIndex -le $doc.ContentControls.Count; $controlIndex++) {
334
+ $control = $doc.ContentControls.Item($controlIndex)
335
+ $contentControls += [ordered]@{
336
+ path = "/body/content-control[$controlIndex]"
337
+ index = $controlIndex
338
+ tag = [string]$control.Tag
339
+ title = [string]$control.Title
340
+ lockContents = [bool]$control.LockContents
341
+ lockControl = [bool]$control.LockContentControl
342
+ text = ([string]$control.Range.Text).TrimEnd("`r", "`a")
343
+ }
344
+ }
345
+ return [ordered]@{
346
+ format = 'docx'
347
+ path = [string]$doc.FullName
348
+ paragraphCount = $doc.Paragraphs.Count
349
+ tableCount = $doc.Tables.Count
350
+ commentCount = $comments.Count
351
+ revisionCount = $doc.Revisions.Count
352
+ comments = $comments
353
+ revisions = $revisions
354
+ footnoteCount = $footnotes.Count
355
+ endnoteCount = $endnotes.Count
356
+ footnotes = $footnotes
357
+ endnotes = $endnotes
358
+ contentControlCount = $contentControls.Count
359
+ contentControls = $contentControls
360
+ paragraphs = $paragraphs
361
+ tables = $tables
362
+ blockOrder = $blockOrder
363
+ sections = $sections
364
+ images = $images
365
+ pagination = $(if ($payload.paged) {
366
+ [ordered]@{
367
+ unit = 'paragraph'
368
+ offset = $paragraphOffset
369
+ limit = $paragraphLimit
370
+ returned = $paragraphs.Count
371
+ total = [int]$doc.Paragraphs.Count
372
+ nextOffset = $(if ($paragraphEnd -lt [int]$doc.Paragraphs.Count) { $paragraphEnd } else { $null })
373
+ }
374
+ } else { $null })
375
+ }
376
+ }
377
+
378
+ function Cell-Value($cell) {
379
+ $value = $cell.Value2
380
+ if ($null -eq $value) { return $null }
381
+ if ($value -is [System.Array]) {
382
+ $rows = @()
383
+ for ($r = 1; $r -le $value.GetLength(0); $r++) {
384
+ $line = @()
385
+ for ($c = 1; $c -le $value.GetLength(1); $c++) { $line += $value[$r, $c] }
386
+ $rows += ,$line
387
+ }
388
+ return $rows
389
+ }
390
+ return $value
391
+ }
392
+
393
+ function Excel-FormulaPrecedents([string]$formula, [string]$currentSheet) {
394
+ $output = @()
395
+ $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
396
+ $clean = $formula.Replace("'", '')
397
+ foreach ($match in [regex]::Matches($clean, '(?:(?<sheet>[A-Za-z0-9_ .-]+)!)?\$?(?<col>[A-Z]{1,3})\$?(?<row>[1-9]\d*)')) {
398
+ $sheet = if ($match.Groups['sheet'].Success) { [string]$match.Groups['sheet'].Value } else { $currentSheet }
399
+ $ref = "$([string]$match.Groups['col'].Value)$([string]$match.Groups['row'].Value)"
400
+ $key = "$sheet!$ref"
401
+ if ($seen.Add($key)) {
402
+ $output += [ordered]@{ sheet = $sheet; ref = $ref; path = "/sheet[$sheet]/cell[$ref]" }
403
+ }
404
+ }
405
+ return $output
406
+ }
407
+
408
+ function Snapshot-Excel($book, $payload) {
409
+ $sheets = @()
410
+ foreach ($sheet in @($book.Worksheets)) {
411
+ $used = $sheet.UsedRange
412
+ $entry = [ordered]@{
413
+ path = "/sheet[$([string]$sheet.Name)]"
414
+ name = [string]$sheet.Name
415
+ rows = [int]$used.Rows.Count
416
+ columns = [int]$used.Columns.Count
417
+ visible = [int]$sheet.Visible
418
+ pageSetup = [ordered]@{
419
+ printArea = $(try { [string]$sheet.PageSetup.PrintArea } catch { '' })
420
+ zoom = $(try { $sheet.PageSetup.Zoom } catch { $null })
421
+ fitToPagesWide = $(try { $sheet.PageSetup.FitToPagesWide } catch { $null })
422
+ fitToPagesTall = $(try { $sheet.PageSetup.FitToPagesTall } catch { $null })
423
+ orientation = $(try { [int]$sheet.PageSetup.Orientation } catch { 0 })
424
+ paperSize = $(try { [int]$sheet.PageSetup.PaperSize } catch { 0 })
425
+ }
426
+ }
427
+ $tables = @()
428
+ for ($tableIndex = 1; $tableIndex -le $sheet.ListObjects.Count; $tableIndex++) {
429
+ $table = $sheet.ListObjects.Item($tableIndex)
430
+ $tables += [ordered]@{
431
+ path = "/sheet[$([string]$sheet.Name)]/table[$tableIndex]"
432
+ index = $tableIndex
433
+ name = [string]$table.Name
434
+ range = [string]$table.Range.Address($false, $false)
435
+ style = [string]$table.TableStyle
436
+ }
437
+ }
438
+ $charts = @()
439
+ for ($chartIndex = 1; $chartIndex -le $sheet.ChartObjects().Count; $chartIndex++) {
440
+ $chartObject = $sheet.ChartObjects().Item($chartIndex)
441
+ $chart = $chartObject.Chart
442
+ $series = @()
443
+ try {
444
+ for ($seriesIndex = 1; $seriesIndex -le $chart.SeriesCollection().Count; $seriesIndex++) {
445
+ $seriesItem = $chart.SeriesCollection().Item($seriesIndex)
446
+ $series += [ordered]@{ index = $seriesIndex; name = [string]$seriesItem.Name; formula = [string]$seriesItem.Formula }
447
+ }
448
+ } catch {}
449
+ $charts += [ordered]@{
450
+ path = "/sheet[$([string]$sheet.Name)]/chart[$chartIndex]"
451
+ index = $chartIndex
452
+ name = [string]$chartObject.Name
453
+ chartType = [int]$chart.ChartType
454
+ title = $(if ($chart.HasTitle) { [string]$chart.ChartTitle.Text } else { '' })
455
+ left = [double]$chartObject.Left
456
+ top = [double]$chartObject.Top
457
+ width = [double]$chartObject.Width
458
+ height = [double]$chartObject.Height
459
+ series = $series
460
+ }
461
+ }
462
+ $pivots = @()
463
+ for ($pivotIndex = 1; $pivotIndex -le $sheet.PivotTables().Count; $pivotIndex++) {
464
+ $pivot = $sheet.PivotTables().Item($pivotIndex)
465
+ $pivots += [ordered]@{
466
+ path = "/sheet[$([string]$sheet.Name)]/pivot[$pivotIndex]"
467
+ index = $pivotIndex
468
+ name = [string]$pivot.Name
469
+ range = [string]$pivot.TableRange2.Address($false, $false)
470
+ }
471
+ }
472
+ $validations = @()
473
+ try {
474
+ $validationCells = $sheet.Cells.SpecialCells(-4174)
475
+ for ($validationIndex = 1; $validationIndex -le $validationCells.Areas.Count; $validationIndex++) {
476
+ $area = $validationCells.Areas.Item($validationIndex)
477
+ $validation = $area.Cells.Item(1, 1).Validation
478
+ $validations += [ordered]@{
479
+ path = "/sheet[$([string]$sheet.Name)]/validation[$validationIndex]"
480
+ index = $validationIndex
481
+ ranges = @([string]$area.Address($false, $false))
482
+ type = [int]$validation.Type
483
+ alertStyle = [int]$validation.AlertStyle
484
+ operator = [int]$validation.Operator
485
+ allowBlank = [bool]$validation.IgnoreBlank
486
+ inCellDropdown = [bool]$validation.InCellDropdown
487
+ showInputMessage = [bool]$validation.ShowInput
488
+ showErrorMessage = [bool]$validation.ShowError
489
+ inputTitle = [string]$validation.InputTitle
490
+ inputMessage = [string]$validation.InputMessage
491
+ errorTitle = [string]$validation.ErrorTitle
492
+ errorMessage = [string]$validation.ErrorMessage
493
+ formula1 = $(try { [string]$validation.Formula1 } catch { '' })
494
+ formula2 = $(try { [string]$validation.Formula2 } catch { '' })
495
+ }
496
+ }
497
+ } catch {}
498
+ $conditionalFormats = @()
499
+ try {
500
+ $conditionRange = $used
501
+ if ($payload.range -and [string]::Equals([string]$payload.sheet, [string]$sheet.Name, [System.StringComparison]::OrdinalIgnoreCase)) {
502
+ $conditionRange = $sheet.Range([string]$payload.range)
503
+ }
504
+ for ($conditionIndex = 1; $conditionIndex -le $conditionRange.FormatConditions.Count; $conditionIndex++) {
505
+ $condition = $conditionRange.FormatConditions.Item($conditionIndex)
506
+ $conditionalFormats += [ordered]@{
507
+ path = "/sheet[$([string]$sheet.Name)]/conditional-format[$conditionIndex]"
508
+ index = $conditionIndex
509
+ range = $(try { [string]$condition.AppliesTo.Address($false, $false) } catch { [string]$conditionRange.Address($false, $false) })
510
+ type = $(try { [int]$condition.Type } catch { 0 })
511
+ operator = $(try { [int]$condition.Operator } catch { 0 })
512
+ formula1 = $(try { [string]$condition.Formula1 } catch { '' })
513
+ formula2 = $(try { [string]$condition.Formula2 } catch { '' })
514
+ priority = $(try { [int]$condition.Priority } catch { 0 })
515
+ }
516
+ }
517
+ } catch {}
518
+ $notes = @()
519
+ try {
520
+ $commentCells = $sheet.Cells.SpecialCells(-4144)
521
+ foreach ($cell in @($commentCells.Cells)) {
522
+ $notes += [ordered]@{
523
+ path = "/sheet[$([string]$sheet.Name)]/cell[$([string]$cell.Address($false, $false))]/note"
524
+ cell = [string]$cell.Address($false, $false)
525
+ text = [string]$cell.Comment.Text()
526
+ author = [string]$cell.Comment.Author
527
+ }
528
+ }
529
+ } catch {}
530
+ $mergedRanges = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
531
+ $mergeScanCount = 0
532
+ for ($mergeRow = 1; $mergeRow -le $used.Rows.Count -and $mergeScanCount -lt 2000; $mergeRow++) {
533
+ for ($mergeColumn = 1; $mergeColumn -le $used.Columns.Count -and $mergeScanCount -lt 2000; $mergeColumn++) {
534
+ $mergeScanCount++
535
+ try {
536
+ $cell = $used.Cells.Item($mergeRow, $mergeColumn)
537
+ if ($cell.MergeCells) { $null = $mergedRanges.Add([string]$cell.MergeArea.Address($false, $false)) }
538
+ } catch {}
539
+ }
540
+ }
541
+ $freezePanes = $null
542
+ if ([string]::Equals([string]$book.ActiveSheet.Name, [string]$sheet.Name, [System.StringComparison]::OrdinalIgnoreCase)) {
543
+ try {
544
+ $window = $book.Windows.Item(1)
545
+ $freezePanes = [ordered]@{
546
+ frozen = [bool]$window.FreezePanes
547
+ splitRow = [int]$window.SplitRow
548
+ splitColumn = [int]$window.SplitColumn
549
+ }
550
+ } catch {}
551
+ }
552
+ $autoFilter = $null
553
+ try {
554
+ if ($sheet.AutoFilterMode -and $null -ne $sheet.AutoFilter) {
555
+ $autoFilter = [ordered]@{ enabled = $true; range = [string]$sheet.AutoFilter.Range.Address($false, $false) }
556
+ }
557
+ } catch {}
558
+ $protection = [ordered]@{
559
+ contents = [bool]$sheet.ProtectContents
560
+ drawingObjects = [bool]$sheet.ProtectDrawingObjects
561
+ scenarios = [bool]$sheet.ProtectScenarios
562
+ }
563
+ $entry.tables = $tables
564
+ $entry.charts = $charts
565
+ $entry.pivots = $pivots
566
+ $entry.validationCount = $validations.Count
567
+ $entry.validations = $validations
568
+ $entry.conditionalFormatCount = $conditionalFormats.Count
569
+ $entry.conditionalFormats = $conditionalFormats
570
+ $entry.noteCount = $notes.Count
571
+ $entry.notes = $notes
572
+ $entry.mergedRanges = @($mergedRanges) | Sort-Object
573
+ $entry.freezePanes = $freezePanes
574
+ $entry.autoFilter = $autoFilter
575
+ $entry.protection = $protection
576
+ $cellCount = [int]$used.Rows.Count * [int]$used.Columns.Count
577
+ if ($cellCount -le 500) {
578
+ $cells = @()
579
+ for ($r = 1; $r -le $used.Rows.Count; $r++) {
580
+ for ($c = 1; $c -le $used.Columns.Count; $c++) {
581
+ $cell = $used.Cells.Item($r, $c)
582
+ if ($null -ne $cell.Value2 -or $cell.HasFormula) {
583
+ $address = ([string]$cell.Address($false, $false)).Replace('$', '')
584
+ $cells += [ordered]@{
585
+ path = "/sheet[$([string]$sheet.Name)]/cell[$address]"
586
+ ref = $address
587
+ value = $cell.Value2
588
+ formula = $(if ($cell.HasFormula) { [string]$cell.Formula } else { $null })
589
+ style = [ordered]@{
590
+ fontName = [string]$cell.Font.Name
591
+ fontSize = [double]$cell.Font.Size
592
+ bold = [bool]$cell.Font.Bold
593
+ italic = [bool]$cell.Font.Italic
594
+ color = [double]$cell.Font.Color
595
+ fillColor = [double]$cell.Interior.Color
596
+ numberFormat = [string]$cell.NumberFormat
597
+ }
598
+ }
599
+ }
600
+ }
601
+ }
602
+ $entry.cells = $cells
603
+ $entry.formulaLineage = @($cells | Where-Object { $_.formula } | ForEach-Object {
604
+ [ordered]@{
605
+ path = "$($_.path)/lineage"
606
+ from = $_.path
607
+ formula = $_.formula
608
+ precedents = @(Excel-FormulaPrecedents ([string]$_.formula) ([string]$sheet.Name))
609
+ }
610
+ })
611
+ $entry.lineageCount = $entry.formulaLineage.Count
612
+ }
613
+ if ($payload.sheet -and [string]::Equals([string]$payload.sheet, [string]$sheet.Name, [System.StringComparison]::OrdinalIgnoreCase)) {
614
+ $range = $used
615
+ if ($payload.range) { $range = $sheet.Range([string]$payload.range) }
616
+ if (($range.Rows.Count * $range.Columns.Count) -le 2000) {
617
+ $entry.values = Cell-Value $range
618
+ $entry.address = [string]$range.Address()
619
+ }
620
+ }
621
+ $sheets += $entry
622
+ }
623
+ $definedNames = @()
624
+ for ($nameIndex = 1; $nameIndex -le $book.Names.Count; $nameIndex++) {
625
+ try {
626
+ $name = $book.Names.Item($nameIndex)
627
+ $definedNames += [ordered]@{
628
+ path = "/defined-name[$nameIndex]"
629
+ index = $nameIndex
630
+ name = [string]$name.Name
631
+ nameLocal = [string]$name.NameLocal
632
+ refersTo = [string]$name.RefersTo
633
+ refersToLocal = [string]$name.RefersToLocal
634
+ visible = [bool]$name.Visible
635
+ }
636
+ } catch {}
637
+ }
638
+ return [ordered]@{
639
+ format = 'xlsx'
640
+ path = [string]$book.FullName
641
+ activeSheet = [string]$book.ActiveSheet.Name
642
+ definedNameCount = $definedNames.Count
643
+ definedNames = $definedNames
644
+ sheets = $sheets
645
+ }
646
+ }
647
+
648
+ function Excel-ColumnLabel([int]$column) {
649
+ $label = ''
650
+ while ($column -gt 0) {
651
+ $column--
652
+ $label = [char](65 + ($column % 26)) + $label
653
+ $column = [Math]::Floor($column / 26)
654
+ }
655
+ return $label
656
+ }
657
+
658
+ function Matrix-Item($matrix, [int]$row, [int]$column) {
659
+ if ($matrix -is [System.Array]) {
660
+ if ($matrix.Rank -eq 2) {
661
+ return $matrix.GetValue(
662
+ $matrix.GetLowerBound(0) + $row - 1,
663
+ $matrix.GetLowerBound(1) + $column - 1
664
+ )
665
+ }
666
+ return $matrix.GetValue($matrix.GetLowerBound(0) + $row - 1)
667
+ }
668
+ if ($row -eq 1 -and $column -eq 1) { return $matrix }
669
+ return $null
670
+ }
671
+
672
+ function Snapshot-ExcelPage($book, $payload) {
673
+ $sheet = $book.ActiveSheet
674
+ if ($payload.sheet) { $sheet = $book.Worksheets.Item([string]$payload.sheet) }
675
+ $base = $sheet.UsedRange
676
+ if ($payload.range) { $base = $sheet.Range([string]$payload.range) }
677
+ $rowCount = [int]$base.Rows.Count
678
+ $columnCount = [int]$base.Columns.Count
679
+ $total = [int64]$rowCount * [int64]$columnCount
680
+ $offset = [Math]::Max(0, [int64]$payload.offset)
681
+ $limit = [Math]::Min(10000, [Math]::Max(1, [int]$payload.limit))
682
+ if ($offset -gt $total) { $offset = $total }
683
+ $remaining = [Math]::Min([int64]$limit, $total - $offset)
684
+ $cursor = $offset
685
+ $detailed = [bool]$payload.includeStyles -or $total -le 500
686
+ $cells = [System.Collections.Generic.List[object]]::new()
687
+ $rowBlocks = [System.Collections.Generic.List[object]]::new()
688
+ $formulaEntries = [System.Collections.Generic.List[object]]::new()
689
+ $populated = 0
690
+ while ($remaining -gt 0) {
691
+ $rowOffset = [Math]::Floor($cursor / $columnCount)
692
+ $columnOffset = [int]($cursor % $columnCount)
693
+ if ($columnOffset -eq 0 -and $remaining -ge $columnCount) {
694
+ $takeRows = [Math]::Floor($remaining / $columnCount)
695
+ $take = [int64]$takeRows * $columnCount
696
+ $range = $sheet.Range(
697
+ $base.Cells.Item($rowOffset + 1, 1),
698
+ $base.Cells.Item($rowOffset + $takeRows, $columnCount)
699
+ )
700
+ } else {
701
+ $take = [Math]::Min($remaining, $columnCount - $columnOffset)
702
+ $takeRows = 1
703
+ $range = $sheet.Range(
704
+ $base.Cells.Item($rowOffset + 1, $columnOffset + 1),
705
+ $base.Cells.Item($rowOffset + 1, $columnOffset + $take)
706
+ )
707
+ }
708
+ $values = $range.Value2
709
+ $formulas = $range.Formula
710
+ $rangeRows = [int]$range.Rows.Count
711
+ $rangeColumns = [int]$range.Columns.Count
712
+ $rangeStartRow = [int]$range.Row
713
+ $rangeStartColumn = [int]$range.Column
714
+ $blockValues = $null
715
+ $blockFormulas = $null
716
+ if (-not $detailed) {
717
+ $blockValues = [object[]]::new($rangeRows)
718
+ $blockFormulas = [System.Collections.Generic.List[object]]::new()
719
+ }
720
+ for ($r = 1; $r -le $rangeRows; $r++) {
721
+ $line = $null
722
+ if (-not $detailed) { $line = [object[]]::new($rangeColumns) }
723
+ for ($c = 1; $c -le $rangeColumns; $c++) {
724
+ $value = Matrix-Item $values $r $c
725
+ $formulaValue = Matrix-Item $formulas $r $c
726
+ $formula = if ([string]$formulaValue -like '=*') { [string]$formulaValue } else { $null }
727
+ if (-not $detailed) { $line[$c - 1] = $value }
728
+ if ($null -eq $value -and $null -eq $formula) { continue }
729
+ $populated++
730
+ $absoluteRow = $null
731
+ $absoluteColumn = $null
732
+ $address = $null
733
+ if ($detailed -or $formula) {
734
+ $absoluteRow = $rangeStartRow + $r - 1
735
+ $absoluteColumn = $rangeStartColumn + $c - 1
736
+ $address = "$(Excel-ColumnLabel $absoluteColumn)$absoluteRow"
737
+ }
738
+ if ($formula) {
739
+ $formulaEntry = [ordered]@{
740
+ path = "/sheet[$([string]$sheet.Name)]/cell[$address]"
741
+ ref = $address
742
+ row = $absoluteRow
743
+ column = $absoluteColumn
744
+ formula = $formula
745
+ }
746
+ $formulaEntries.Add($formulaEntry)
747
+ if (-not $detailed) {
748
+ $blockFormulas.Add([ordered]@{
749
+ rowOffset = $r - 1
750
+ columnOffset = $c - 1
751
+ formula = $formula
752
+ })
753
+ }
754
+ }
755
+ if ($detailed) {
756
+ $entry = [ordered]@{
757
+ path = "/sheet[$([string]$sheet.Name)]/cell[$address]"
758
+ ref = $address
759
+ value = $value
760
+ formula = $formula
761
+ }
762
+ }
763
+ if ($detailed -and $payload.includeStyles) {
764
+ $cell = $range.Cells.Item($r, $c)
765
+ $entry.style = [ordered]@{
766
+ fontName = [string]$cell.Font.Name
767
+ fontSize = [double]$cell.Font.Size
768
+ bold = [bool]$cell.Font.Bold
769
+ italic = [bool]$cell.Font.Italic
770
+ color = [double]$cell.Font.Color
771
+ fillColor = [double]$cell.Interior.Color
772
+ numberFormat = [string]$cell.NumberFormat
773
+ }
774
+ }
775
+ if ($detailed) { $cells.Add($entry) }
776
+ }
777
+ if (-not $detailed) { $blockValues[$r - 1] = $line }
778
+ }
779
+ if (-not $detailed) {
780
+ [object[]]$blockFormulaOutput = @($blockFormulas)
781
+ $rowBlocks.Add([ordered]@{
782
+ startRow = $rangeStartRow
783
+ startColumn = $rangeStartColumn
784
+ rowCount = $rangeRows
785
+ columnCount = $rangeColumns
786
+ values = $blockValues
787
+ formulas = $blockFormulaOutput
788
+ })
789
+ }
790
+ $cursor += $take
791
+ $remaining -= $take
792
+ }
793
+ $lineage = @($formulaEntries | ForEach-Object {
794
+ [ordered]@{
795
+ path = "$($_.path)/lineage"
796
+ from = $_.path
797
+ formula = $_.formula
798
+ precedents = @(Excel-FormulaPrecedents ([string]$_.formula) ([string]$sheet.Name))
799
+ }
800
+ })
801
+ [object[]]$cellOutput = @()
802
+ [object[]]$rowBlockOutput = @()
803
+ if ($detailed) { $cellOutput = @($cells) } else { $rowBlockOutput = @($rowBlocks) }
804
+ return [ordered]@{
805
+ format = 'xlsx'
806
+ path = [string]$book.FullName
807
+ activeSheet = [string]$book.ActiveSheet.Name
808
+ sheetCount = [int]$book.Worksheets.Count
809
+ sheets = @([ordered]@{
810
+ path = "/sheet[$([string]$sheet.Name)]"
811
+ name = [string]$sheet.Name
812
+ rows = $rowCount
813
+ columns = $columnCount
814
+ address = [string]$base.Address($false, $false)
815
+ cellCount = $total
816
+ representation = $(if ($detailed) { 'cells' } else { 'row-blocks' })
817
+ cells = $cellOutput
818
+ rowBlocks = $rowBlockOutput
819
+ lineageCount = $lineage.Count
820
+ formulaLineage = $lineage
821
+ truncated = $cursor -lt $total
822
+ })
823
+ pagination = [ordered]@{
824
+ unit = 'cell'
825
+ scope = "$([string]$sheet.Name)!$([string]$base.Address($false, $false))"
826
+ offset = $offset
827
+ limit = $limit
828
+ returned = $populated
829
+ scanned = $cursor - $offset
830
+ total = $total
831
+ nextOffset = $(if ($cursor -lt $total) { $cursor } else { $null })
832
+ }
833
+ }
834
+ }
835
+
836
+ function Snapshot-PowerPoint($presentation, $payload) {
837
+ $slides = @()
838
+ $slideOffset = if ($payload.paged) { [Math]::Max(0, [int]$payload.offset) } else { 0 }
839
+ $slideLimit = if ($payload.paged) { [Math]::Max(1, [int]$payload.limit) } else { [int]$presentation.Slides.Count }
840
+ $slideNumbers = if ($payload.pages) {
841
+ @($payload.pages | ForEach-Object { [int]$_ })
842
+ } else {
843
+ $end = [Math]::Min([int]$presentation.Slides.Count, $slideOffset + $slideLimit)
844
+ if ($end -le $slideOffset) { @() } else { @(($slideOffset + 1)..$end) }
845
+ }
846
+ foreach ($slideNumber in $slideNumbers) {
847
+ if ($slideNumber -lt 1 -or $slideNumber -gt $presentation.Slides.Count) { throw "PowerPoint slide out of range: $slideNumber" }
848
+ $slide = $presentation.Slides.Item($slideNumber)
849
+ $shapes = @()
850
+ for ($shapeIndex = 1; $shapeIndex -le $slide.Shapes.Count; $shapeIndex++) {
851
+ $shape = $slide.Shapes.Item($shapeIndex)
852
+ $text = $null
853
+ try {
854
+ if ($shape.HasTextFrame -and $shape.TextFrame.HasText) { $text = [string]$shape.TextFrame.TextRange.Text }
855
+ } catch {}
856
+ $placeholder = $null
857
+ try {
858
+ if ([int]$shape.Type -eq 14) {
859
+ $placeholder = [ordered]@{
860
+ type = [int]$shape.PlaceholderFormat.Type
861
+ index = [int]$shape.PlaceholderFormat.Index
862
+ }
863
+ }
864
+ } catch {}
865
+ $group = $null
866
+ try {
867
+ if ([int]$shape.Type -eq 6) {
868
+ $items = @()
869
+ for ($groupIndex = 1; $groupIndex -le $shape.GroupItems.Count; $groupIndex++) {
870
+ $item = $shape.GroupItems.Item($groupIndex)
871
+ $items += [ordered]@{ index = $groupIndex; name = [string]$item.Name; type = [int]$item.Type }
872
+ }
873
+ $group = [ordered]@{ count = $items.Count; items = $items }
874
+ }
875
+ } catch {}
876
+ $crop = $null
877
+ try {
878
+ if ([int]$shape.Type -eq 13) {
879
+ $crop = [ordered]@{
880
+ left = [double]$shape.PictureFormat.CropLeft
881
+ right = [double]$shape.PictureFormat.CropRight
882
+ top = [double]$shape.PictureFormat.CropTop
883
+ bottom = [double]$shape.PictureFormat.CropBottom
884
+ }
885
+ }
886
+ } catch {}
887
+ $table = $null
888
+ try {
889
+ if ($shape.HasTable) {
890
+ $rows = @()
891
+ for ($rowIndex = 1; $rowIndex -le $shape.Table.Rows.Count; $rowIndex++) {
892
+ $cells = @()
893
+ for ($columnIndex = 1; $columnIndex -le $shape.Table.Columns.Count; $columnIndex++) {
894
+ $cells += [string]$shape.Table.Cell($rowIndex, $columnIndex).Shape.TextFrame.TextRange.Text
895
+ }
896
+ $rows += ,$cells
897
+ }
898
+ $table = [ordered]@{
899
+ rows = [int]$shape.Table.Rows.Count
900
+ columns = [int]$shape.Table.Columns.Count
901
+ values = $rows
902
+ }
903
+ }
904
+ } catch {}
905
+ $shapes += [ordered]@{
906
+ path = "/slide[$([int]$slide.SlideIndex)]/shape[$shapeIndex]"
907
+ index = $shapeIndex
908
+ name = [string]$shape.Name
909
+ type = [int]$shape.Type
910
+ text = $text
911
+ placeholder = $placeholder
912
+ group = $group
913
+ crop = $crop
914
+ table = $table
915
+ left = [double]$shape.Left
916
+ top = [double]$shape.Top
917
+ width = [double]$shape.Width
918
+ height = [double]$shape.Height
919
+ rotation = [double]$shape.Rotation
920
+ fillColor = $(try { [double]$shape.Fill.ForeColor.RGB } catch { $null })
921
+ fillTransparency = $(try { [double]$shape.Fill.Transparency } catch { $null })
922
+ lineColor = $(try { [double]$shape.Line.ForeColor.RGB } catch { $null })
923
+ lineTransparency = $(try { [double]$shape.Line.Transparency } catch { $null })
924
+ shadow = $(try {
925
+ [ordered]@{
926
+ visible = [int]$shape.Shadow.Visible
927
+ color = [double]$shape.Shadow.ForeColor.RGB
928
+ transparency = [double]$shape.Shadow.Transparency
929
+ blur = [double]$shape.Shadow.Blur
930
+ offsetX = [double]$shape.Shadow.OffsetX
931
+ offsetY = [double]$shape.Shadow.OffsetY
932
+ }
933
+ } catch { $null })
934
+ textFrame = $(try {
935
+ [ordered]@{
936
+ marginLeft = [double]$shape.TextFrame.MarginLeft
937
+ marginTop = [double]$shape.TextFrame.MarginTop
938
+ marginRight = [double]$shape.TextFrame.MarginRight
939
+ marginBottom = [double]$shape.TextFrame.MarginBottom
940
+ paragraphSpacing = [double]$shape.TextFrame.TextRange.ParagraphFormat.SpaceAfter
941
+ }
942
+ } catch { $null })
943
+ font = $(try {
944
+ [ordered]@{
945
+ name = [string]$shape.TextFrame.TextRange.Font.Name
946
+ size = [double]$shape.TextFrame.TextRange.Font.Size
947
+ bold = [int]$shape.TextFrame.TextRange.Font.Bold
948
+ italic = [int]$shape.TextFrame.TextRange.Font.Italic
949
+ color = [double]$shape.TextFrame.TextRange.Font.Color.RGB
950
+ }
951
+ } catch { $null })
952
+ chart = $(try {
953
+ if ($shape.HasChart) {
954
+ $chart = $shape.Chart
955
+ [ordered]@{
956
+ path = "/slide[$([int]$slide.SlideIndex)]/shape[$shapeIndex]/chart"
957
+ chartType = [int]$chart.ChartType
958
+ title = $(if ($chart.HasTitle) { [string]$chart.ChartTitle.Text } else { '' })
959
+ seriesCount = [int]$chart.SeriesCollection().Count
960
+ series = $(try {
961
+ $items = @()
962
+ for ($seriesIndex = 1; $seriesIndex -le $chart.SeriesCollection().Count; $seriesIndex++) {
963
+ $series = $chart.SeriesCollection().Item($seriesIndex)
964
+ $items += [ordered]@{
965
+ index = $seriesIndex
966
+ name = [string]$series.Name
967
+ formula = ''
968
+ chartType = $(try { [int]$series.ChartType } catch { $null })
969
+ axisGroup = $(try { [int]$series.AxisGroup } catch { $null })
970
+ trendlineCount = $(try { [int]$series.Trendlines().Count } catch { 0 })
971
+ hasErrorBars = $(try { [bool]$series.HasErrorBars } catch { $false })
972
+ hasDataLabels = [bool]$series.HasDataLabels
973
+ dataLabels = $(if ($series.HasDataLabels) {
974
+ [ordered]@{
975
+ showValue = [bool]$series.DataLabels().ShowValue
976
+ showCategoryName = [bool]$series.DataLabels().ShowCategoryName
977
+ numberFormat = [string]$series.DataLabels().NumberFormat
978
+ position = [int]$series.DataLabels().Position
979
+ }
980
+ } else { $null })
981
+ }
982
+ }
983
+ ,$items
984
+ } catch { ,@() })
985
+ axes = $(try {
986
+ $axes = @()
987
+ foreach ($axisSpec in @(
988
+ [ordered]@{ name = 'category'; type = 1 },
989
+ [ordered]@{ name = 'value'; type = 2 }
990
+ )) {
991
+ $axis = $chart.Axes([int]$axisSpec.type, 1)
992
+ $axes += [ordered]@{
993
+ type = [string]$axisSpec.name
994
+ title = $(if ($axis.HasTitle) { [string]$axis.AxisTitle.Text } else { '' })
995
+ minimum = $(try { [double]$axis.MinimumScale } catch { $null })
996
+ maximum = $(try { [double]$axis.MaximumScale } catch { $null })
997
+ majorUnit = $(try { [double]$axis.MajorUnit } catch { $null })
998
+ numberFormat = $(try { [string]$axis.TickLabels.NumberFormat } catch { '' })
999
+ }
1000
+ }
1001
+ $axes
1002
+ } catch { @() })
1003
+ }
1004
+ } else { $null }
1005
+ } catch { $null })
1006
+ }
1007
+ }
1008
+ $notes = $null
1009
+ try { $notes = [string]$slide.NotesPage.Shapes.Placeholders.Item(2).TextFrame.TextRange.Text } catch {}
1010
+ $comments = @()
1011
+ try {
1012
+ for ($commentIndex = 1; $commentIndex -le $slide.Comments.Count; $commentIndex++) {
1013
+ $comment = $slide.Comments.Item($commentIndex)
1014
+ $comments += [ordered]@{
1015
+ path = "/slide[$([int]$slide.SlideIndex)]/comment[$commentIndex]"
1016
+ index = $commentIndex
1017
+ author = [string]$comment.Author
1018
+ initials = [string]$comment.AuthorInitials
1019
+ text = [string]$comment.Text
1020
+ left = [double]$comment.Left
1021
+ top = [double]$comment.Top
1022
+ }
1023
+ }
1024
+ } catch {}
1025
+ $animations = @()
1026
+ try {
1027
+ $sequence = $slide.TimeLine.MainSequence
1028
+ for ($effectIndex = 1; $effectIndex -le $sequence.Count; $effectIndex++) {
1029
+ $effect = $sequence.Item($effectIndex)
1030
+ $animations += [ordered]@{
1031
+ index = $effectIndex
1032
+ shape = [string]$effect.Shape.Name
1033
+ effect = [int]$effect.EffectType
1034
+ trigger = [int]$effect.Timing.TriggerType
1035
+ duration = [double]$effect.Timing.Duration
1036
+ delay = [double]$effect.Timing.TriggerDelayTime
1037
+ }
1038
+ }
1039
+ } catch {}
1040
+ $layoutName = $(try { [string]$slide.CustomLayout.Name } catch { '' })
1041
+ $layoutIndex = 0
1042
+ try {
1043
+ for ($candidateIndex = 1; $candidateIndex -le $presentation.SlideMaster.CustomLayouts.Count; $candidateIndex++) {
1044
+ if ([string]::Equals([string]$presentation.SlideMaster.CustomLayouts.Item($candidateIndex).Name, $layoutName, [System.StringComparison]::OrdinalIgnoreCase)) {
1045
+ $layoutIndex = $candidateIndex
1046
+ break
1047
+ }
1048
+ }
1049
+ } catch {}
1050
+ $followMasterBackground = $(try { [bool]$slide.FollowMasterBackground } catch { $false })
1051
+ $backgroundSource = $(if ($followMasterBackground) { 'layout' } else { 'slide' })
1052
+ $backgroundColor = $null
1053
+ try {
1054
+ if ($followMasterBackground) {
1055
+ try {
1056
+ $backgroundColor = Color-Hex ([long]$slide.CustomLayout.Background.Fill.ForeColor.RGB)
1057
+ } catch {}
1058
+ if (-not $backgroundColor) {
1059
+ $backgroundSource = 'master'
1060
+ try { $backgroundColor = Color-Hex ([long]$presentation.SlideMaster.Background.Fill.ForeColor.RGB) } catch {}
1061
+ }
1062
+ } else {
1063
+ $backgroundColor = Color-Hex ([long]$slide.Background.Fill.ForeColor.RGB)
1064
+ }
1065
+ } catch {}
1066
+ $slides += [ordered]@{
1067
+ path = "/slide[$([int]$slide.SlideIndex)]"
1068
+ index = [int]$slide.SlideIndex
1069
+ layout = [ordered]@{ index = $layoutIndex; name = $layoutName }
1070
+ background = [ordered]@{
1071
+ followMaster = $followMasterBackground
1072
+ source = $backgroundSource
1073
+ color = $backgroundColor
1074
+ }
1075
+ shapes = $shapes
1076
+ notes = $notes
1077
+ comments = $comments
1078
+ animations = $animations
1079
+ transition = [ordered]@{
1080
+ effect = $(try { [int]$slide.SlideShowTransition.EntryEffect } catch { 0 })
1081
+ advanceOnTime = $(try { [bool]$slide.SlideShowTransition.AdvanceOnTime } catch { $false })
1082
+ advanceTime = $(try { [double]$slide.SlideShowTransition.AdvanceTime } catch { 0 })
1083
+ }
1084
+ }
1085
+ }
1086
+ $layouts = @()
1087
+ try {
1088
+ for ($layoutIndex = 1; $layoutIndex -le $presentation.SlideMaster.CustomLayouts.Count; $layoutIndex++) {
1089
+ $layout = $presentation.SlideMaster.CustomLayouts.Item($layoutIndex)
1090
+ $layouts += [ordered]@{
1091
+ path = "/layout[$layoutIndex]"
1092
+ index = $layoutIndex
1093
+ name = [string]$layout.Name
1094
+ }
1095
+ }
1096
+ } catch {}
1097
+ $designs = @()
1098
+ try {
1099
+ for ($designIndex = 1; $designIndex -le $presentation.Designs.Count; $designIndex++) {
1100
+ $design = $presentation.Designs.Item($designIndex)
1101
+ $designs += [ordered]@{
1102
+ index = $designIndex
1103
+ name = [string]$design.Name
1104
+ }
1105
+ }
1106
+ } catch {}
1107
+ return [ordered]@{
1108
+ format = 'pptx'
1109
+ path = [string]$presentation.FullName
1110
+ slideCount = $presentation.Slides.Count
1111
+ slideWidth = [double]$presentation.PageSetup.SlideWidth
1112
+ slideHeight = [double]$presentation.PageSetup.SlideHeight
1113
+ layoutCount = $layouts.Count
1114
+ layouts = $layouts
1115
+ designCount = $designs.Count
1116
+ designs = $designs
1117
+ slides = $slides
1118
+ theme = $(try { [string]$presentation.Designs.Item(1).Name } catch { '' })
1119
+ pagination = $(if ($payload.paged) {
1120
+ [ordered]@{
1121
+ unit = 'slide'
1122
+ offset = $slideOffset
1123
+ limit = $slideLimit
1124
+ returned = $slides.Count
1125
+ total = [int]$presentation.Slides.Count
1126
+ nextOffset = $(if (-not $payload.pages -and $slideOffset + $slides.Count -lt [int]$presentation.Slides.Count) { $slideOffset + $slides.Count } else { $null })
1127
+ }
1128
+ } else { $null })
1129
+ }
1130
+ }
1131
+
1132
+ function Same-OfficePath([string]$left, [string]$right) {
1133
+ try {
1134
+ return [string]::Equals(
1135
+ [System.IO.Path]::GetFullPath($left),
1136
+ [System.IO.Path]::GetFullPath($right),
1137
+ [System.StringComparison]::OrdinalIgnoreCase
1138
+ )
1139
+ } catch {
1140
+ return $false
1141
+ }
1142
+ }
1143
+
1144
+ function Snapshot-Selection($document, [string]$format) {
1145
+ try {
1146
+ switch ($format) {
1147
+ 'docx' {
1148
+ $selection = $document.Application.Selection
1149
+ if ($null -eq $selection -or -not (Same-OfficePath ([string]$selection.Document.FullName) ([string]$document.FullName))) {
1150
+ return [ordered]@{ available = $false; reason = 'The active Word selection belongs to another document.' }
1151
+ }
1152
+ $range = $selection.Range
1153
+ $paragraph = [Math]::Max(1, [int]$document.Range(0, [Math]::Max(0, [int]$range.Start)).Paragraphs.Count)
1154
+ $target = "/body/p[$paragraph]"
1155
+ return [ordered]@{
1156
+ available = $true
1157
+ kind = $(if ([int]$range.Start -eq [int]$range.End) { 'insertion-point' } else { 'text' })
1158
+ target = $target
1159
+ key = "$target`:$([int]$range.Start)-$([int]$range.End)"
1160
+ start = [int]$range.Start
1161
+ end = [int]$range.End
1162
+ text = $([string]$range.Text).TrimEnd("`r", "`a")
1163
+ }
1164
+ }
1165
+ 'xlsx' {
1166
+ $selection = $document.Application.Selection
1167
+ $sheet = $selection.Parent
1168
+ if ($null -eq $sheet -or -not (Same-OfficePath ([string]$sheet.Parent.FullName) ([string]$document.FullName))) {
1169
+ return [ordered]@{ available = $false; reason = 'The active Excel selection belongs to another workbook.' }
1170
+ }
1171
+ $address = [string]$selection.Address($false, $false)
1172
+ $activeCell = [string]$document.Application.ActiveCell.Address($false, $false)
1173
+ $target = "/sheet[$([string]$sheet.Name)]/range[$address]"
1174
+ return [ordered]@{
1175
+ available = $true
1176
+ kind = 'range'
1177
+ target = $target
1178
+ key = "$([string]$sheet.Name)!$address@$activeCell"
1179
+ sheet = [string]$sheet.Name
1180
+ address = $address
1181
+ activeCell = $activeCell
1182
+ rows = [int]$selection.Rows.Count
1183
+ columns = [int]$selection.Columns.Count
1184
+ }
1185
+ }
1186
+ 'pptx' {
1187
+ $window = $document.Application.ActiveWindow
1188
+ if ($null -eq $window) { return [ordered]@{ available = $false; reason = 'PowerPoint has no active window.' } }
1189
+ $selection = $window.Selection
1190
+ $slideIndex = $(try { [int]$window.View.Slide.SlideIndex } catch { 0 })
1191
+ $selectionType = [int]$selection.Type
1192
+ $paths = @()
1193
+ if (@(2, 3) -contains $selectionType -and $slideIndex -gt 0) {
1194
+ $slide = $document.Slides.Item($slideIndex)
1195
+ foreach ($selectedShape in @($selection.ShapeRange)) {
1196
+ for ($shapeIndex = 1; $shapeIndex -le $slide.Shapes.Count; $shapeIndex++) {
1197
+ if ([int]$slide.Shapes.Item($shapeIndex).Id -eq [int]$selectedShape.Id) {
1198
+ $paths += "/slide[$slideIndex]/shape[$shapeIndex]"
1199
+ break
1200
+ }
1201
+ }
1202
+ }
1203
+ } elseif ($selectionType -eq 1) {
1204
+ foreach ($selectedSlide in @($selection.SlideRange)) { $paths += "/slide[$([int]$selectedSlide.SlideIndex)]" }
1205
+ } elseif ($slideIndex -gt 0) {
1206
+ $paths += "/slide[$slideIndex]"
1207
+ }
1208
+ return [ordered]@{
1209
+ available = $true
1210
+ kind = $(switch ($selectionType) { 1 { 'slides' } 2 { 'shapes' } 3 { 'text' } default { 'none' } })
1211
+ target = $(if ($paths.Count -gt 0) { [string]$paths[0] } else { '/' })
1212
+ targets = $paths
1213
+ key = "$selectionType`:$($paths -join ',')"
1214
+ slide = $slideIndex
1215
+ selectionType = $selectionType
1216
+ }
1217
+ }
1218
+ }
1219
+ } catch {
1220
+ return [ordered]@{ available = $false; reason = [string]$_.Exception.Message }
1221
+ }
1222
+ return [ordered]@{ available = $false; reason = 'Selection is unsupported for this format.' }
1223
+ }
1224
+
1225
+ function Snapshot-Document($document, [string]$format, $payload) {
1226
+ $value = switch ($format) {
1227
+ 'docx' { Snapshot-Word $document $payload }
1228
+ 'xlsx' {
1229
+ if ($payload.paged) {
1230
+ $sheet = $document.ActiveSheet
1231
+ if ($payload.sheet) { $sheet = $document.Worksheets.Item([string]$payload.sheet) }
1232
+ $range = $sheet.UsedRange
1233
+ if ($payload.range) { $range = $sheet.Range([string]$payload.range) }
1234
+ if ($payload.range -or [int64]$range.Rows.Count * [int64]$range.Columns.Count -gt 500 -or [int64]$payload.offset -gt 0) {
1235
+ Snapshot-ExcelPage $document $payload
1236
+ break
1237
+ }
1238
+ }
1239
+ Snapshot-Excel $document $payload
1240
+ }
1241
+ 'pptx' { Snapshot-PowerPoint $document $payload }
1242
+ }
1243
+ if ([bool]$payload.includeSelection) { $value['selection'] = Snapshot-Selection $document $format }
1244
+ return $value
1245
+ }
1246
+
1247
+ function Color-Value([string]$hex) {
1248
+ $clean = $hex.TrimStart('#')
1249
+ if ($clean.Length -ne 6) { throw "Invalid color '$hex'; expected RRGGBB" }
1250
+ $r = [Convert]::ToInt32($clean.Substring(0, 2), 16)
1251
+ $g = [Convert]::ToInt32($clean.Substring(2, 2), 16)
1252
+ $b = [Convert]::ToInt32($clean.Substring(4, 2), 16)
1253
+ return $r + (256 * $g) + (65536 * $b)
1254
+ }
1255
+
1256
+ function Color-Hex([long]$color) {
1257
+ $r = $color -band 255
1258
+ $g = ($color -shr 8) -band 255
1259
+ $b = ($color -shr 16) -band 255
1260
+ return ('{0:X2}{1:X2}{2:X2}' -f $r, $g, $b)
1261
+ }
1262
+
1263
+ function Operation-Property($op, [string]$name, $fallback) {
1264
+ $direct = $op.PSObject.Properties[$name]
1265
+ if ($null -ne $direct -and $null -ne $direct.Value) { return $direct.Value }
1266
+ $properties = $op.PSObject.Properties['properties']
1267
+ if ($null -ne $properties -and $null -ne $properties.Value) {
1268
+ $nested = $properties.Value.PSObject.Properties[$name]
1269
+ if ($null -ne $nested -and $null -ne $nested.Value) { return $nested.Value }
1270
+ }
1271
+ return $fallback
1272
+ }
1273
+
1274
+ function Template-ValueMap($op) {
1275
+ $map = @{}
1276
+ $source = $op.tokens
1277
+ if ($null -eq $source) { throw 'fill_template requires tokens as an object' }
1278
+ if ($source -is [System.Collections.IDictionary]) {
1279
+ foreach ($key in $source.Keys) { $map[[string]$key] = $source[$key] }
1280
+ } else {
1281
+ foreach ($property in @($source.PSObject.Properties)) { $map[[string]$property.Name] = $property.Value }
1282
+ }
1283
+ return $map
1284
+ }
1285
+
1286
+ function Template-Matches([string]$text) {
1287
+ $matches = @()
1288
+ foreach ($match in [regex]::Matches($text, '\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}')) {
1289
+ $matches += [pscustomobject]@{ Raw = [string]$match.Value; Key = [string]$match.Groups[1].Value }
1290
+ }
1291
+ return $matches
1292
+ }
1293
+
1294
+ function Word-StoryRanges($doc) {
1295
+ $ranges = @()
1296
+ for ($storyType = 1; $storyType -le 17; $storyType++) {
1297
+ try {
1298
+ $current = $doc.StoryRanges.Item($storyType)
1299
+ while ($null -ne $current) {
1300
+ $ranges += $current.Duplicate
1301
+ try { $current = $current.NextStoryRange } catch { $current = $null }
1302
+ }
1303
+ } catch {}
1304
+ }
1305
+ return $ranges
1306
+ }
1307
+
1308
+ function Fill-WordTemplate($doc, $op) {
1309
+ $values = Template-ValueMap $op
1310
+ $filled = [ordered]@{}
1311
+ foreach ($range in @(Word-StoryRanges $doc)) {
1312
+ $text = [string]$range.Text
1313
+ $variants = @{}
1314
+ foreach ($match in @(Template-Matches $text)) { $variants[$match.Raw] = $match.Key }
1315
+ foreach ($raw in $variants.Keys) {
1316
+ $key = [string]$variants[$raw]
1317
+ if (-not $values.ContainsKey($key)) { continue }
1318
+ $count = [regex]::Matches($text, [regex]::Escape([string]$raw)).Count
1319
+ if ($count -eq 0) { continue }
1320
+ $search = $range.Duplicate
1321
+ $changed = $search.Find.Execute([string]$raw, $false, $false, $false, $false, $false, $true, 0, $false, [string]$values[$key], 2)
1322
+ if ($changed) { $filled[$key] = $(if ($filled.Contains($key)) { [int]$filled[$key] + $count } else { $count }) }
1323
+ }
1324
+ }
1325
+ $remaining = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
1326
+ foreach ($range in @(Word-StoryRanges $doc)) {
1327
+ foreach ($match in @(Template-Matches ([string]$range.Text))) { $null = $remaining.Add([string]$match.Key) }
1328
+ }
1329
+ $unfilled = @($remaining) | Sort-Object
1330
+ if ([bool]$op.strict -and $unfilled.Count -gt 0) { throw "Unfilled template tokens: $($unfilled -join ', ')" }
1331
+ return [ordered]@{ op = 'fill_template'; changed = $filled.Count -gt 0; filled = $filled; unfilledTokens = $unfilled; strict = [bool]$op.strict }
1332
+ }
1333
+
1334
+ function PowerPoint-ShapeTextRanges($shape) {
1335
+ $ranges = @()
1336
+ try {
1337
+ if ($shape.HasTextFrame -and $shape.TextFrame.HasText) { $ranges += $shape.TextFrame.TextRange }
1338
+ } catch {}
1339
+ try {
1340
+ if ($shape.HasTable) {
1341
+ for ($row = 1; $row -le $shape.Table.Rows.Count; $row++) {
1342
+ for ($column = 1; $column -le $shape.Table.Columns.Count; $column++) {
1343
+ $ranges += $shape.Table.Cell($row, $column).Shape.TextFrame.TextRange
1344
+ }
1345
+ }
1346
+ }
1347
+ } catch {}
1348
+ try {
1349
+ if ([int]$shape.Type -eq 6) {
1350
+ for ($index = 1; $index -le $shape.GroupItems.Count; $index++) {
1351
+ $ranges += @(PowerPoint-ShapeTextRanges $shape.GroupItems.Item($index))
1352
+ }
1353
+ }
1354
+ } catch {}
1355
+ return $ranges
1356
+ }
1357
+
1358
+ function PowerPoint-TextRanges($presentation) {
1359
+ $ranges = @()
1360
+ foreach ($slide in @($presentation.Slides)) {
1361
+ foreach ($shape in @($slide.Shapes)) { $ranges += @(PowerPoint-ShapeTextRanges $shape) }
1362
+ try {
1363
+ foreach ($shape in @($slide.NotesPage.Shapes)) { $ranges += @(PowerPoint-ShapeTextRanges $shape) }
1364
+ } catch {}
1365
+ }
1366
+ return $ranges
1367
+ }
1368
+
1369
+ function Fill-PowerPointTemplate($presentation, $op) {
1370
+ $values = Template-ValueMap $op
1371
+ $filled = [ordered]@{}
1372
+ foreach ($range in @(PowerPoint-TextRanges $presentation)) {
1373
+ $text = [string]$range.Text
1374
+ $variants = @{}
1375
+ foreach ($match in @(Template-Matches $text)) { $variants[$match.Raw] = $match.Key }
1376
+ foreach ($raw in $variants.Keys) {
1377
+ $key = [string]$variants[$raw]
1378
+ if (-not $values.ContainsKey($key)) { continue }
1379
+ $expected = [regex]::Matches($text, [regex]::Escape([string]$raw)).Count
1380
+ $replaced = 0
1381
+ $after = 0
1382
+ while ($replaced -lt $expected) {
1383
+ $found = $range.Replace([string]$raw, [string]$values[$key], $after, 0, 0)
1384
+ if ($null -eq $found) { break }
1385
+ $replaced++
1386
+ $after = [int]$found.Start + [Math]::Max([int]$found.Length, 1) - 1
1387
+ }
1388
+ if ($replaced -gt 0) { $filled[$key] = $(if ($filled.Contains($key)) { [int]$filled[$key] + $replaced } else { $replaced }) }
1389
+ }
1390
+ }
1391
+ $remaining = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
1392
+ foreach ($range in @(PowerPoint-TextRanges $presentation)) {
1393
+ foreach ($match in @(Template-Matches ([string]$range.Text))) { $null = $remaining.Add([string]$match.Key) }
1394
+ }
1395
+ $unfilled = @($remaining) | Sort-Object
1396
+ if ([bool]$op.strict -and $unfilled.Count -gt 0) { throw "Unfilled template tokens: $($unfilled -join ', ')" }
1397
+ return [ordered]@{ op = 'fill_template'; changed = $filled.Count -gt 0; filled = $filled; unfilledTokens = $unfilled; strict = [bool]$op.strict }
1398
+ }
1399
+
1400
+ function Word-StyleValue([string]$name) {
1401
+ $styles = @{
1402
+ normal = -1
1403
+ heading1 = -2
1404
+ heading2 = -3
1405
+ heading3 = -4
1406
+ heading4 = -5
1407
+ heading5 = -6
1408
+ heading6 = -7
1409
+ heading7 = -8
1410
+ heading8 = -9
1411
+ heading9 = -10
1412
+ title = -63
1413
+ subtitle = -75
1414
+ tablegrid = -155
1415
+ }
1416
+ $key = $name.Replace(' ', '').Replace('-', '').ToLowerInvariant()
1417
+ if ($styles.ContainsKey($key)) { return [int]$styles[$key] }
1418
+ return $name
1419
+ }
1420
+
1421
+ function Provenance-Text($op) {
1422
+ $source = $op.source
1423
+ if ($null -eq $source) { throw 'add_provenance requires source' }
1424
+ $document = if ($source -is [string]) { [string]$source } else { [string]$source.document }
1425
+ $target = if ($source -is [string]) { '' } else { [string]$source.target }
1426
+ $label = if ($source -is [string]) { '' } else { [string]$source.label }
1427
+ if ([string]::IsNullOrWhiteSpace($document)) { throw 'add_provenance source.document is required' }
1428
+ $text = "Source: $document"
1429
+ if (-not [string]::IsNullOrWhiteSpace($target)) { $text += "#$target" }
1430
+ if (-not [string]::IsNullOrWhiteSpace($label)) { $text += " ($label)" }
1431
+ return $text
1432
+ }
1433
+
1434
+ function Apply-WordOperation($doc, $op) {
1435
+ switch ([string]$op.op) {
1436
+ 'fill_template' { return Fill-WordTemplate $doc $op }
1437
+ 'replace_text' {
1438
+ $range = $doc.Content.Duplicate
1439
+ $ok = $range.Find.Execute([string]$op.find, $false, $false, $false, $false, $false, $true, 1, $false, [string]$op.replace, 2)
1440
+ return [ordered]@{ op = 'replace_text'; changed = [bool]$ok }
1441
+ }
1442
+ 'append_text' {
1443
+ $text = [string]$op.text
1444
+ $existing = ([string]$doc.Content.Text).TrimEnd("`r", "`a")
1445
+ $reusedInitialParagraph = [string]::IsNullOrWhiteSpace($existing)
1446
+ if ($reusedInitialParagraph) {
1447
+ $paragraph = $doc.Paragraphs.Item(1)
1448
+ $paragraph.Range.Text = "$text`r"
1449
+ } else {
1450
+ $range = $doc.Content.Duplicate
1451
+ $range.Collapse(0)
1452
+ $paragraph = $doc.Paragraphs.Add($range)
1453
+ $paragraph.Range.Text = "$text`r"
1454
+ }
1455
+ $style = if ($op.style) { [string]$op.style } elseif ($op.properties.style) { [string]$op.properties.style } else { '' }
1456
+ if ($style) { $paragraph.Range.Style = Word-StyleValue $style }
1457
+ $props = $op.properties
1458
+ if ($props.name) { $paragraph.Range.Font.Name = [string]$props.name }
1459
+ if ($props.size) { $paragraph.Range.Font.Size = [single]$props.size }
1460
+ if ($null -ne $props.bold) { $paragraph.Range.Font.Bold = if ($props.bold) { -1 } else { 0 } }
1461
+ if ($null -ne $props.italic) { $paragraph.Range.Font.Italic = if ($props.italic) { -1 } else { 0 } }
1462
+ if ($props.color) { $paragraph.Range.Font.Color = Color-Value ([string]$props.color) }
1463
+ $format = $paragraph.Format
1464
+ if ($props.alignment) {
1465
+ $format.Alignment = switch ([string]$props.alignment) { 'center' { 1 } 'right' { 2 } 'justify' { 3 } default { 0 } }
1466
+ }
1467
+ if ($null -ne $props.spacingBefore) { $format.SpaceBefore = [single]$props.spacingBefore }
1468
+ if ($null -ne $props.spacingAfter) { $format.SpaceAfter = [single]$props.spacingAfter }
1469
+ if ($null -ne $props.lineSpacing) { $format.LineSpacing = [single]$props.lineSpacing }
1470
+ if ($null -ne $props.keepWithNext) { $format.KeepWithNext = if ($props.keepWithNext) { -1 } else { 0 } }
1471
+ if ($null -ne $props.pageBreakBefore) { $format.PageBreakBefore = if ($props.pageBreakBefore) { -1 } else { 0 } }
1472
+ if ($props.tabStops) {
1473
+ $format.TabStops.ClearAll()
1474
+ foreach ($tab in @($props.tabStops)) {
1475
+ $alignment = switch ([string]$tab.alignment) { 'center' { 1 } 'right' { 2 } 'decimal' { 3 } 'bar' { 4 } default { 0 } }
1476
+ $leader = switch ([string]$tab.leader) { 'dot' { 1 } 'dash' { 2 } 'line' { 3 } 'heavy' { 4 } 'middleDot' { 5 } default { 0 } }
1477
+ $null = $format.TabStops.Add([single]$tab.position, $alignment, $leader)
1478
+ }
1479
+ }
1480
+ if ($props.border) {
1481
+ $borderIndex = switch ([string]$props.border.side) { 'top' { -1 } 'left' { -2 } 'right' { -4 } default { -3 } }
1482
+ $border = $paragraph.Borders.Item($borderIndex)
1483
+ $border.LineStyle = 1
1484
+ if ($props.border.color) { $border.Color = Color-Value ([string]$props.border.color) }
1485
+ }
1486
+ if ($props.listKind) {
1487
+ $kind = ([string]$props.listKind).ToLowerInvariant()
1488
+ if ($kind -eq 'number') {
1489
+ $paragraph.Range.ListFormat.ApplyNumberDefault()
1490
+ } elseif ($kind -ne 'none') {
1491
+ $paragraph.Range.ListFormat.ApplyBulletDefault()
1492
+ } else {
1493
+ $paragraph.Range.ListFormat.RemoveNumbers()
1494
+ }
1495
+ for ($level = 0; $level -lt [int]$props.listLevel; $level++) { $paragraph.Range.ListFormat.ListIndent() }
1496
+ } else {
1497
+ $paragraph.Range.ListFormat.RemoveNumbers()
1498
+ }
1499
+ $paragraphIndex = if ($reusedInitialParagraph) { 1 } else { [Math]::Max(1, [int]$doc.Paragraphs.Count - 1) }
1500
+ return [ordered]@{ op = 'append_text'; changed = $true; paragraph = $paragraphIndex; path = "/body/p[$paragraphIndex]"; style = $style }
1501
+ }
1502
+ 'set_table_cell' {
1503
+ $doc.Tables.Item([int]$op.table).Cell([int]$op.row, [int]$op.col).Range.Text = [string]$op.text
1504
+ return [ordered]@{ op = 'set_table_cell'; changed = $true }
1505
+ }
1506
+ 'add_table' {
1507
+ $values = @($op.values)
1508
+ $rows = if ($op.rows) { [int]$op.rows } elseif ($values.Count -gt 0) { [int]$values.Count } else { 1 }
1509
+ $columns = if ($op.columns) {
1510
+ [int]$op.columns
1511
+ } elseif ($values.Count -gt 0) {
1512
+ [Math]::Max(1, [int]@($values | ForEach-Object { @($_).Count } | Measure-Object -Maximum).Maximum)
1513
+ } else {
1514
+ 1
1515
+ }
1516
+ if ($op.paragraph) {
1517
+ $range = $doc.Paragraphs.Item([int]$op.paragraph).Range.Duplicate
1518
+ $range.Collapse(0)
1519
+ } else {
1520
+ $range = $doc.Content.Duplicate
1521
+ $range.Collapse(0)
1522
+ }
1523
+ $table = $doc.Tables.Add($range, $rows, $columns)
1524
+ for ($row = 1; $row -le $rows; $row++) {
1525
+ for ($column = 1; $column -le $columns; $column++) {
1526
+ if ($row -le $values.Count -and $column -le @($values[$row - 1]).Count) {
1527
+ $table.Cell($row, $column).Range.Text = [string]@($values[$row - 1])[$column - 1]
1528
+ }
1529
+ }
1530
+ }
1531
+ $props = $op.properties
1532
+ if ($props.style) { $table.Style = Word-StyleValue ([string]$props.style) }
1533
+ if ($props.textStyle) { $table.Range.Style = Word-StyleValue ([string]$props.textStyle) }
1534
+ if ($props.fontName) { $table.Range.Font.Name = [string]$props.fontName }
1535
+ if ($props.fontSize) { $table.Range.Font.Size = [single]$props.fontSize }
1536
+ if ($props.color) { $table.Range.Font.Color = Color-Value ([string]$props.color) }
1537
+ if ($null -ne $props.spacingAfter) { $table.Range.ParagraphFormat.SpaceAfter = [single]$props.spacingAfter }
1538
+ if ($props.alignment) {
1539
+ $table.Rows.Alignment = switch ([string]$props.alignment) { 'center' { 1 } 'right' { 2 } default { 0 } }
1540
+ }
1541
+ if ($props.columnWidths) {
1542
+ for ($column = 1; $column -le [Math]::Min($columns, @($props.columnWidths).Count); $column++) {
1543
+ $table.Columns.Item($column).Width = [single]@($props.columnWidths)[$column - 1]
1544
+ }
1545
+ }
1546
+ if ($props.borders) { $table.Borders.Enable = 1 }
1547
+ if ($props.shading) { $table.Shading.BackgroundPatternColor = Color-Value ([string]$props.shading) }
1548
+ return [ordered]@{ op = 'add_table'; changed = $true; table = [int]$table.Index; rows = $rows; columns = $columns }
1549
+ }
1550
+ 'set_table_style' {
1551
+ $table = $doc.Tables.Item([int]$op.table)
1552
+ $props = $op.properties
1553
+ if ($props.style) { $table.Style = Word-StyleValue ([string]$props.style) }
1554
+ if ($props.alignment) {
1555
+ $table.Rows.Alignment = switch ([string]$props.alignment) { 'center' { 1 } 'right' { 2 } default { 0 } }
1556
+ }
1557
+ if ($props.columnWidths) {
1558
+ for ($column = 1; $column -le [Math]::Min($table.Columns.Count, @($props.columnWidths).Count); $column++) {
1559
+ $table.Columns.Item($column).Width = [single]@($props.columnWidths)[$column - 1]
1560
+ }
1561
+ }
1562
+ if ($props.borders) { $table.Borders.Enable = 1 }
1563
+ if ($props.shading) { $table.Shading.BackgroundPatternColor = Color-Value ([string]$props.shading) }
1564
+ return [ordered]@{ op = 'set_table_style'; changed = $true; table = [int]$op.table }
1565
+ }
1566
+ 'merge_table_cells' {
1567
+ $table = $doc.Tables.Item([int]$op.table)
1568
+ $rowSpan = [Math]::Max(1, $(if ($op.rowSpan) { [int]$op.rowSpan } else { 1 }))
1569
+ $colSpan = [Math]::Max(1, $(if ($op.colSpan) { [int]$op.colSpan } else { 1 }))
1570
+ $lastRow = [int]$op.row + $rowSpan - 1
1571
+ $lastCol = [int]$op.col + $colSpan - 1
1572
+ $table.Cell([int]$op.row, [int]$op.col).Merge($table.Cell($lastRow, $lastCol))
1573
+ return [ordered]@{ op = 'merge_table_cells'; changed = $true; table = [int]$op.table; row = [int]$op.row; col = [int]$op.col }
1574
+ }
1575
+ 'set_table_cell_style' {
1576
+ $cell = $doc.Tables.Item([int]$op.table).Cell([int]$op.row, [int]$op.col)
1577
+ $props = $op.properties
1578
+ if ($props.fillColor) { $cell.Shading.BackgroundPatternColor = Color-Value ([string]$props.fillColor) }
1579
+ if ($props.verticalAlignment) {
1580
+ $cell.VerticalAlignment = switch ([string]$props.verticalAlignment) { 'center' { 1 } 'bottom' { 3 } default { 0 } }
1581
+ }
1582
+ if ($props.width) { $cell.Width = [single]$props.width }
1583
+ if ($null -ne $props.bold) { $cell.Range.Font.Bold = if ($props.bold) { -1 } else { 0 } }
1584
+ if ($null -ne $props.italic) { $cell.Range.Font.Italic = if ($props.italic) { -1 } else { 0 } }
1585
+ if ($props.fontName) { $cell.Range.Font.Name = [string]$props.fontName }
1586
+ if ($props.fontSize) { $cell.Range.Font.Size = [single]$props.fontSize }
1587
+ if ($props.color) { $cell.Range.Font.Color = Color-Value ([string]$props.color) }
1588
+ return [ordered]@{ op = 'set_table_cell_style'; changed = $true; table = [int]$op.table; row = [int]$op.row; col = [int]$op.col }
1589
+ }
1590
+ 'set_paragraph_text' {
1591
+ $paragraph = $doc.Paragraphs.Item([int]$op.paragraph)
1592
+ $paragraph.Range.Text = ([string]$op.text) + "`r"
1593
+ return [ordered]@{ op = 'set_paragraph_text'; changed = $true }
1594
+ }
1595
+ 'set_run_text' {
1596
+ $paragraph = $doc.Paragraphs.Item([int]$op.paragraph)
1597
+ $run = $paragraph.Range.Words.Item([int]$op.run)
1598
+ $run.Text = [string]$op.text
1599
+ return [ordered]@{ op = 'set_run_text'; changed = $true }
1600
+ }
1601
+ 'remove_paragraph' {
1602
+ $doc.Paragraphs.Item([int]$op.paragraph).Range.Delete()
1603
+ return [ordered]@{ op = 'remove_paragraph'; changed = $true }
1604
+ }
1605
+ 'move_paragraph' {
1606
+ $source = $doc.Paragraphs.Item([int]$op.paragraph).Range.Duplicate
1607
+ $formatted = $source.FormattedText
1608
+ $source.Delete()
1609
+ $destination = $doc.Paragraphs.Item([int]$op.index).Range.Duplicate
1610
+ $destination.Collapse(1)
1611
+ $destination.FormattedText = $formatted
1612
+ return [ordered]@{ op = 'move_paragraph'; changed = $true }
1613
+ }
1614
+ 'add_image' {
1615
+ $width = if ($op.width) { [single]$op.width } else { [single]0 }
1616
+ $height = if ($op.height) { [single]$op.height } else { [single]0 }
1617
+ $shape = $doc.InlineShapes.AddPicture([string]$op.path, $false, $true)
1618
+ if ($width -gt 0) { $shape.Width = $width }
1619
+ if ($height -gt 0) { $shape.Height = $height }
1620
+ return [ordered]@{ op = 'add_image'; changed = $true }
1621
+ }
1622
+ 'add_comment' {
1623
+ $range = $doc.Content.Duplicate
1624
+ $found = $range.Find.Execute([string]$op.find)
1625
+ if (-not $found) { throw "Comment target not found: $($op.find)" }
1626
+ $null = $doc.Comments.Add($range, [string]$op.text)
1627
+ return [ordered]@{ op = 'add_comment'; changed = $true }
1628
+ }
1629
+ 'add_comment_reply' {
1630
+ $index = [int]$op.comment
1631
+ if ($index -lt 1 -or $index -gt $doc.Comments.Count) { throw "Comment index out of range: $index" }
1632
+ $comment = $doc.Comments.Item($index)
1633
+ try {
1634
+ $range = $comment.Scope.Duplicate
1635
+ $null = $comment.Replies.Add($range, [string]$op.text)
1636
+ } catch {
1637
+ throw 'This Word version does not expose threaded comment replies through COM'
1638
+ }
1639
+ return [ordered]@{ op = 'add_comment_reply'; changed = $true; comment = $index }
1640
+ }
1641
+ 'add_provenance' {
1642
+ $paragraphIndex = [int]$op.paragraph
1643
+ if ($paragraphIndex -lt 1 -or $paragraphIndex -gt $doc.Paragraphs.Count) { throw "Provenance paragraph index out of range: $paragraphIndex" }
1644
+ $range = $doc.Paragraphs.Item($paragraphIndex).Range.Duplicate
1645
+ if ($range.End -gt $range.Start) { $range.End-- }
1646
+ $text = Provenance-Text $op
1647
+ $null = $doc.Comments.Add($range, $text)
1648
+ return [ordered]@{ op = 'add_provenance'; changed = $true; target = "/body/p[$paragraphIndex]"; citation = $text }
1649
+ }
1650
+ 'delete_comment' {
1651
+ $index = [int]$op.comment
1652
+ if ($index -lt 1 -or $index -gt $doc.Comments.Count) { throw "Comment index out of range: $index" }
1653
+ $comment = $doc.Comments.Item($index)
1654
+ if ($comment.Replies.Count -gt 0) {
1655
+ $comment.DeleteRecursively()
1656
+ } else {
1657
+ $comment.Delete()
1658
+ }
1659
+ return [ordered]@{ op = 'delete_comment'; changed = $true; comment = $index }
1660
+ }
1661
+ 'set_comment_resolved' {
1662
+ $index = [int]$op.comment
1663
+ if ($index -lt 1 -or $index -gt $doc.Comments.Count) { throw "Comment index out of range: $index" }
1664
+ $comment = $doc.Comments.Item($index)
1665
+ try {
1666
+ $comment.Done = [bool]$op.resolved
1667
+ } catch {
1668
+ throw 'This Word version does not expose resolved comment state through COM'
1669
+ }
1670
+ return [ordered]@{ op = 'set_comment_resolved'; changed = $true; comment = $index; resolved = [bool]$op.resolved }
1671
+ }
1672
+ 'set_font' {
1673
+ $range = $doc.Content.Duplicate
1674
+ if ($op.find -and -not $range.Find.Execute([string]$op.find)) { throw "Font target not found: $($op.find)" }
1675
+ $props = $op.properties
1676
+ if ($props.name) { $range.Font.Name = [string]$props.name }
1677
+ if ($props.size) { $range.Font.Size = [single]$props.size }
1678
+ if ($null -ne $props.bold) { $range.Font.Bold = if ($props.bold) { -1 } else { 0 } }
1679
+ if ($null -ne $props.italic) { $range.Font.Italic = if ($props.italic) { -1 } else { 0 } }
1680
+ if ($props.color) { $range.Font.Color = Color-Value ([string]$props.color) }
1681
+ return [ordered]@{ op = 'set_font'; changed = $true }
1682
+ }
1683
+ 'set_paragraph_style' {
1684
+ $paragraph = $doc.Paragraphs.Item([int]$op.paragraph)
1685
+ $paragraph.Range.Style = Word-StyleValue ([string]$op.style)
1686
+ return [ordered]@{ op = 'set_paragraph_style'; changed = $true; style = [string]$op.style }
1687
+ }
1688
+ 'set_paragraph_format' {
1689
+ $paragraph = $doc.Paragraphs.Item([int]$op.paragraph)
1690
+ $format = $paragraph.Format
1691
+ $props = $op.properties
1692
+ if ($props.alignment) {
1693
+ $format.Alignment = switch ([string]$props.alignment) { 'center' { 1 } 'right' { 2 } 'justify' { 3 } default { 0 } }
1694
+ }
1695
+ if ($null -ne $props.spacingBefore) { $format.SpaceBefore = [single]$props.spacingBefore }
1696
+ if ($null -ne $props.spacingAfter) { $format.SpaceAfter = [single]$props.spacingAfter }
1697
+ if ($null -ne $props.lineSpacing) { $format.LineSpacing = [single]$props.lineSpacing }
1698
+ if ($null -ne $props.keepWithNext) { $format.KeepWithNext = if ($props.keepWithNext) { -1 } else { 0 } }
1699
+ if ($null -ne $props.pageBreakBefore) { $format.PageBreakBefore = if ($props.pageBreakBefore) { -1 } else { 0 } }
1700
+ if ($props.tabStops) {
1701
+ $format.TabStops.ClearAll()
1702
+ foreach ($tab in @($props.tabStops)) {
1703
+ $alignment = switch ([string]$tab.alignment) { 'center' { 1 } 'right' { 2 } 'decimal' { 3 } 'bar' { 4 } default { 0 } }
1704
+ $leader = switch ([string]$tab.leader) { 'dot' { 1 } 'dash' { 2 } 'line' { 3 } 'heavy' { 4 } 'middleDot' { 5 } default { 0 } }
1705
+ $null = $format.TabStops.Add([single]$tab.position, $alignment, $leader)
1706
+ }
1707
+ }
1708
+ if ($props.border) {
1709
+ $borderIndex = switch ([string]$props.border.side) { 'top' { -1 } 'left' { -2 } 'right' { -4 } default { -3 } }
1710
+ $border = $paragraph.Borders.Item($borderIndex)
1711
+ $border.LineStyle = 1
1712
+ if ($props.border.color) { $border.Color = Color-Value ([string]$props.border.color) }
1713
+ }
1714
+ return [ordered]@{ op = 'set_paragraph_format'; changed = $true; paragraph = [int]$op.paragraph }
1715
+ }
1716
+ 'insert_table_row' {
1717
+ $table = $doc.Tables.Item([int]$op.table)
1718
+ if ($op.row) { $null = $table.Rows.Add($table.Rows.Item([int]$op.row)) } else { $null = $table.Rows.Add() }
1719
+ return [ordered]@{ op = 'insert_table_row'; changed = $true; rows = $table.Rows.Count }
1720
+ }
1721
+ 'delete_table_row' {
1722
+ $table = $doc.Tables.Item([int]$op.table)
1723
+ $table.Rows.Item([int]$op.row).Delete()
1724
+ return [ordered]@{ op = 'delete_table_row'; changed = $true; rows = $table.Rows.Count }
1725
+ }
1726
+ 'insert_table_column' {
1727
+ $table = $doc.Tables.Item([int]$op.table)
1728
+ if ($op.column) { $null = $table.Columns.Add($table.Columns.Item([int]$op.column)) } else { $null = $table.Columns.Add() }
1729
+ return [ordered]@{ op = 'insert_table_column'; changed = $true; columns = $table.Columns.Count }
1730
+ }
1731
+ 'delete_table_column' {
1732
+ $table = $doc.Tables.Item([int]$op.table)
1733
+ $table.Columns.Item([int]$op.column).Delete()
1734
+ return [ordered]@{ op = 'delete_table_column'; changed = $true; columns = $table.Columns.Count }
1735
+ }
1736
+ 'set_header_footer' {
1737
+ $section = $doc.Sections.Item($(if ($op.section) { [int]$op.section } else { 1 }))
1738
+ $kind = switch ([string]$op.kind) {
1739
+ 'first' { 2 }
1740
+ 'even' { 3 }
1741
+ default { 1 }
1742
+ }
1743
+ $collection = if ($null -ne $op.header -and -not [bool]$op.header) { $section.Footers } else { $section.Headers }
1744
+ $collection.Item($kind).Range.Text = [string]$op.text
1745
+ return [ordered]@{ op = 'set_header_footer'; changed = $true; section = $section.Index }
1746
+ }
1747
+ 'track_changes' {
1748
+ $doc.TrackRevisions = [bool]$op.enabled
1749
+ return [ordered]@{ op = 'track_changes'; changed = $true; enabled = [bool]$doc.TrackRevisions }
1750
+ }
1751
+ 'resolve_revisions' {
1752
+ $resolution = ([string]$op.resolution).ToLowerInvariant()
1753
+ $count = $doc.Revisions.Count
1754
+ if ($resolution -eq 'reject') { $doc.RejectAllRevisions() } else { $doc.AcceptAllRevisions() }
1755
+ return [ordered]@{ op = 'resolve_revisions'; changed = $count -gt 0; resolved = $count; resolution = $(if ($resolution -eq 'reject') { 'reject' } else { 'accept' }) }
1756
+ }
1757
+ 'resolve_revision' {
1758
+ $index = [int]$op.revision
1759
+ if ($index -lt 1 -or $index -gt $doc.Revisions.Count) { throw "Revision index out of range: $index" }
1760
+ $resolution = ([string]$op.resolution).ToLowerInvariant()
1761
+ $revision = $doc.Revisions.Item($index)
1762
+ if ($resolution -eq 'reject') { $revision.Reject() } else { $revision.Accept() }
1763
+ return [ordered]@{ op = 'resolve_revision'; changed = $true; revision = $index; resolution = $(if ($resolution -eq 'reject') { 'reject' } else { 'accept' }) }
1764
+ }
1765
+ 'insert_toc' {
1766
+ $range = if ($op.paragraph) {
1767
+ $doc.Paragraphs.Item([int]$op.paragraph).Range.Duplicate
1768
+ } else {
1769
+ $doc.Range(0, 0)
1770
+ }
1771
+ $range.Collapse(1)
1772
+ $toc = $doc.TablesOfContents.Add(
1773
+ $range,
1774
+ $true,
1775
+ $(if ($op.lowerHeadingLevel) { [int]$op.lowerHeadingLevel } else { 1 }),
1776
+ $(if ($op.upperHeadingLevel) { [int]$op.upperHeadingLevel } else { 9 })
1777
+ )
1778
+ try { $toc.Update() } catch {}
1779
+ return [ordered]@{ op = 'insert_toc'; changed = $true; index = [int]$toc.Index }
1780
+ }
1781
+ 'add_page_numbers' {
1782
+ $section = $doc.Sections.Item($(if ($op.section) { [int]$op.section } else { 1 }))
1783
+ $kind = switch ([string]$op.kind) {
1784
+ 'first' { 2 }
1785
+ 'even' { 3 }
1786
+ default { 1 }
1787
+ }
1788
+ $footer = $section.Footers.Item($kind)
1789
+ $range = $footer.Range
1790
+ $range.Text = $(if ($null -ne $op.prefix) { [string]$op.prefix } else { 'Page ' })
1791
+ $range = $footer.Range
1792
+ $range.Collapse(0)
1793
+ $null = $footer.Range.Fields.Add($range, -1, 'PAGE', $true)
1794
+ if ($null -eq $op.includeTotal -or [bool]$op.includeTotal) {
1795
+ $range = $footer.Range
1796
+ $range.Collapse(0)
1797
+ $range.InsertAfter($(if ($null -ne $op.separator) { [string]$op.separator } else { ' of ' }))
1798
+ $range = $footer.Range
1799
+ $range.Collapse(0)
1800
+ $null = $footer.Range.Fields.Add($range, -1, 'NUMPAGES', $true)
1801
+ }
1802
+ return [ordered]@{ op = 'add_page_numbers'; changed = $true; section = [int]$section.Index }
1803
+ }
1804
+ 'insert_break' {
1805
+ $range = if ($op.paragraph) {
1806
+ $doc.Paragraphs.Item([int]$op.paragraph).Range.Duplicate
1807
+ } else {
1808
+ $doc.Content.Duplicate
1809
+ }
1810
+ $range.Collapse(0)
1811
+ $breakType = switch ([string]$op.kind) {
1812
+ 'section_next' { 2 }
1813
+ 'section_continuous' { 3 }
1814
+ default { 7 }
1815
+ }
1816
+ $range.InsertBreak($breakType)
1817
+ return [ordered]@{ op = 'insert_break'; changed = $true; kind = $(if ($op.kind) { [string]$op.kind } else { 'page' }) }
1818
+ }
1819
+ 'set_list' {
1820
+ $paragraph = $doc.Paragraphs.Item([int]$op.paragraph)
1821
+ $kind = ([string]$op.kind).ToLowerInvariant()
1822
+ if ($kind -eq 'none') {
1823
+ $paragraph.Range.ListFormat.RemoveNumbers()
1824
+ } elseif ($kind -eq 'number') {
1825
+ $paragraph.Range.ListFormat.ApplyNumberDefault()
1826
+ } else {
1827
+ $paragraph.Range.ListFormat.ApplyBulletDefault()
1828
+ }
1829
+ if ($op.level) { $paragraph.Range.ListFormat.ListIndent(); for ($level = 2; $level -lt [int]$op.level; $level++) { $paragraph.Range.ListFormat.ListIndent() } }
1830
+ return [ordered]@{ op = 'set_list'; changed = $true; paragraph = [int]$op.paragraph; kind = $(if ($kind) { $kind } else { 'bullet' }) }
1831
+ }
1832
+ 'add_hyperlink' {
1833
+ $range = if ($op.paragraph) { $doc.Paragraphs.Item([int]$op.paragraph).Range.Duplicate } else { $doc.Content.Duplicate }
1834
+ if ($op.find) {
1835
+ $found = $range.Find.Execute([string]$op.find, $false, $false, $false, $false, $false, $true)
1836
+ if (-not $found) { throw "Hyperlink target text not found: $($op.find)" }
1837
+ } elseif (-not $op.paragraph) {
1838
+ $range.Collapse(0)
1839
+ }
1840
+ $display = if ($null -ne $op.display) { [string]$op.display } else { [string]$range.Text }
1841
+ $link = $doc.Hyperlinks.Add($range, [string]$op.address, [string]$op.subAddress, $null, $display)
1842
+ return [ordered]@{ op = 'add_hyperlink'; changed = $true; address = [string]$link.Address; subAddress = [string]$link.SubAddress }
1843
+ }
1844
+ 'add_bookmark' {
1845
+ $range = if ($op.paragraph) { $doc.Paragraphs.Item([int]$op.paragraph).Range.Duplicate } else { $doc.Content.Duplicate }
1846
+ if ($op.find) {
1847
+ $found = $range.Find.Execute([string]$op.find, $false, $false, $false, $false, $false, $true)
1848
+ if (-not $found) { throw "Bookmark target text not found: $($op.find)" }
1849
+ } elseif (-not $op.paragraph) {
1850
+ $range.Collapse(0)
1851
+ }
1852
+ $bookmark = $doc.Bookmarks.Add([string]$op.name, $range)
1853
+ return [ordered]@{ op = 'add_bookmark'; changed = $true; name = [string]$bookmark.Name }
1854
+ }
1855
+ 'set_page' {
1856
+ $section = $doc.Sections.Item($(if ($op.section) { [int]$op.section } else { 1 }))
1857
+ $props = $op.properties
1858
+ if ($props.orientation) { $section.PageSetup.Orientation = $(if ([string]$props.orientation -eq 'landscape') { 1 } else { 0 }) }
1859
+ if ($props.topMargin) { $section.PageSetup.TopMargin = [single]$props.topMargin }
1860
+ if ($props.bottomMargin) { $section.PageSetup.BottomMargin = [single]$props.bottomMargin }
1861
+ if ($props.leftMargin) { $section.PageSetup.LeftMargin = [single]$props.leftMargin }
1862
+ if ($props.rightMargin) { $section.PageSetup.RightMargin = [single]$props.rightMargin }
1863
+ return [ordered]@{ op = 'set_page'; changed = $true; section = $section.Index }
1864
+ }
1865
+ 'fit_table' {
1866
+ $table = $doc.Tables.Item([int]$op.table)
1867
+ $table.AutoFitBehavior(2)
1868
+ return [ordered]@{ op = 'fit_table'; changed = $true; table = [int]$op.table }
1869
+ }
1870
+ default { throw "Unsupported DOCX operation: $($op.op)" }
1871
+ }
1872
+ }
1873
+
1874
+ function Excel-Sheet($book, $op) {
1875
+ if ($op.sheet) { return $book.Worksheets.Item([string]$op.sheet) }
1876
+ return $book.ActiveSheet
1877
+ }
1878
+
1879
+ function Excel-CellValue($value) {
1880
+ if ($null -eq $value) { return $null }
1881
+ if ($value -is [System.Boolean]) { return [bool]$value }
1882
+ if (
1883
+ $value -is [System.Byte] -or
1884
+ $value -is [System.SByte] -or
1885
+ $value -is [System.Int16] -or
1886
+ $value -is [System.UInt16] -or
1887
+ $value -is [System.Int32] -or
1888
+ $value -is [System.UInt32] -or
1889
+ $value -is [System.Int64] -or
1890
+ $value -is [System.UInt64] -or
1891
+ $value -is [System.Single] -or
1892
+ $value -is [System.Double] -or
1893
+ $value -is [System.Decimal]
1894
+ ) {
1895
+ return [double]$value
1896
+ }
1897
+ return [string]$value
1898
+ }
1899
+
1900
+ function Set-ExcelCellValue($cell, $value) {
1901
+ $converted = Excel-CellValue $value
1902
+ if ($null -eq $converted) {
1903
+ $cell.ClearContents()
1904
+ return
1905
+ }
1906
+ $arguments = [object[]]::new(1)
1907
+ $arguments[0] = $converted
1908
+ $cell.GetType().InvokeMember(
1909
+ 'Value2',
1910
+ [System.Reflection.BindingFlags]::SetProperty,
1911
+ $null,
1912
+ $cell,
1913
+ $arguments
1914
+ ) | Out-Null
1915
+ }
1916
+
1917
+ function Excel-PivotField($pivot, [string]$name) {
1918
+ $collection = $pivot.PivotFields()
1919
+ $available = @()
1920
+ for ($index = 1; $index -le $collection.Count; $index++) {
1921
+ $candidate = $collection.Item($index)
1922
+ $candidateName = [string]$candidate.Name
1923
+ $available += $candidateName
1924
+ if ([string]::Equals($candidateName, $name, [System.StringComparison]::OrdinalIgnoreCase)) { return $candidate }
1925
+ }
1926
+ throw "Pivot field '$name' not found; available fields: $($available -join ', ')"
1927
+ }
1928
+
1929
+ function PowerPoint-Layout($presentation, $reference) {
1930
+ $layouts = $presentation.SlideMaster.CustomLayouts
1931
+ if ($null -eq $reference -or [string]::IsNullOrWhiteSpace([string]$reference)) {
1932
+ for ($index = 1; $index -le $layouts.Count; $index++) {
1933
+ if ([string]::Equals([string]$layouts.Item($index).Name, 'Blank', [System.StringComparison]::OrdinalIgnoreCase)) {
1934
+ return [ordered]@{ Index = $index; Layout = $layouts.Item($index) }
1935
+ }
1936
+ }
1937
+ for ($index = 1; $index -le $layouts.Count; $index++) {
1938
+ $candidate = $layouts.Item($index)
1939
+ $contentPlaceholderCount = 0
1940
+ for ($shapeIndex = 1; $shapeIndex -le $candidate.Shapes.Count; $shapeIndex++) {
1941
+ try {
1942
+ $shape = $candidate.Shapes.Item($shapeIndex)
1943
+ if ([int]$shape.Type -ne 14) { continue }
1944
+ $placeholderType = [int]$shape.PlaceholderFormat.Type
1945
+ if ($placeholderType -notin @(13, 15, 16)) { $contentPlaceholderCount++ }
1946
+ } catch {
1947
+ $contentPlaceholderCount++
1948
+ }
1949
+ }
1950
+ if ($contentPlaceholderCount -eq 0) {
1951
+ return [ordered]@{ Index = $index; Layout = $candidate }
1952
+ }
1953
+ }
1954
+ return [ordered]@{ Index = $layouts.Count; Layout = $layouts.Item($layouts.Count) }
1955
+ }
1956
+ $numeric = 0
1957
+ if ([int]::TryParse([string]$reference, [ref]$numeric)) {
1958
+ if ($numeric -lt 1 -or $numeric -gt $layouts.Count) { throw "PowerPoint layout index out of range: $numeric" }
1959
+ return [ordered]@{ Index = $numeric; Layout = $layouts.Item($numeric) }
1960
+ }
1961
+ $available = @()
1962
+ for ($index = 1; $index -le $layouts.Count; $index++) {
1963
+ $candidate = $layouts.Item($index)
1964
+ $name = [string]$candidate.Name
1965
+ $available += $name
1966
+ if ([string]::Equals($name, [string]$reference, [System.StringComparison]::OrdinalIgnoreCase)) {
1967
+ return [ordered]@{ Index = $index; Layout = $candidate }
1968
+ }
1969
+ }
1970
+ throw "PowerPoint layout '$reference' not found; available layouts: $($available -join ', ')"
1971
+ }
1972
+
1973
+ function Apply-ExcelOperation($book, $op) {
1974
+ switch ([string]$op.op) {
1975
+ 'replace_text' {
1976
+ $changed = $false
1977
+ foreach ($sheet in @($book.Worksheets)) {
1978
+ $changed = [bool]$sheet.Cells.Replace([string]$op.find, [string]$op.replace, 2, 1, $false, $false, $false, $false) -or $changed
1979
+ }
1980
+ return [ordered]@{ op = 'replace_text'; changed = $changed }
1981
+ }
1982
+ 'set_cell' {
1983
+ $sheet = Excel-Sheet $book $op
1984
+ $target = $sheet.Range([string]$op.cell)
1985
+ Set-ExcelCellValue $target $op.value
1986
+ return [ordered]@{ op = 'set_cell'; changed = $true }
1987
+ }
1988
+ 'set_formula' {
1989
+ $sheet = Excel-Sheet $book $op
1990
+ $formula = [string]$op.formula
1991
+ if (-not $formula.StartsWith('=')) { $formula = "=$formula" }
1992
+ $sheet.Range([string]$op.cell).Formula = $formula
1993
+ return [ordered]@{ op = 'set_formula'; changed = $true }
1994
+ }
1995
+ 'set_range' {
1996
+ $sheet = Excel-Sheet $book $op
1997
+ $target = $sheet.Range([string]$op.range)
1998
+ $rows = @($op.values)
1999
+ $rowCount = $rows.Count
2000
+ $columnCount = if ($rowCount -gt 0) { @($rows[0]).Count } else { 0 }
2001
+ if ($rowCount -eq [int]$target.Rows.Count -and $columnCount -eq [int]$target.Columns.Count -and ($rowCount * $columnCount) -gt 500) {
2002
+ $matrix = New-Object 'object[,]' $rowCount, $columnCount
2003
+ for ($r = 0; $r -lt $rowCount; $r++) {
2004
+ $line = @($rows[$r])
2005
+ for ($c = 0; $c -lt $columnCount; $c++) { $matrix[$r, $c] = $line[$c] }
2006
+ }
2007
+ $target.Value2 = $matrix
2008
+ return [ordered]@{ op = 'set_range'; changed = $true; bulk = $true; cells = $rowCount * $columnCount }
2009
+ }
2010
+ for ($r = 0; $r -lt $rows.Count; $r++) {
2011
+ $line = @($rows[$r])
2012
+ for ($c = 0; $c -lt $line.Count; $c++) {
2013
+ $cell = $target.Cells.Item($r + 1, $c + 1)
2014
+ Set-ExcelCellValue $cell $line[$c]
2015
+ }
2016
+ }
2017
+ return [ordered]@{ op = 'set_range'; changed = $true }
2018
+ }
2019
+ 'append_row' {
2020
+ $sheet = Excel-Sheet $book $op
2021
+ $row = [int]$sheet.UsedRange.Row + [int]$sheet.UsedRange.Rows.Count
2022
+ $values = @($op.values)
2023
+ for ($c = 0; $c -lt $values.Count; $c++) {
2024
+ $cell = $sheet.Cells.Item($row, $c + 1)
2025
+ Set-ExcelCellValue $cell $values[$c]
2026
+ }
2027
+ return [ordered]@{ op = 'append_row'; changed = $true; row = $row }
2028
+ }
2029
+ 'add_sheet' {
2030
+ $sheet = $book.Worksheets.Add()
2031
+ $sheet.Name = [string]$op.name
2032
+ return [ordered]@{ op = 'add_sheet'; changed = $true; sheet = [string]$sheet.Name }
2033
+ }
2034
+ 'copy_sheet' {
2035
+ $source = $book.Worksheets.Item([string]$op.sheet)
2036
+ $source.Copy($null, $book.Worksheets.Item($book.Worksheets.Count))
2037
+ $copy = $book.Worksheets.Item($book.Worksheets.Count)
2038
+ if ($op.name) { $copy.Name = [string]$op.name }
2039
+ return [ordered]@{ op = 'copy_sheet'; changed = $true; sheet = [string]$copy.Name }
2040
+ }
2041
+ 'clear_cell' {
2042
+ $sheet = Excel-Sheet $book $op
2043
+ $sheet.Range([string]$op.cell).Clear()
2044
+ return [ordered]@{ op = 'clear_cell'; changed = $true }
2045
+ }
2046
+ 'add_note' {
2047
+ $sheet = Excel-Sheet $book $op
2048
+ $cell = $sheet.Range([string]$op.cell)
2049
+ if ($null -ne $cell.Comment) { $cell.Comment.Delete() }
2050
+ $comment = $cell.AddComment([string]$op.text)
2051
+ return [ordered]@{ op = 'add_note'; changed = $true; cell = [string]$cell.Address($false, $false); author = [string]$comment.Author }
2052
+ }
2053
+ 'add_provenance' {
2054
+ $sheet = Excel-Sheet $book $op
2055
+ $cell = $sheet.Range([string]$op.cell)
2056
+ $text = Provenance-Text $op
2057
+ if ($null -ne $cell.Comment) {
2058
+ $existing = [string]$cell.Comment.Text()
2059
+ if ($existing -notmatch [regex]::Escape($text)) { $cell.Comment.Text("$existing`n$text") }
2060
+ } else {
2061
+ $null = $cell.AddComment($text)
2062
+ }
2063
+ return [ordered]@{ op = 'add_provenance'; changed = $true; target = "/sheet[$([string]$sheet.Name)]/cell[$([string]$cell.Address($false, $false))]"; citation = $text }
2064
+ }
2065
+ 'delete_note' {
2066
+ $sheet = Excel-Sheet $book $op
2067
+ $cell = $sheet.Range([string]$op.cell)
2068
+ $changed = $null -ne $cell.Comment
2069
+ if ($changed) { $cell.Comment.Delete() }
2070
+ return [ordered]@{ op = 'delete_note'; changed = $changed; cell = [string]$cell.Address($false, $false) }
2071
+ }
2072
+ 'delete_sheet' {
2073
+ $book.Worksheets.Item([string]$op.sheet).Delete()
2074
+ return [ordered]@{ op = 'delete_sheet'; changed = $true }
2075
+ }
2076
+ 'set_style' {
2077
+ $sheet = Excel-Sheet $book $op
2078
+ $target = $sheet.Range([string]$(if ($op.range) { $op.range } else { $op.cell }))
2079
+ $props = $op.properties
2080
+ if ($props.fontName) { $target.Font.Name = [string]$props.fontName }
2081
+ if ($props.fontSize) { $target.Font.Size = [single]$props.fontSize }
2082
+ if ($null -ne $props.bold) { $target.Font.Bold = [bool]$props.bold }
2083
+ if ($null -ne $props.italic) { $target.Font.Italic = [bool]$props.italic }
2084
+ if ($props.numberFormat) { $target.NumberFormat = [string]$props.numberFormat }
2085
+ if ($props.color) { $target.Font.Color = Color-Value ([string]$props.color) }
2086
+ if ($props.fillColor) { $target.Interior.Color = Color-Value ([string]$props.fillColor) }
2087
+ if ($props.horizontalAlignment) {
2088
+ $target.HorizontalAlignment = switch ([string]$props.horizontalAlignment) {
2089
+ 'center' { -4108 }
2090
+ 'right' { -4152 }
2091
+ 'justify' { -4130 }
2092
+ default { -4131 }
2093
+ }
2094
+ }
2095
+ if ($props.verticalAlignment) {
2096
+ $target.VerticalAlignment = switch ([string]$props.verticalAlignment) {
2097
+ 'center' { -4108 }
2098
+ 'bottom' { -4107 }
2099
+ default { -4160 }
2100
+ }
2101
+ }
2102
+ if ($null -ne $props.wrapText) { $target.WrapText = [bool]$props.wrapText }
2103
+ return [ordered]@{ op = 'set_style'; changed = $true }
2104
+ }
2105
+ 'add_image' {
2106
+ $sheet = Excel-Sheet $book $op
2107
+ $left = if ($op.left) { [single]$op.left } else { [single]0 }
2108
+ $top = if ($op.top) { [single]$op.top } else { [single]0 }
2109
+ $width = if ($op.width) { [single]$op.width } else { [single]320 }
2110
+ $height = if ($op.height) { [single]$op.height } else { [single]240 }
2111
+ $null = $sheet.Shapes.AddPicture([string]$op.path, $false, $true, $left, $top, $width, $height)
2112
+ return [ordered]@{ op = 'add_image'; changed = $true }
2113
+ }
2114
+ 'rename_sheet' {
2115
+ $sheet = Excel-Sheet $book $op
2116
+ $sheet.Name = [string]$op.name
2117
+ return [ordered]@{ op = 'rename_sheet'; changed = $true; sheet = [string]$sheet.Name }
2118
+ }
2119
+ 'add_table' {
2120
+ $sheet = Excel-Sheet $book $op
2121
+ $source = $sheet.Range([string]$op.range)
2122
+ $table = $sheet.ListObjects.Add(1, $source, $null, 1)
2123
+ if ($op.name) { $table.Name = [string]$op.name }
2124
+ if ($op.style) { $table.TableStyle = [string]$op.style }
2125
+ return [ordered]@{ op = 'add_table'; changed = $true; name = [string]$table.Name }
2126
+ }
2127
+ 'add_chart' {
2128
+ $sheet = Excel-Sheet $book $op
2129
+ $chartTypes = @{ column = 51; bar = 57; line = 4; pie = 5; area = 1; scatter = -4169 }
2130
+ $kind = ([string]$op.chartType).ToLowerInvariant()
2131
+ $chartType = if ($kind -and $chartTypes.ContainsKey($kind)) { [int]$chartTypes[$kind] } elseif ($op.chartType -as [int]) { [int]$op.chartType } else { 51 }
2132
+ $left = if ($null -ne $op.left) { [single]$op.left } else { [single]300 }
2133
+ $top = if ($null -ne $op.top) { [single]$op.top } else { [single]20 }
2134
+ $width = if ($op.width) { [single]$op.width } else { [single]480 }
2135
+ $height = if ($op.height) { [single]$op.height } else { [single]280 }
2136
+ $shape = $sheet.Shapes.AddChart2(-1, $chartType, $left, $top, $width, $height)
2137
+ if ($op.range) { $shape.Chart.SetSourceData($sheet.Range([string]$op.range), 2) }
2138
+ if ($op.title) { $shape.Chart.HasTitle = $true; $shape.Chart.ChartTitle.Text = [string]$op.title }
2139
+ $seriesCount = [int]$shape.Chart.SeriesCollection().Count
2140
+ $pointCount = if ($seriesCount -gt 0) { [int]$shape.Chart.SeriesCollection().Item(1).Points().Count } else { 0 }
2141
+ return [ordered]@{ op = 'add_chart'; changed = $true; name = [string]$shape.Name; series = $seriesCount; categories = $pointCount }
2142
+ }
2143
+ 'add_conditional_format' {
2144
+ $sheet = Excel-Sheet $book $op
2145
+ $target = $sheet.Range([string]$op.range)
2146
+ if ($op.formula) {
2147
+ $rule = $target.FormatConditions.Add(2, $null, [string]$op.formula)
2148
+ if ($op.color) { $rule.Font.Color = Color-Value ([string]$op.color) }
2149
+ if ($op.fillColor) { $rule.Interior.Color = Color-Value ([string]$op.fillColor) }
2150
+ } else {
2151
+ $null = $target.FormatConditions.AddColorScale(3)
2152
+ }
2153
+ return [ordered]@{ op = 'add_conditional_format'; changed = $true }
2154
+ }
2155
+ 'delete_conditional_formats' {
2156
+ $sheet = Excel-Sheet $book $op
2157
+ $target = $sheet.Range([string]$op.range)
2158
+ $count = [int]$target.FormatConditions.Count
2159
+ $target.FormatConditions.Delete()
2160
+ return [ordered]@{ op = 'delete_conditional_formats'; changed = $count -gt 0; count = $count }
2161
+ }
2162
+ 'add_validation' {
2163
+ $sheet = Excel-Sheet $book $op
2164
+ $target = $sheet.Range([string]$op.range)
2165
+ $target.Validation.Delete()
2166
+ $target.Validation.Add(3, 1, 1, [string]$op.formula1)
2167
+ if ($op.inputMessage) { $target.Validation.InputMessage = [string]$op.inputMessage }
2168
+ if ($op.errorMessage) { $target.Validation.ErrorMessage = [string]$op.errorMessage }
2169
+ return [ordered]@{ op = 'add_validation'; changed = $true }
2170
+ }
2171
+ 'freeze_panes' {
2172
+ $sheet = Excel-Sheet $book $op
2173
+ $sheet.Activate()
2174
+ $window = $book.Windows.Item(1)
2175
+ $window.FreezePanes = $false
2176
+ $window.SplitRow = $(if ($null -ne $op.row) { [int]$op.row } else { 1 })
2177
+ $window.SplitColumn = $(if ($null -ne $op.column) { [int]$op.column } else { 0 })
2178
+ $window.FreezePanes = $true
2179
+ return [ordered]@{ op = 'freeze_panes'; changed = $true }
2180
+ }
2181
+ 'add_pivot_table' {
2182
+ $sourceSheet = Excel-Sheet $book $op
2183
+ $destinationSheet = if ($op.destinationSheet) { $book.Worksheets.Item([string]$op.destinationSheet) } else { $sourceSheet }
2184
+ $cache = $book.PivotCaches().Create(1, $sourceSheet.Range([string]$op.source))
2185
+ $pivot = $cache.CreatePivotTable($destinationSheet.Range([string]$op.destination), [string]$(if ($op.name) { $op.name } else { "MixdogPivot$($destinationSheet.PivotTables().Count + 1)" }))
2186
+ foreach ($field in @($op.rows)) {
2187
+ if (-not [string]::IsNullOrWhiteSpace([string]$field)) { (Excel-PivotField $pivot ([string]$field)).Orientation = 1 }
2188
+ }
2189
+ foreach ($field in @($op.columns)) {
2190
+ if (-not [string]::IsNullOrWhiteSpace([string]$field)) { (Excel-PivotField $pivot ([string]$field)).Orientation = 2 }
2191
+ }
2192
+ foreach ($field in @($op.values)) {
2193
+ if ([string]::IsNullOrWhiteSpace([string]$field)) { continue }
2194
+ $dataField = $pivot.AddDataField((Excel-PivotField $pivot ([string]$field)), "Sum of $field", -4157)
2195
+ $null = $dataField
2196
+ }
2197
+ return [ordered]@{ op = 'add_pivot_table'; changed = $true; name = [string]$pivot.Name }
2198
+ }
2199
+ 'insert_rows' {
2200
+ $sheet = Excel-Sheet $book $op
2201
+ $count = if ($op.count) { [int]$op.count } else { 1 }
2202
+ for ($index = 0; $index -lt $count; $index++) { $sheet.Rows.Item([int]$op.row).Insert(-4121) }
2203
+ return [ordered]@{ op = 'insert_rows'; changed = $true; row = [int]$op.row; count = $count; referenceAware = $true }
2204
+ }
2205
+ 'delete_rows' {
2206
+ $sheet = Excel-Sheet $book $op
2207
+ $count = if ($op.count) { [int]$op.count } else { 1 }
2208
+ for ($index = 0; $index -lt $count; $index++) { $sheet.Rows.Item([int]$op.row).Delete() }
2209
+ return [ordered]@{ op = 'delete_rows'; changed = $true; row = [int]$op.row; count = $count; referenceAware = $true }
2210
+ }
2211
+ 'insert_columns' {
2212
+ $sheet = Excel-Sheet $book $op
2213
+ $count = if ($op.count) { [int]$op.count } else { 1 }
2214
+ for ($index = 0; $index -lt $count; $index++) { $sheet.Columns.Item([int]$op.column).Insert(-4161) }
2215
+ return [ordered]@{ op = 'insert_columns'; changed = $true; column = [int]$op.column; count = $count; referenceAware = $true }
2216
+ }
2217
+ 'delete_columns' {
2218
+ $sheet = Excel-Sheet $book $op
2219
+ $count = if ($op.count) { [int]$op.count } else { 1 }
2220
+ for ($index = 0; $index -lt $count; $index++) { $sheet.Columns.Item([int]$op.column).Delete() }
2221
+ return [ordered]@{ op = 'delete_columns'; changed = $true; column = [int]$op.column; count = $count; referenceAware = $true }
2222
+ }
2223
+ 'merge_cells' {
2224
+ $sheet = Excel-Sheet $book $op
2225
+ $sheet.Range([string]$op.range).Merge()
2226
+ return [ordered]@{ op = 'merge_cells'; changed = $true; range = [string]$op.range }
2227
+ }
2228
+ 'unmerge_cells' {
2229
+ $sheet = Excel-Sheet $book $op
2230
+ $sheet.Range([string]$op.range).UnMerge()
2231
+ return [ordered]@{ op = 'unmerge_cells'; changed = $true; range = [string]$op.range }
2232
+ }
2233
+ 'set_autofilter' {
2234
+ $sheet = Excel-Sheet $book $op
2235
+ if ($null -ne $op.enabled -and -not [bool]$op.enabled) {
2236
+ $sheet.AutoFilterMode = $false
2237
+ } else {
2238
+ $sheet.Range([string]$op.range).AutoFilter()
2239
+ }
2240
+ return [ordered]@{ op = 'set_autofilter'; changed = $true; enabled = $(if ($null -ne $op.enabled) { [bool]$op.enabled } else { $true }) }
2241
+ }
2242
+ 'set_hyperlink' {
2243
+ $sheet = Excel-Sheet $book $op
2244
+ $target = $sheet.Range([string]$op.cell)
2245
+ try { $target.Hyperlinks.Delete() } catch {}
2246
+ $display = if ($null -ne $op.text) { [string]$op.text } else { [string]$target.Text }
2247
+ $link = $sheet.Hyperlinks.Add($target, [string]$op.address, [string]$op.subAddress, [string]$op.screenTip, $display)
2248
+ return [ordered]@{ op = 'set_hyperlink'; changed = $true; cell = [string]$op.cell; address = [string]$link.Address }
2249
+ }
2250
+ 'define_name' {
2251
+ $refersTo = [string]$op.refersTo
2252
+ if (-not $refersTo.StartsWith('=')) { $refersTo = "=$refersTo" }
2253
+ $name = $book.Names.Add([string]$op.name, $refersTo)
2254
+ return [ordered]@{ op = 'define_name'; changed = $true; name = [string]$name.Name; refersTo = [string]$name.RefersTo }
2255
+ }
2256
+ 'delete_name' {
2257
+ $book.Names.Item([string]$op.name).Delete()
2258
+ return [ordered]@{ op = 'delete_name'; changed = $true; name = [string]$op.name }
2259
+ }
2260
+ 'protect_sheet' {
2261
+ $sheet = Excel-Sheet $book $op
2262
+ $sheet.Protect([string]$op.password)
2263
+ return [ordered]@{ op = 'protect_sheet'; changed = $true; sheet = [string]$sheet.Name }
2264
+ }
2265
+ 'unprotect_sheet' {
2266
+ $sheet = Excel-Sheet $book $op
2267
+ $sheet.Unprotect([string]$op.password)
2268
+ return [ordered]@{ op = 'unprotect_sheet'; changed = $true; sheet = [string]$sheet.Name }
2269
+ }
2270
+ 'autofit_range' {
2271
+ $sheet = Excel-Sheet $book $op
2272
+ $target = $sheet.Range([string]$op.range)
2273
+ $null = $target.EntireColumn.AutoFit()
2274
+ if ([bool]$op.rows) { $null = $target.EntireRow.AutoFit() }
2275
+ return [ordered]@{ op = 'autofit_range'; changed = $true; range = [string]$op.range }
2276
+ }
2277
+ 'set_page_setup' {
2278
+ $sheet = Excel-Sheet $book $op
2279
+ $setup = $sheet.PageSetup
2280
+ if ($op.printArea) { $setup.PrintArea = [string]$op.printArea }
2281
+ if ($op.orientation) { $setup.Orientation = $(if ([string]$op.orientation -eq 'landscape') { 2 } else { 1 }) }
2282
+ if ($null -ne $op.fitToPagesWide -or $null -ne $op.fitToPagesTall) { $setup.Zoom = $false }
2283
+ if ($null -ne $op.fitToPagesWide) { $setup.FitToPagesWide = [int]$op.fitToPagesWide }
2284
+ if ($null -ne $op.fitToPagesTall) { $setup.FitToPagesTall = [int]$op.fitToPagesTall }
2285
+ if ($null -ne $op.centerHorizontally) { $setup.CenterHorizontally = [bool]$op.centerHorizontally }
2286
+ if ($null -ne $op.topMargin) { $setup.TopMargin = [double]$op.topMargin }
2287
+ if ($null -ne $op.bottomMargin) { $setup.BottomMargin = [double]$op.bottomMargin }
2288
+ if ($null -ne $op.leftMargin) { $setup.LeftMargin = [double]$op.leftMargin }
2289
+ if ($null -ne $op.rightMargin) { $setup.RightMargin = [double]$op.rightMargin }
2290
+ return [ordered]@{ op = 'set_page_setup'; changed = $true; sheet = [string]$sheet.Name; printArea = [string]$setup.PrintArea }
2291
+ }
2292
+ 'set_sheet_view' {
2293
+ $sheet = Excel-Sheet $book $op
2294
+ $sheet.Activate()
2295
+ $window = $book.Windows.Item(1)
2296
+ if ($null -ne $op.showGridlines) { $window.DisplayGridlines = [bool]$op.showGridlines }
2297
+ if ($null -ne $op.zoom) { $window.Zoom = [Math]::Max(10, [Math]::Min(400, [int]$op.zoom)) }
2298
+ return [ordered]@{ op = 'set_sheet_view'; changed = $true; sheet = [string]$sheet.Name }
2299
+ }
2300
+ 'set_sheet_visibility' {
2301
+ $sheet = Excel-Sheet $book $op
2302
+ $visibility = ([string]$op.visibility).ToLowerInvariant()
2303
+ $sheet.Visible = switch ($visibility) {
2304
+ 'hidden' { 0 }
2305
+ 'very_hidden' { 2 }
2306
+ 'veryhidden' { 2 }
2307
+ 'visible' { -1 }
2308
+ default { throw "Unknown worksheet visibility: $visibility" }
2309
+ }
2310
+ return [ordered]@{ op = 'set_sheet_visibility'; changed = $true; sheet = [string]$sheet.Name; visibility = $visibility }
2311
+ }
2312
+ default { throw "Unsupported XLSX operation: $($op.op)" }
2313
+ }
2314
+ }
2315
+
2316
+ function Ppt-Slide($presentation, $op) {
2317
+ return $presentation.Slides.Item([int]$op.slide)
2318
+ }
2319
+
2320
+ function Set-PowerPointChartData($chart, $categoryValues, $seriesValues) {
2321
+ $specs = @($seriesValues)
2322
+ if ($specs.Count -eq 0) { throw 'PowerPoint chart data requires at least one series' }
2323
+ $categories = @($categoryValues)
2324
+ $pointCount = $categories.Count
2325
+ foreach ($spec in $specs) { $pointCount = [Math]::Max($pointCount, @($spec.values).Count) }
2326
+ if ($pointCount -eq 0) { throw 'PowerPoint chart data requires at least one category or value' }
2327
+ if ($categories.Count -eq 0) {
2328
+ $categories = @(1..$pointCount | ForEach-Object { "Item $_" })
2329
+ }
2330
+ $chartData = $null
2331
+ $workbook = $null
2332
+ $worksheet = $null
2333
+ try {
2334
+ $chartData = $chart.ChartData
2335
+ $null = $chartData.Activate()
2336
+ for ($attempt = 0; $attempt -lt 50 -and $null -eq $workbook; $attempt++) {
2337
+ try { $workbook = $chartData.Workbook } catch {}
2338
+ if ($null -eq $workbook) { Start-Sleep -Milliseconds 100 }
2339
+ }
2340
+ if ($null -eq $workbook) { throw 'PowerPoint chart embedded workbook did not activate' }
2341
+ $worksheet = $workbook.Worksheets.Item(1)
2342
+ $null = $worksheet.Cells.Clear()
2343
+ $matrix = New-Object 'object[,]' ($pointCount + 1), ($specs.Count + 1)
2344
+ $matrix[0, 0] = 'Category'
2345
+ for ($seriesIndex = 1; $seriesIndex -le $specs.Count; $seriesIndex++) {
2346
+ $matrix[0, $seriesIndex] = [string]$specs[$seriesIndex - 1].name
2347
+ }
2348
+ for ($pointIndex = 1; $pointIndex -le $pointCount; $pointIndex++) {
2349
+ $category = if ($pointIndex -le $categories.Count) { $categories[$pointIndex - 1] } else { "Item $pointIndex" }
2350
+ $matrix[$pointIndex, 0] = $category
2351
+ for ($seriesIndex = 1; $seriesIndex -le $specs.Count; $seriesIndex++) {
2352
+ $values = @($specs[$seriesIndex - 1].values)
2353
+ if ($pointIndex -le $values.Count) {
2354
+ $matrix[$pointIndex, $seriesIndex] = $values[$pointIndex - 1]
2355
+ }
2356
+ }
2357
+ }
2358
+ $lastColumn = Excel-ColumnLabel ($specs.Count + 1)
2359
+ $source = $worksheet.Range("A1:${lastColumn}$($pointCount + 1)")
2360
+ $null = ($source.Value2 = $matrix)
2361
+ $collection = $chart.SeriesCollection()
2362
+ $placeholderSeriesCount = [int]$collection.Count
2363
+ $categoryArray = New-Object 'object[]' $pointCount
2364
+ for ($pointIndex = 0; $pointIndex -lt $pointCount; $pointIndex++) {
2365
+ $categoryArray[$pointIndex] = if ($pointIndex -lt $categories.Count) { $categories[$pointIndex] } else { "Item $($pointIndex + 1)" }
2366
+ }
2367
+ for ($seriesIndex = 1; $seriesIndex -le $specs.Count; $seriesIndex++) {
2368
+ $series = $collection.NewSeries()
2369
+ $values = @($specs[$seriesIndex - 1].values)
2370
+ $valueArray = New-Object 'object[]' $pointCount
2371
+ for ($pointIndex = 0; $pointIndex -lt $pointCount; $pointIndex++) {
2372
+ if ($pointIndex -lt $values.Count) { $valueArray[$pointIndex] = $values[$pointIndex] }
2373
+ }
2374
+ $null = ($series.Name = [string]$specs[$seriesIndex - 1].name)
2375
+ $null = ($series.XValues = $categoryArray)
2376
+ $null = ($series.Values = $valueArray)
2377
+ }
2378
+ for ($seriesIndex = 0; $seriesIndex -lt $placeholderSeriesCount; $seriesIndex++) { $null = $collection.Item(1).Delete() }
2379
+ $null = $chart.Refresh()
2380
+ } finally {
2381
+ if ($null -ne $workbook) { try { $null = $workbook.Close($true) } catch {} }
2382
+ if ($null -ne $worksheet) { try { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($worksheet) } catch {} }
2383
+ if ($null -ne $workbook) { try { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($workbook) } catch {} }
2384
+ if ($null -ne $chartData) { try { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($chartData) } catch {} }
2385
+ }
2386
+ for ($seriesIndex = 1; $seriesIndex -le [Math]::Min($specs.Count, [int]$chart.SeriesCollection().Count); $seriesIndex++) {
2387
+ $spec = $specs[$seriesIndex - 1]
2388
+ if ($spec.color) {
2389
+ try {
2390
+ $chart.SeriesCollection().Item($seriesIndex).Format.Fill.ForeColor.RGB = Color-Value ([string]$spec.color)
2391
+ $chart.SeriesCollection().Item($seriesIndex).Format.Line.ForeColor.RGB = Color-Value ([string]$spec.color)
2392
+ } catch {}
2393
+ }
2394
+ }
2395
+ $null = ($chart.HasLegend = $specs.Count -gt 1)
2396
+ return [ordered]@{ categories = $pointCount; series = [int]$chart.SeriesCollection().Count }
2397
+ }
2398
+
2399
+ function Set-PowerPointParagraphs($shape, $paragraphs) {
2400
+ $items = @($paragraphs)
2401
+ $shape.TextFrame.TextRange.Text = (@($items | ForEach-Object { [string]$_.text }) -join "`r")
2402
+ for ($index = 1; $index -le $items.Count; $index++) {
2403
+ $spec = $items[$index - 1]
2404
+ $paragraph = $shape.TextFrame.TextRange.Paragraphs($index, 1)
2405
+ if ($null -ne $spec.level) { $paragraph.IndentLevel = [Math]::Max(1, [Math]::Min(5, [int]$spec.level + 1)) }
2406
+ if ($null -ne $spec.bullet) { $paragraph.ParagraphFormat.Bullet.Visible = $(if ([bool]$spec.bullet) { -1 } else { 0 }) }
2407
+ if ($spec.fontName) { $paragraph.Font.Name = [string]$spec.fontName }
2408
+ if ($spec.fontSize) { $paragraph.Font.Size = [single]$spec.fontSize }
2409
+ if ($null -ne $spec.bold) { $paragraph.Font.Bold = $(if ([bool]$spec.bold) { -1 } else { 0 }) }
2410
+ if ($null -ne $spec.italic) { $paragraph.Font.Italic = $(if ([bool]$spec.italic) { -1 } else { 0 }) }
2411
+ if ($spec.color) { $paragraph.Font.Color.RGB = Color-Value ([string]$spec.color) }
2412
+ }
2413
+ return $items.Count
2414
+ }
2415
+
2416
+ function Apply-PowerPointOperation($presentation, $op, [bool]$live = $false) {
2417
+ switch ([string]$op.op) {
2418
+ 'fill_template' { return Fill-PowerPointTemplate $presentation $op }
2419
+ 'replace_text' {
2420
+ $count = 0
2421
+ foreach ($slide in @($presentation.Slides)) {
2422
+ foreach ($shape in @($slide.Shapes)) {
2423
+ try {
2424
+ if ($shape.HasTextFrame -and $shape.TextFrame.HasText) {
2425
+ $before = [string]$shape.TextFrame.TextRange.Text
2426
+ $after = $before.Replace([string]$op.find, [string]$op.replace)
2427
+ if ($after -ne $before) { $shape.TextFrame.TextRange.Text = $after; $count++ }
2428
+ }
2429
+ } catch {}
2430
+ }
2431
+ }
2432
+ return [ordered]@{ op = 'replace_text'; changed = $count -gt 0; shapes = $count }
2433
+ }
2434
+ 'set_text' {
2435
+ $slide = Ppt-Slide $presentation $op
2436
+ $shape = $slide.Shapes.Item([int]$op.shape)
2437
+ $shape.TextFrame.TextRange.Text = [string]$op.text
2438
+ return [ordered]@{ op = 'set_text'; changed = $true }
2439
+ }
2440
+ 'add_slide' {
2441
+ $index = if ($op.index) { [int]$op.index } else { $presentation.Slides.Count + 1 }
2442
+ $layout = PowerPoint-Layout $presentation $op.layout
2443
+ $slide = $presentation.Slides.AddSlide($index, $layout.Layout)
2444
+ return [ordered]@{ op = 'add_slide'; changed = $true; slide = [int]$slide.SlideIndex; layout = [int]$layout.Index; layoutName = [string]$layout.Layout.Name }
2445
+ }
2446
+ 'delete_slide' {
2447
+ (Ppt-Slide $presentation $op).Delete()
2448
+ return [ordered]@{ op = 'delete_slide'; changed = $true }
2449
+ }
2450
+ 'move_slide' {
2451
+ $presentation.Slides.Item([int]$op.slide).MoveTo([int]$op.index)
2452
+ return [ordered]@{ op = 'move_slide'; changed = $true }
2453
+ }
2454
+ 'delete_shape' {
2455
+ (Ppt-Slide $presentation $op).Shapes.Item([int]$op.shape).Delete()
2456
+ return [ordered]@{ op = 'delete_shape'; changed = $true }
2457
+ }
2458
+ 'add_textbox' {
2459
+ $slide = Ppt-Slide $presentation $op
2460
+ $left = [single](Operation-Property $op 'left' 72)
2461
+ $top = [single](Operation-Property $op 'top' 72)
2462
+ $width = [single](Operation-Property $op 'width' 360)
2463
+ $height = [single](Operation-Property $op 'height' 72)
2464
+ $shape = $slide.Shapes.AddTextbox(1, $left, $top, $width, $height)
2465
+ try { $shape.TextFrame.AutoSize = 0 } catch {}
2466
+ try { $shape.TextFrame2.AutoSize = 0 } catch {}
2467
+ $paragraphs = $op.PSObject.Properties['paragraphs']
2468
+ if ($null -ne $paragraphs -and $null -ne $paragraphs.Value -and @($paragraphs.Value).Count -gt 0) {
2469
+ $null = Set-PowerPointParagraphs $shape $paragraphs.Value
2470
+ } else {
2471
+ $shape.TextFrame.TextRange.Text = [string]$op.text
2472
+ }
2473
+ $shape.Left = $left
2474
+ $shape.Top = $top
2475
+ $shape.Width = $width
2476
+ $shape.Height = $height
2477
+ $fontSize = Operation-Property $op 'fontSize' $null
2478
+ $fontName = Operation-Property $op 'fontName' $null
2479
+ $color = Operation-Property $op 'color' $null
2480
+ if ($null -ne $fontSize) { $shape.TextFrame.TextRange.Font.Size = [single]$fontSize }
2481
+ if ($null -ne $fontName) { $shape.TextFrame.TextRange.Font.Name = [string]$fontName }
2482
+ if ($null -ne $color) { $shape.TextFrame.TextRange.Font.Color.RGB = Color-Value ([string]$color) }
2483
+ $props = $op.properties
2484
+ if ($null -ne $props.bold) { $shape.TextFrame.TextRange.Font.Bold = $(if ([bool]$props.bold) { -1 } else { 0 }) }
2485
+ if ($null -ne $props.italic) { $shape.TextFrame.TextRange.Font.Italic = $(if ([bool]$props.italic) { -1 } else { 0 }) }
2486
+ if ($props.alignment) {
2487
+ $shape.TextFrame.TextRange.ParagraphFormat.Alignment = switch ([string]$props.alignment) {
2488
+ 'center' { 2 }
2489
+ 'right' { 3 }
2490
+ 'justify' { 4 }
2491
+ default { 1 }
2492
+ }
2493
+ }
2494
+ if ($props.verticalAlignment) {
2495
+ $shape.TextFrame.VerticalAnchor = switch ([string]$props.verticalAlignment) {
2496
+ 'middle' { 3 }
2497
+ 'bottom' { 4 }
2498
+ default { 1 }
2499
+ }
2500
+ }
2501
+ if ($null -ne $props.marginLeft) { $shape.TextFrame.MarginLeft = [single]$props.marginLeft }
2502
+ if ($null -ne $props.marginTop) { $shape.TextFrame.MarginTop = [single]$props.marginTop }
2503
+ if ($null -ne $props.marginRight) { $shape.TextFrame.MarginRight = [single]$props.marginRight }
2504
+ if ($null -ne $props.marginBottom) { $shape.TextFrame.MarginBottom = [single]$props.marginBottom }
2505
+ return [ordered]@{ op = 'add_textbox'; changed = $true; shape = [string]$shape.Name }
2506
+ }
2507
+ 'add_shape' {
2508
+ $slide = Ppt-Slide $presentation $op
2509
+ $shapeTypes = @{
2510
+ rectangle = 1
2511
+ rounded_rectangle = 5
2512
+ oval = 9
2513
+ diamond = 4
2514
+ right_arrow = 33
2515
+ chevron = 52
2516
+ }
2517
+ $kind = ([string]$op.shapeType).ToLowerInvariant()
2518
+ $shapeType = if ($shapeTypes.ContainsKey($kind)) { [int]$shapeTypes[$kind] } elseif ($op.shapeType -as [int]) { [int]$op.shapeType } else { 1 }
2519
+ $left = [single](Operation-Property $op 'left' 72)
2520
+ $top = [single](Operation-Property $op 'top' 72)
2521
+ $width = [single](Operation-Property $op 'width' 180)
2522
+ $height = [single](Operation-Property $op 'height' 90)
2523
+ $shape = $slide.Shapes.AddShape($shapeType, $left, $top, $width, $height)
2524
+ $fillColor = Operation-Property $op 'fillColor' $null
2525
+ $lineColor = Operation-Property $op 'lineColor' $null
2526
+ if ($fillColor) { $shape.Fill.Visible = $true; $shape.Fill.ForeColor.RGB = Color-Value ([string]$fillColor) }
2527
+ if ($lineColor) { $shape.Line.Visible = $true; $shape.Line.ForeColor.RGB = Color-Value ([string]$lineColor) }
2528
+ $paragraphs = $op.PSObject.Properties['paragraphs']
2529
+ if ($null -ne $paragraphs -and $null -ne $paragraphs.Value -and @($paragraphs.Value).Count -gt 0) {
2530
+ $null = Set-PowerPointParagraphs $shape $paragraphs.Value
2531
+ } elseif ($null -ne $op.text) {
2532
+ $shape.TextFrame.TextRange.Text = [string]$op.text
2533
+ }
2534
+ return [ordered]@{ op = 'add_shape'; changed = $true; shape = [string]$shape.Name; shapeType = $shapeType }
2535
+ }
2536
+ 'add_table' {
2537
+ $slide = Ppt-Slide $presentation $op
2538
+ $values = @($op.values)
2539
+ $rows = if ($op.rows) { [int]$op.rows } else { [Math]::Max(1, $values.Count) }
2540
+ $columns = if ($op.columns) { [int]$op.columns } elseif ($values.Count -gt 0) { [Math]::Max(1, @($values[0]).Count) } else { 1 }
2541
+ $left = [single](Operation-Property $op 'left' 72)
2542
+ $top = [single](Operation-Property $op 'top' 72)
2543
+ $width = [single](Operation-Property $op 'width' 480)
2544
+ $height = [single](Operation-Property $op 'height' 180)
2545
+ $shape = $slide.Shapes.AddTable($rows, $columns, $left, $top, $width, $height)
2546
+ if ($op.properties.headerRowHeight) { $shape.Table.Rows.Item(1).Height = [single]$op.properties.headerRowHeight }
2547
+ if ($op.properties.bodyRowHeight) {
2548
+ for ($row = 2; $row -le $rows; $row++) { $shape.Table.Rows.Item($row).Height = [single]$op.properties.bodyRowHeight }
2549
+ }
2550
+ for ($row = 1; $row -le $rows; $row++) {
2551
+ for ($column = 1; $column -le $columns; $column++) {
2552
+ $value = if ($row -le $values.Count -and $column -le @($values[$row - 1]).Count) { $values[$row - 1][$column - 1] } else { '' }
2553
+ $cell = $shape.Table.Cell($row, $column).Shape
2554
+ $cell.TextFrame.TextRange.Text = [string]$value
2555
+ $props = $op.properties
2556
+ if ($props.fontName) { $cell.TextFrame.TextRange.Font.Name = [string]$props.fontName }
2557
+ if ($props.fontSize) { $cell.TextFrame.TextRange.Font.Size = [single]$props.fontSize }
2558
+ if ($props.color) { $cell.TextFrame.TextRange.Font.Color.RGB = Color-Value ([string]$props.color) }
2559
+ if ($row -eq 1) {
2560
+ if ($props.headerFillColor) { $cell.Fill.ForeColor.RGB = Color-Value ([string]$props.headerFillColor) }
2561
+ if ($props.headerColor) { $cell.TextFrame.TextRange.Font.Color.RGB = Color-Value ([string]$props.headerColor) }
2562
+ $cell.TextFrame.TextRange.Font.Bold = -1
2563
+ } elseif ($props.bodyFillColor) {
2564
+ $cell.Fill.ForeColor.RGB = Color-Value ([string]$props.bodyFillColor)
2565
+ }
2566
+ }
2567
+ }
2568
+ return [ordered]@{ op = 'add_table'; changed = $true; shape = [string]$shape.Name; rows = $rows; columns = $columns }
2569
+ }
2570
+ 'set_table_data' {
2571
+ $slide = Ppt-Slide $presentation $op
2572
+ $shape = $slide.Shapes.Item([int]$op.shape)
2573
+ if (-not $shape.HasTable) { throw "PowerPoint shape $($op.shape) is not a table" }
2574
+ $values = @($op.values)
2575
+ $availableRows = [int]$shape.Table.Rows.Count
2576
+ $availableColumns = [int]$shape.Table.Columns.Count
2577
+ $requestedColumns = 0
2578
+ foreach ($rowValues in $values) {
2579
+ $requestedColumns = [Math]::Max($requestedColumns, @($rowValues).Count)
2580
+ }
2581
+ if ($values.Count -gt $availableRows -or $requestedColumns -gt $availableColumns) {
2582
+ throw "PowerPoint table shape $($op.shape) is ${availableRows}x${availableColumns}, but received $($values.Count)x${requestedColumns}"
2583
+ }
2584
+ for ($row = 1; $row -le $availableRows; $row++) {
2585
+ for ($column = 1; $column -le $availableColumns; $column++) {
2586
+ $value = if ($row -le $values.Count -and $column -le @($values[$row - 1]).Count) { $values[$row - 1][$column - 1] } else { '' }
2587
+ $shape.Table.Cell($row, $column).Shape.TextFrame.TextRange.Text = [string]$value
2588
+ }
2589
+ }
2590
+ return [ordered]@{ op = 'set_table_data'; changed = $true; shape = [int]$op.shape; rows = $availableRows; columns = $availableColumns }
2591
+ }
2592
+ 'set_hyperlink' {
2593
+ $slide = Ppt-Slide $presentation $op
2594
+ $shape = $slide.Shapes.Item([int]$op.shape)
2595
+ $link = $shape.ActionSettings(1).Hyperlink
2596
+ $link.Address = [string]$op.address
2597
+ $link.SubAddress = [string]$op.subAddress
2598
+ return [ordered]@{ op = 'set_hyperlink'; changed = $true; shape = [int]$op.shape; address = [string]$link.Address }
2599
+ }
2600
+ 'z_order' {
2601
+ $slide = Ppt-Slide $presentation $op
2602
+ $shape = $slide.Shapes.Item([int]$op.shape)
2603
+ $commands = @{ front = 0; back = 1; forward = 2; backward = 3 }
2604
+ $command = ([string]$op.command).ToLowerInvariant()
2605
+ if (-not $commands.ContainsKey($command)) { throw "Unsupported z-order command: $command" }
2606
+ $null = $shape.ZOrder([int]$commands[$command])
2607
+ return [ordered]@{ op = 'z_order'; changed = $true; shape = [int]$op.shape; command = $command }
2608
+ }
2609
+ 'align_shapes' {
2610
+ $slide = Ppt-Slide $presentation $op
2611
+ $commands = @{ left = 0; center = 1; right = 2; top = 3; middle = 4; bottom = 5 }
2612
+ $command = ([string]$op.align).ToLowerInvariant()
2613
+ if (-not $commands.ContainsKey($command)) { throw "Unsupported shape alignment: $command" }
2614
+ $indices = [object[]]@($op.shapes | ForEach-Object { [int]$_ })
2615
+ $null = $slide.Shapes.Range($indices).Align([int]$commands[$command], [bool]$op.relativeToSlide)
2616
+ return [ordered]@{ op = 'align_shapes'; changed = $true; shapes = @($op.shapes); align = $command }
2617
+ }
2618
+ 'distribute_shapes' {
2619
+ $slide = Ppt-Slide $presentation $op
2620
+ $commands = @{ horizontal = 0; vertical = 1 }
2621
+ $command = ([string]$op.direction).ToLowerInvariant()
2622
+ if (-not $commands.ContainsKey($command)) { throw "Unsupported shape distribution: $command" }
2623
+ $indices = [object[]]@($op.shapes | ForEach-Object { [int]$_ })
2624
+ $null = $slide.Shapes.Range($indices).Distribute([int]$commands[$command], [bool]$op.relativeToSlide)
2625
+ return [ordered]@{ op = 'distribute_shapes'; changed = $true; shapes = @($op.shapes); direction = $command }
2626
+ }
2627
+ 'group_shapes' {
2628
+ $slide = Ppt-Slide $presentation $op
2629
+ $indices = [object[]]@($op.shapes | ForEach-Object { [int]$_ })
2630
+ if ($indices.Count -lt 2) { throw 'group_shapes requires at least two shape indexes' }
2631
+ $shape = $slide.Shapes.Range($indices).Group()
2632
+ return [ordered]@{ op = 'group_shapes'; changed = $true; shape = [int]$shape.ZOrderPosition; name = [string]$shape.Name }
2633
+ }
2634
+ 'ungroup_shape' {
2635
+ $slide = Ppt-Slide $presentation $op
2636
+ $shape = $slide.Shapes.Item([int]$op.shape)
2637
+ $items = $shape.Ungroup()
2638
+ return [ordered]@{ op = 'ungroup_shape'; changed = $true; count = [int]$items.Count }
2639
+ }
2640
+ 'set_notes' {
2641
+ $slide = Ppt-Slide $presentation $op
2642
+ $slide.NotesPage.Shapes.Placeholders.Item(2).TextFrame.TextRange.Text = [string]$op.text
2643
+ return [ordered]@{ op = 'set_notes'; changed = $true }
2644
+ }
2645
+ 'add_comment' {
2646
+ $slide = Ppt-Slide $presentation $op
2647
+ $left = if ($null -ne $op.left) { [single]$op.left } else { [single]20 }
2648
+ $top = if ($null -ne $op.top) { [single]$op.top } else { [single]20 }
2649
+ $author = if ($op.author) { [string]$op.author } else { 'Mixdog' }
2650
+ $initials = if ($op.initials) { [string]$op.initials } else { 'MD' }
2651
+ $comment = $slide.Comments.Add($left, $top, $author, $initials, [string]$op.text)
2652
+ return [ordered]@{ op = 'add_comment'; changed = $true; slide = [int]$slide.SlideIndex; comment = [int]$comment.Index }
2653
+ }
2654
+ 'delete_comment' {
2655
+ $slide = Ppt-Slide $presentation $op
2656
+ $index = [int]$op.comment
2657
+ if ($index -lt 1 -or $index -gt $slide.Comments.Count) { throw "PowerPoint comment index out of range: $index" }
2658
+ $slide.Comments.Item($index).Delete()
2659
+ return [ordered]@{ op = 'delete_comment'; changed = $true; slide = [int]$slide.SlideIndex; comment = $index }
2660
+ }
2661
+ 'add_provenance' {
2662
+ $slide = Ppt-Slide $presentation $op
2663
+ $text = Provenance-Text $op
2664
+ $range = $slide.NotesPage.Shapes.Placeholders.Item(2).TextFrame.TextRange
2665
+ $existing = [string]$range.Text
2666
+ if ($existing -notmatch [regex]::Escape($text)) {
2667
+ $range.Text = $(if ([string]::IsNullOrWhiteSpace($existing)) { $text } else { "$($existing.TrimEnd())`r$text" })
2668
+ }
2669
+ $target = if ($op.shape) { "/slide[$([int]$slide.SlideIndex)]/shape[$([int]$op.shape)]" } else { "/slide[$([int]$slide.SlideIndex)]" }
2670
+ return [ordered]@{ op = 'add_provenance'; changed = $true; target = $target; citation = $text }
2671
+ }
2672
+ 'set_footer' {
2673
+ $slide = Ppt-Slide $presentation $op
2674
+ $slide.HeadersFooters.Footer.Visible = -1
2675
+ $slide.HeadersFooters.Footer.Text = [string]$op.text
2676
+ return [ordered]@{ op = 'set_footer'; changed = $true }
2677
+ }
2678
+ 'set_slide_number' {
2679
+ $slide = Ppt-Slide $presentation $op
2680
+ $slide.HeadersFooters.SlideNumber.Visible = $(if ([bool]$op.visible) { -1 } else { 0 })
2681
+ return [ordered]@{ op = 'set_slide_number'; changed = $true; visible = [bool]$op.visible }
2682
+ }
2683
+ 'add_image' {
2684
+ $slide = Ppt-Slide $presentation $op
2685
+ $left = if ($op.left) { [single]$op.left } else { [single]72 }
2686
+ $top = if ($op.top) { [single]$op.top } else { [single]72 }
2687
+ $width = if ($op.width) { [single]$op.width } else { [single]320 }
2688
+ $height = if ($op.height) { [single]$op.height } else { [single]240 }
2689
+ $null = $slide.Shapes.AddPicture([string]$op.path, $false, $true, $left, $top, $width, $height)
2690
+ return [ordered]@{ op = 'add_image'; changed = $true }
2691
+ }
2692
+ 'replace_image' {
2693
+ $slide = Ppt-Slide $presentation $op
2694
+ $old = $slide.Shapes.Item([int]$op.shape)
2695
+ $left = [single]$old.Left; $top = [single]$old.Top; $width = [single]$old.Width; $height = [single]$old.Height
2696
+ $old.Delete()
2697
+ $shape = $slide.Shapes.AddPicture([string]$op.path, $false, $true, $left, $top, $width, $height)
2698
+ return [ordered]@{ op = 'replace_image'; changed = $true; shape = [string]$shape.Name }
2699
+ }
2700
+ 'crop_image' {
2701
+ $slide = Ppt-Slide $presentation $op
2702
+ $shape = $slide.Shapes.Item([int]$op.shape)
2703
+ if ($null -ne $op.left) { $shape.PictureFormat.CropLeft = [single]$op.left }
2704
+ if ($null -ne $op.right) { $shape.PictureFormat.CropRight = [single]$op.right }
2705
+ if ($null -ne $op.top) { $shape.PictureFormat.CropTop = [single]$op.top }
2706
+ if ($null -ne $op.bottom) { $shape.PictureFormat.CropBottom = [single]$op.bottom }
2707
+ return [ordered]@{ op = 'crop_image'; changed = $true; shape = [int]$op.shape }
2708
+ }
2709
+ 'add_media' {
2710
+ $slide = Ppt-Slide $presentation $op
2711
+ $left = if ($null -ne $op.left) { [single]$op.left } else { [single]40 }
2712
+ $top = if ($null -ne $op.top) { [single]$op.top } else { [single]40 }
2713
+ $width = if ($op.width) { [single]$op.width } else { [single]320 }
2714
+ $height = if ($op.height) { [single]$op.height } else { [single]180 }
2715
+ $link = [bool]$op.link
2716
+ $embed = if ($null -ne $op.embed) { [bool]$op.embed } else { -not $link }
2717
+ $shape = $slide.Shapes.AddMediaObject2([string]$op.path, $(if ($link) { -1 } else { 0 }), $(if ($embed) { -1 } else { 0 }), $left, $top, $width, $height)
2718
+ return [ordered]@{ op = 'add_media'; changed = $true; shape = [int]$shape.ZOrderPosition; media = [string]$op.path; embedded = $embed }
2719
+ }
2720
+ 'apply_theme' {
2721
+ if (-not $op.path) { throw 'apply_theme requires path' }
2722
+ $presentation.ApplyTheme([string]$op.path)
2723
+ return [ordered]@{ op = 'apply_theme'; changed = $true; path = [string]$op.path }
2724
+ }
2725
+ 'set_transition' {
2726
+ $slide = Ppt-Slide $presentation $op
2727
+ $effects = @{ none = 0; fade = 3849; push = 3850; wipe = 3844; split = 3586; reveal = 3847; random = 513 }
2728
+ $kind = ([string]$op.effect).ToLowerInvariant()
2729
+ if ($kind) { $slide.SlideShowTransition.EntryEffect = $(if ($effects.ContainsKey($kind)) { [int]$effects[$kind] } elseif ($op.effect -as [int]) { [int]$op.effect } else { throw "Unknown transition effect: $kind" }) }
2730
+ if ($null -ne $op.advanceOnTime) { $slide.SlideShowTransition.AdvanceOnTime = [bool]$op.advanceOnTime }
2731
+ if ($null -ne $op.advanceTime) { $slide.SlideShowTransition.AdvanceTime = [single]$op.advanceTime }
2732
+ if ($null -ne $op.duration) {
2733
+ $slide.SlideShowTransition.Speed = if ([single]$op.duration -le 0.5) { 1 } elseif ([single]$op.duration -le 1.5) { 2 } else { 3 }
2734
+ }
2735
+ return [ordered]@{ op = 'set_transition'; changed = $true; slide = [int]$slide.SlideIndex; effect = [int]$slide.SlideShowTransition.EntryEffect }
2736
+ }
2737
+ 'add_animation' {
2738
+ $slide = Ppt-Slide $presentation $op
2739
+ $shape = $slide.Shapes.Item([int]$op.shape)
2740
+ $effects = @{ appear = 1; fly = 2; fade = 10; wipe = 22; zoom = 23; float = 30 }
2741
+ $triggers = @{ onclick = 1; withprevious = 2; afterprevious = 3 }
2742
+ $effectKey = ([string]$op.effect).Replace(' ', '').ToLowerInvariant()
2743
+ $triggerKey = ([string]$op.trigger).Replace(' ', '').ToLowerInvariant()
2744
+ $effectType = if ($effects.ContainsKey($effectKey)) { [int]$effects[$effectKey] } elseif ($op.effect -as [int]) { [int]$op.effect } else { 10 }
2745
+ $triggerType = if ($triggers.ContainsKey($triggerKey)) { [int]$triggers[$triggerKey] } elseif ($op.trigger -as [int]) { [int]$op.trigger } else { 1 }
2746
+ $effect = $slide.TimeLine.MainSequence.AddEffect($shape, $effectType, 0, $triggerType)
2747
+ if ($null -ne $op.duration) { $effect.Timing.Duration = [single]$op.duration }
2748
+ if ($null -ne $op.delay) { $effect.Timing.TriggerDelayTime = [single]$op.delay }
2749
+ return [ordered]@{ op = 'add_animation'; changed = $true; slide = [int]$slide.SlideIndex; shape = [int]$op.shape; effect = $effectType }
2750
+ }
2751
+ 'set_shape' {
2752
+ $slide = Ppt-Slide $presentation $op
2753
+ $shape = $slide.Shapes.Item([int]$op.shape)
2754
+ $props = $op.properties
2755
+ if ($null -ne $props.left) { $shape.Left = [single]$props.left }
2756
+ if ($null -ne $props.top) { $shape.Top = [single]$props.top }
2757
+ if ($props.width) { $shape.Width = [single]$props.width }
2758
+ if ($props.height) { $shape.Height = [single]$props.height }
2759
+ if ($null -ne $props.rotation) { $shape.Rotation = [single]$props.rotation }
2760
+ if ($props.fillColor) { $shape.Fill.Visible = $true; $shape.Fill.ForeColor.RGB = Color-Value ([string]$props.fillColor) }
2761
+ if ($null -ne $props.fillTransparency) { $shape.Fill.Transparency = [single]$props.fillTransparency }
2762
+ if ($props.lineColor) { $shape.Line.Visible = $true; $shape.Line.ForeColor.RGB = Color-Value ([string]$props.lineColor) }
2763
+ if ($null -ne $props.lineTransparency) { $shape.Line.Transparency = [single]$props.lineTransparency }
2764
+ if ($props.shadow) {
2765
+ $shape.Shadow.Visible = -1
2766
+ if ($props.shadow.color) { $shape.Shadow.ForeColor.RGB = Color-Value ([string]$props.shadow.color) }
2767
+ if ($null -ne $props.shadow.transparency) { $shape.Shadow.Transparency = [single]$props.shadow.transparency }
2768
+ if ($null -ne $props.shadow.blur) { $shape.Shadow.Blur = [single]$props.shadow.blur }
2769
+ if ($null -ne $props.shadow.offsetX) { $shape.Shadow.OffsetX = [single]$props.shadow.offsetX }
2770
+ if ($null -ne $props.shadow.offsetY) { $shape.Shadow.OffsetY = [single]$props.shadow.offsetY }
2771
+ }
2772
+ try {
2773
+ if ($null -ne $props.marginLeft) { $shape.TextFrame.MarginLeft = [single]$props.marginLeft }
2774
+ if ($null -ne $props.marginTop) { $shape.TextFrame.MarginTop = [single]$props.marginTop }
2775
+ if ($null -ne $props.marginRight) { $shape.TextFrame.MarginRight = [single]$props.marginRight }
2776
+ if ($null -ne $props.marginBottom) { $shape.TextFrame.MarginBottom = [single]$props.marginBottom }
2777
+ if ($null -ne $props.paragraphSpacing) { $shape.TextFrame.TextRange.ParagraphFormat.SpaceAfter = [single]$props.paragraphSpacing }
2778
+ $font = $shape.TextFrame.TextRange.Font
2779
+ if ($props.fontName) { $font.Name = [string]$props.fontName }
2780
+ if ($props.fontSize) { $font.Size = [single]$props.fontSize }
2781
+ if ($null -ne $props.bold) { $font.Bold = if ($props.bold) { -1 } else { 0 } }
2782
+ if ($null -ne $props.italic) { $font.Italic = if ($props.italic) { -1 } else { 0 } }
2783
+ if ($props.color) { $font.Color.RGB = Color-Value ([string]$props.color) }
2784
+ } catch {}
2785
+ return [ordered]@{ op = 'set_shape'; changed = $true; shape = [string]$shape.Name }
2786
+ }
2787
+ 'duplicate_slide' {
2788
+ $slides = $presentation.Slides.Item([int]$op.slide).Duplicate()
2789
+ $slide = $slides.Item(1)
2790
+ if ($op.index) { $slide.MoveTo([int]$op.index) }
2791
+ return [ordered]@{ op = 'duplicate_slide'; changed = $true; slide = [int]$slide.SlideIndex }
2792
+ }
2793
+ 'import_slides' {
2794
+ if ([int]$presentation.Slides.Count -eq 0) {
2795
+ throw 'import_slides cannot target a zero-slide presentation; import as the first batch of a new background deck'
2796
+ }
2797
+ $after = if ($null -ne $op.after) { [int]$op.after } else { [int]$presentation.Slides.Count }
2798
+ $inserted = 0
2799
+ $sourcePath = [string]$op.path
2800
+ $openPresentations = $presentation.Application.Presentations
2801
+ for ($candidateIndex = 1; $candidateIndex -le [int]$openPresentations.Count; $candidateIndex++) {
2802
+ $candidate = $openPresentations.Item($candidateIndex)
2803
+ try {
2804
+ if (
2805
+ -not [object]::ReferenceEquals($candidate, $presentation) -and
2806
+ [string]::Equals(
2807
+ [System.IO.Path]::GetFullPath([string]$candidate.FullName),
2808
+ [System.IO.Path]::GetFullPath($sourcePath),
2809
+ [System.StringComparison]::OrdinalIgnoreCase
2810
+ )
2811
+ ) {
2812
+ throw 'import_slides requires the saved source deck to be closed before import'
2813
+ }
2814
+ } catch {
2815
+ if ($_.Exception.Message -match '^import_slides requires') { throw }
2816
+ }
2817
+ }
2818
+ if ($op.slides) {
2819
+ foreach ($sourceSlide in @($op.slides)) {
2820
+ $count = [int]$presentation.Slides.InsertFromFile($sourcePath, $after + $inserted, [int]$sourceSlide, [int]$sourceSlide)
2821
+ $inserted += $count
2822
+ }
2823
+ } else {
2824
+ $inserted = [int]$presentation.Slides.InsertFromFile($sourcePath, $after)
2825
+ }
2826
+ return [ordered]@{ op = 'import_slides'; changed = $inserted -gt 0; count = $inserted; after = $after; source = $sourcePath }
2827
+ }
2828
+ 'keep_slides' {
2829
+ $keep = @{}
2830
+ foreach ($slideNumber in @($op.slides)) { $keep[[int]$slideNumber] = $true }
2831
+ $removed = 0
2832
+ for ($index = $presentation.Slides.Count; $index -ge 1; $index--) {
2833
+ if (-not $keep.ContainsKey($index)) { $presentation.Slides.Item($index).Delete(); $removed++ }
2834
+ }
2835
+ return [ordered]@{ op = 'keep_slides'; changed = $removed -gt 0; removed = $removed; remaining = [int]$presentation.Slides.Count }
2836
+ }
2837
+ 'set_slide_background' {
2838
+ $slide = Ppt-Slide $presentation $op
2839
+ $slide.FollowMasterBackground = $false
2840
+ $slide.Background.Fill.Visible = $true
2841
+ $slide.Background.Fill.ForeColor.RGB = Color-Value ([string]$op.color)
2842
+ return [ordered]@{ op = 'set_slide_background'; changed = $true }
2843
+ }
2844
+ 'set_layout' {
2845
+ $slide = Ppt-Slide $presentation $op
2846
+ $layout = PowerPoint-Layout $presentation $op.layout
2847
+ $slide.CustomLayout = $layout.Layout
2848
+ return [ordered]@{ op = 'set_layout'; changed = $true; layout = [int]$layout.Index; layoutName = [string]$layout.Layout.Name }
2849
+ }
2850
+ 'add_chart' {
2851
+ $slide = Ppt-Slide $presentation $op
2852
+ if ($presentation.Windows.Count -eq 0) {
2853
+ $window = $presentation.NewWindow()
2854
+ try { $window.WindowState = 2 } catch {}
2855
+ }
2856
+ $chartTypes = @{ column = 51; bar = 57; line = 4; pie = 5; area = 1; scatter = -4169 }
2857
+ $kind = ([string]$op.chartType).ToLowerInvariant()
2858
+ $chartType = if ($kind -and $chartTypes.ContainsKey($kind)) { [int]$chartTypes[$kind] } elseif ($op.chartType -as [int]) { [int]$op.chartType } else { 51 }
2859
+ $left = if ($null -ne $op.left) { [single]$op.left } else { [single]72 }
2860
+ $top = if ($null -ne $op.top) { [single]$op.top } else { [single]72 }
2861
+ $width = if ($op.width) { [single]$op.width } else { [single]480 }
2862
+ $height = if ($op.height) { [single]$op.height } else { [single]280 }
2863
+ $shape = $slide.Shapes.AddChart2(-1, $chartType, $left, $top, $width, $height)
2864
+ $chartResult = $null
2865
+ $seriesProperty = $op.PSObject.Properties['series']
2866
+ if ($null -ne $seriesProperty -and $null -ne $seriesProperty.Value) {
2867
+ $chartResult = Set-PowerPointChartData $shape.Chart $op.categories $seriesProperty.Value
2868
+ }
2869
+ if ($op.title) { $shape.Chart.HasTitle = $true; $shape.Chart.ChartTitle.Text = [string]$op.title }
2870
+ return [ordered]@{
2871
+ op = 'add_chart'
2872
+ changed = $true
2873
+ shape = [string]$shape.Name
2874
+ categories = $(if ($null -ne $chartResult) { [int]$chartResult.categories } else { 0 })
2875
+ series = $(if ($null -ne $chartResult) { [int]$chartResult.series } else { [int]$shape.Chart.SeriesCollection().Count })
2876
+ }
2877
+ }
2878
+ 'set_chart_data' {
2879
+ $slide = Ppt-Slide $presentation $op
2880
+ $shape = $slide.Shapes.Item([int]$op.shape)
2881
+ if (-not $shape.HasChart) { throw "Shape $($op.shape) is not a chart" }
2882
+ $chart = $shape.Chart
2883
+ $chartResult = Set-PowerPointChartData $chart $op.categories $op.series
2884
+ if ($op.title) { $chart.HasTitle = $true; $chart.ChartTitle.Text = [string]$op.title }
2885
+ return [ordered]@{ op = 'set_chart_data'; changed = $true; shape = [int]$op.shape; categories = [int]$chartResult.categories; series = [int]$chartResult.series }
2886
+ }
2887
+ 'set_chart_series' {
2888
+ $slide = Ppt-Slide $presentation $op
2889
+ $shape = $slide.Shapes.Item([int]$op.shape)
2890
+ if (-not $shape.HasChart) { throw "Shape $($op.shape) is not a chart" }
2891
+ $series = $shape.Chart.SeriesCollection().Item([int]$op.series)
2892
+ if ($null -ne $op.name) { $series.Name = [string]$op.name }
2893
+ if ($null -ne $op.categories) { $series.XValues = [object[]]@($op.categories) }
2894
+ if ($null -ne $op.values) { $series.Values = [object[]]@($op.values) }
2895
+ if ($null -ne $op.secondaryAxis) { $series.AxisGroup = $(if ([bool]$op.secondaryAxis) { 2 } else { 1 }) }
2896
+ if ($null -ne $op.chartType) {
2897
+ $chartTypes = @{ column = 51; bar = 57; line = 4; pie = 5; area = 1; scatter = -4169 }
2898
+ $kind = ([string]$op.chartType).ToLowerInvariant()
2899
+ $series.ChartType = $(if ($chartTypes.ContainsKey($kind)) { [int]$chartTypes[$kind] } elseif ($op.chartType -as [int]) { [int]$op.chartType } else { throw "Unknown chart type: $kind" })
2900
+ }
2901
+ return [ordered]@{ op = 'set_chart_series'; changed = $true; shape = [int]$op.shape; series = [int]$op.series }
2902
+ }
2903
+ 'set_chart_axis' {
2904
+ $slide = Ppt-Slide $presentation $op
2905
+ $shape = $slide.Shapes.Item([int]$op.shape)
2906
+ if (-not $shape.HasChart) { throw "Shape $($op.shape) is not a chart" }
2907
+ $axisType = if ([string]$op.axis -eq 'category') { 1 } else { 2 }
2908
+ $axis = $shape.Chart.Axes($axisType, $(if ([bool]$op.secondaryAxis) { 2 } else { 1 }))
2909
+ if ($null -ne $op.title) { $axis.HasTitle = $true; $axis.AxisTitle.Text = [string]$op.title }
2910
+ if ($null -ne $op.minimum) { $axis.MinimumScale = [double]$op.minimum }
2911
+ if ($null -ne $op.maximum) { $axis.MaximumScale = [double]$op.maximum }
2912
+ if ($null -ne $op.majorUnit) { $axis.MajorUnit = [double]$op.majorUnit }
2913
+ if ($op.numberFormat) { $axis.TickLabels.NumberFormat = [string]$op.numberFormat }
2914
+ return [ordered]@{ op = 'set_chart_axis'; changed = $true; shape = [int]$op.shape; axis = [string]$op.axis }
2915
+ }
2916
+ 'set_chart_trendline' {
2917
+ $slide = Ppt-Slide $presentation $op
2918
+ $shape = $slide.Shapes.Item([int]$op.shape)
2919
+ if (-not $shape.HasChart) { throw "Shape $($op.shape) is not a chart" }
2920
+ $seriesIndex = if ($op.series) { [int]$op.series } else { 1 }
2921
+ $types = @{ exponential = 5; linear = -4132; logarithmic = -4133; movingaverage = 6; polynomial = 3; power = 4 }
2922
+ $kind = ([string]$op.type).Replace(' ', '').ToLowerInvariant()
2923
+ $type = if ($types.ContainsKey($kind)) { [int]$types[$kind] } elseif ($op.type -as [int]) { [int]$op.type } else { -4132 }
2924
+ $trendline = $shape.Chart.SeriesCollection().Item($seriesIndex).Trendlines().Add($type)
2925
+ if ($null -ne $op.displayEquation) { $trendline.DisplayEquation = [bool]$op.displayEquation }
2926
+ if ($null -ne $op.displayRSquared) { $trendline.DisplayRSquared = [bool]$op.displayRSquared }
2927
+ return [ordered]@{ op = 'set_chart_trendline'; changed = $true; shape = [int]$op.shape; series = $seriesIndex; type = $type }
2928
+ }
2929
+ 'set_chart_error_bars' {
2930
+ $slide = Ppt-Slide $presentation $op
2931
+ $shape = $slide.Shapes.Item([int]$op.shape)
2932
+ if (-not $shape.HasChart) { throw "Shape $($op.shape) is not a chart" }
2933
+ $seriesIndex = if ($op.series) { [int]$op.series } else { 1 }
2934
+ $series = $shape.Chart.SeriesCollection().Item($seriesIndex)
2935
+ $direction = if ([string]$op.direction -eq 'x') { -4168 } else { 1 }
2936
+ $series.ErrorBar($direction, 1, 1, [double]$(if ($null -ne $op.amount) { $op.amount } else { 1 }))
2937
+ try { $series.ErrorBars.EndStyle = $(if ($op.endStyle -eq 'none') { 0 } else { 1 }) } catch {}
2938
+ return [ordered]@{ op = 'set_chart_error_bars'; changed = $true; shape = [int]$op.shape; series = $seriesIndex }
2939
+ }
2940
+ 'set_chart_data_labels' {
2941
+ $slide = Ppt-Slide $presentation $op
2942
+ $shape = $slide.Shapes.Item([int]$op.shape)
2943
+ if (-not $shape.HasChart) { throw "Shape $($op.shape) is not a chart" }
2944
+ $series = $shape.Chart.SeriesCollection().Item([int]$op.series)
2945
+ $null = $series.ApplyDataLabels()
2946
+ $labels = $series.DataLabels()
2947
+ if ($null -ne $op.showValue) { $labels.ShowValue = [bool]$op.showValue }
2948
+ if ($null -ne $op.showCategoryName) { $labels.ShowCategoryName = [bool]$op.showCategoryName }
2949
+ if ($op.numberFormat) { $labels.NumberFormat = [string]$op.numberFormat }
2950
+ if ($null -ne $op.position) { $labels.Position = [int]$op.position }
2951
+ return [ordered]@{ op = 'set_chart_data_labels'; changed = $true; shape = [int]$op.shape; series = [int]$op.series }
2952
+ }
2953
+ 'fit_text' {
2954
+ $slide = Ppt-Slide $presentation $op
2955
+ $shape = $slide.Shapes.Item([int]$op.shape)
2956
+ $minimum = if ($op.minFontSize) { [single]$op.minFontSize } else { [single]8 }
2957
+ try { $shape.TextFrame.AutoSize = 0 } catch {}
2958
+ try { $shape.TextFrame2.AutoSize = 0 } catch {}
2959
+ $maximumWidth = [single]$presentation.PageSetup.SlideWidth - [single]$shape.Left
2960
+ $maximumHeight = [single]$presentation.PageSetup.SlideHeight - [single]$shape.Top
2961
+ if ($shape.Width -gt $maximumWidth) { $shape.Width = $maximumWidth }
2962
+ if ($shape.Height -gt $maximumHeight) { $shape.Height = $maximumHeight }
2963
+ $font = $shape.TextFrame.TextRange.Font
2964
+ while ($font.Size -gt $minimum -and (
2965
+ [single]$shape.TextFrame2.TextRange.BoundWidth -gt ([single]$shape.Width + 1) -or
2966
+ [single]$shape.TextFrame2.TextRange.BoundHeight -gt ([single]$shape.Height + 1)
2967
+ )) {
2968
+ $font.Size = [single]$font.Size - 1
2969
+ }
2970
+ return [ordered]@{ op = 'fit_text'; changed = $true; shape = [int]$op.shape; fontSize = [single]$font.Size }
2971
+ }
2972
+ default { throw "Unsupported PPTX operation: $($op.op)" }
2973
+ }
2974
+ }
2975
+
2976
+ function Apply-Operations($document, [string]$format, $operations, [bool]$live, [bool]$requireChanges = $true) {
2977
+ $results = @()
2978
+ $undoUnits = 0
2979
+ $wordUndoRecord = $null
2980
+ $wordRecordStarted = $false
2981
+ $failure = $null
2982
+ $beforeFingerprint = ''
2983
+ if ($live -and @('docx', 'pptx') -contains $format) {
2984
+ $beforeFingerprint = Snapshot-Fingerprint (Snapshot-Document $document $format ([ordered]@{}))
2985
+ }
2986
+ if ($live -and $format -eq 'docx') {
2987
+ try {
2988
+ $wordUndoRecord = $document.Application.UndoRecord
2989
+ $wordUndoRecord.StartCustomRecord('Mixdog Office transaction')
2990
+ $wordRecordStarted = $true
2991
+ } catch {}
2992
+ }
2993
+ try {
2994
+ foreach ($op in @($operations)) {
2995
+ if ($live -and $format -eq 'pptx') {
2996
+ try { $document.Application.StartNewUndoEntry() } catch {}
2997
+ }
2998
+ $emitted = @(switch ($format) {
2999
+ 'docx' { Apply-WordOperation $document $op }
3000
+ 'xlsx' { Apply-ExcelOperation $document $op }
3001
+ 'pptx' { Apply-PowerPointOperation $document $op $live }
3002
+ })
3003
+ $structured = @($emitted | Where-Object { $_ -is [System.Collections.IDictionary] } | Select-Object -Last 1)
3004
+ if ($structured.Count -ne 1) { throw "$($op.op) returned no structured operation result" }
3005
+ $entry = $structured[0]
3006
+ if ($requireChanges -and -not [bool]$op.allowNoChange -and $entry.Contains('changed') -and -not [bool]$entry.changed) {
3007
+ throw "$($op.op) produced no change"
3008
+ }
3009
+ $results += $entry
3010
+ if ($live -and $format -eq 'pptx') { $undoUnits++ }
3011
+ }
3012
+ } catch {
3013
+ $line = [int]$_.InvocationInfo.ScriptLineNumber
3014
+ $failure = "$($op.op) failed at office-com-host.ps1:$line`: $($_.Exception.Message)"
3015
+ } finally {
3016
+ if ($wordRecordStarted) {
3017
+ try { $wordUndoRecord.EndCustomRecord() } catch {}
3018
+ }
3019
+ }
3020
+ if ($failure) {
3021
+ if ($live -and $format -eq 'docx' -and $wordRecordStarted) {
3022
+ try { $null = $document.Undo(1) } catch {}
3023
+ } elseif ($live -and $format -eq 'pptx') {
3024
+ $attempts = 0
3025
+ $maximumAttempts = @($operations).Count + 5
3026
+ while ($attempts -lt $maximumAttempts) {
3027
+ $currentFingerprint = Snapshot-Fingerprint (Snapshot-Document $document $format ([ordered]@{}))
3028
+ if ($currentFingerprint -eq $beforeFingerprint) { break }
3029
+ try { $document.Application.CommandBars.ExecuteMso('Undo') } catch { break }
3030
+ $attempts++
3031
+ }
3032
+ }
3033
+ throw $failure
3034
+ }
3035
+ if ($live -and $format -eq 'docx' -and @($operations).Count -gt 0) { $undoUnits = 1 }
3036
+ if ($live -and $format -eq 'xlsx' -and @($operations).Count -gt 0) { $undoUnits = 1 }
3037
+ return [ordered]@{ results = $results; undoUnits = $undoUnits }
3038
+ }
3039
+
3040
+ function Office-Issue([string]$severity, [string]$code, [string]$path, [string]$message) {
3041
+ return [ordered]@{ severity = $severity; code = $code; path = $path; message = $message }
3042
+ }
3043
+
3044
+ function Installed-OfficeFonts {
3045
+ $fonts = @{}
3046
+ try {
3047
+ Add-Type -AssemblyName System.Drawing
3048
+ $collection = [System.Drawing.Text.InstalledFontCollection]::new()
3049
+ foreach ($family in @($collection.Families)) { $fonts[[string]$family.Name] = $true }
3050
+ $collection.Dispose()
3051
+ } catch {}
3052
+ return $fonts
3053
+ }
3054
+
3055
+ function Missing-FontIssue($fonts, [string]$name, [string]$path) {
3056
+ if ([string]::IsNullOrWhiteSpace($name) -or $name.StartsWith('+') -or $fonts.ContainsKey($name)) { return $null }
3057
+ return Office-Issue 'warning' 'missing_font' $path "Font is not installed: $name"
3058
+ }
3059
+
3060
+ function Excel-MatrixValue($matrix, [int]$row, [int]$column) {
3061
+ if ($matrix -is [Array] -and $matrix.Rank -eq 2) {
3062
+ return $matrix.GetValue(
3063
+ $matrix.GetLowerBound(0) + $row - 1,
3064
+ $matrix.GetLowerBound(1) + $column - 1
3065
+ )
3066
+ }
3067
+ if ($row -eq 1 -and $column -eq 1) { return $matrix }
3068
+ return $null
3069
+ }
3070
+
3071
+ function Excel-ColumnLetters([int]$column) {
3072
+ $letters = ''
3073
+ while ($column -gt 0) {
3074
+ $column--
3075
+ $letters = [char](65 + ($column % 26)) + $letters
3076
+ $column = [Math]::Floor($column / 26)
3077
+ }
3078
+ return $letters
3079
+ }
3080
+
3081
+ function Excel-RangeCellAddress($range, [int]$row, [int]$column) {
3082
+ $absoluteRow = [int]$range.Row + $row - 1
3083
+ $absoluteColumn = [int]$range.Column + $column - 1
3084
+ return "$(Excel-ColumnLetters $absoluteColumn)$absoluteRow"
3085
+ }
3086
+
3087
+ function Issues-Word($doc) {
3088
+ $issues = @()
3089
+ $fonts = Installed-OfficeFonts
3090
+ if ($doc.Revisions.Count -gt 0) { $issues += Office-Issue 'info' 'unresolved_revisions' '/body' "$($doc.Revisions.Count) tracked revision(s) remain unresolved." }
3091
+ if ($doc.Comments.Count -gt 0) { $issues += Office-Issue 'info' 'unresolved_comments' '/body' "$($doc.Comments.Count) comment(s) remain in the document." }
3092
+ $fieldIndex = 0
3093
+ foreach ($field in @($doc.Fields)) {
3094
+ $fieldIndex++
3095
+ try {
3096
+ $text = ([string]$field.Result.Text).Trim()
3097
+ if ($text -match 'Error!|Reference source not found') { $issues += Office-Issue 'error' 'field_error' "/body/field[$fieldIndex]" $text }
3098
+ } catch {}
3099
+ }
3100
+ for ($paragraphIndex = 1; $paragraphIndex -le $doc.Paragraphs.Count; $paragraphIndex++) {
3101
+ try {
3102
+ $paragraph = $doc.Paragraphs.Item($paragraphIndex)
3103
+ $path = "/body/p[$paragraphIndex]"
3104
+ $fontIssue = Missing-FontIssue $fonts ([string]$paragraph.Range.Font.Name) $path
3105
+ if ($fontIssue) { $issues += $fontIssue }
3106
+ $text = ([string]$paragraph.Range.Text).TrimEnd("`r", "`a")
3107
+ $fontSize = [single]$paragraph.Range.Font.Size
3108
+ if ($text.Length -gt 180 -and $fontSize -ge 18) {
3109
+ $issues += Office-Issue 'warning' 'oversized_heading_text' $path 'A long paragraph uses heading-sized text and is likely mis-styled.'
3110
+ }
3111
+ } catch {}
3112
+ }
3113
+ for ($tableIndex = 1; $tableIndex -le $doc.Tables.Count; $tableIndex++) {
3114
+ try {
3115
+ $table = $doc.Tables.Item($tableIndex)
3116
+ $width = 0
3117
+ foreach ($column in @($table.Columns)) { $width += [double]$column.Width }
3118
+ $page = $table.Range.Sections.Item(1).PageSetup
3119
+ $available = [double]$page.PageWidth - [double]$page.LeftMargin - [double]$page.RightMargin
3120
+ if ($width -gt ($available + 1)) {
3121
+ $issues += Office-Issue 'warning' 'table_width' "/body/tbl[$tableIndex]" "Table width $([Math]::Round($width, 1))pt exceeds available page width $([Math]::Round($available, 1))pt."
3122
+ }
3123
+ } catch {}
3124
+ }
3125
+ return $issues
3126
+ }
3127
+
3128
+ function Add-CappedOfficeIssue($issues, [ref]$omitted, $issue, [int]$limit = 500) {
3129
+ if ($issues.Count -lt $limit) {
3130
+ [void]$issues.Add($issue)
3131
+ } else {
3132
+ $omitted.Value = [int]$omitted.Value + 1
3133
+ }
3134
+ }
3135
+
3136
+ function Inspect-ExcelFinancialRange($sheet, $used, $fonts, $commentAddresses) {
3137
+ $issues = [System.Collections.ArrayList]::new()
3138
+ $omitted = 0
3139
+ $rowCount = [int]$used.Rows.Count
3140
+ $columnCount = [int]$used.Columns.Count
3141
+ $totalCells = [int64]$rowCount * [int64]$columnCount
3142
+ $targetChunkCells = 20000
3143
+ $rowsPerBlock = [Math]::Min(2000, [Math]::Max(1, [Math]::Floor($targetChunkCells / [Math]::Max(1, [Math]::Min($columnCount, $targetChunkCells)))))
3144
+ $scannedCells = [int64]0
3145
+ $chunks = 0
3146
+ $visualProbeCount = 0
3147
+ $checksSheet = [string]::Equals([string]$sheet.Name, 'Checks', [System.StringComparison]::OrdinalIgnoreCase)
3148
+
3149
+ for ($rowStart = 1; $rowStart -le $rowCount; $rowStart += $rowsPerBlock) {
3150
+ $rowsThisBlock = [Math]::Min($rowsPerBlock, $rowCount - $rowStart + 1)
3151
+ $states = @()
3152
+ for ($localRow = 1; $localRow -le $rowsThisBlock; $localRow++) {
3153
+ $states += [pscustomobject]@{
3154
+ FormulaCount = 0
3155
+ FirstFormula = [int]::MaxValue
3156
+ LastFormula = 0
3157
+ Values = [System.Collections.ArrayList]::new()
3158
+ }
3159
+ }
3160
+ $columnsPerBlock = [Math]::Max(1, [Math]::Floor($targetChunkCells / $rowsThisBlock))
3161
+ for ($columnStart = 1; $columnStart -le $columnCount; $columnStart += $columnsPerBlock) {
3162
+ $columnsThisBlock = [Math]::Min($columnsPerBlock, $columnCount - $columnStart + 1)
3163
+ $chunk = $used.Cells.Item($rowStart, $columnStart).Resize($rowsThisBlock, $columnsThisBlock)
3164
+ $values = $chunk.Value2
3165
+ $formulas = $chunk.FormulaR1C1
3166
+ $chunks++
3167
+ $scannedCells += [int64]$rowsThisBlock * [int64]$columnsThisBlock
3168
+ for ($localRow = 1; $localRow -le $rowsThisBlock; $localRow++) {
3169
+ $state = $states[$localRow - 1]
3170
+ for ($localColumn = 1; $localColumn -le $columnsThisBlock; $localColumn++) {
3171
+ $value = Excel-MatrixValue $values $localRow $localColumn
3172
+ $formula = Excel-MatrixValue $formulas $localRow $localColumn
3173
+ $hasFormula = [string]$formula -match '^='
3174
+ $absoluteRow = [int]$used.Row + $rowStart + $localRow - 2
3175
+ $absoluteColumn = [int]$used.Column + $columnStart + $localColumn - 2
3176
+ $address = "$(Excel-ColumnLetters $absoluteColumn)$absoluteRow"
3177
+ $path = "/sheet[$($sheet.Name)]/cell[$address]"
3178
+ if ($hasFormula) {
3179
+ $state.FormulaCount++
3180
+ $state.FirstFormula = [Math]::Min([int]$state.FirstFormula, $absoluteColumn)
3181
+ $state.LastFormula = [Math]::Max([int]$state.LastFormula, $absoluteColumn)
3182
+ } elseif ($null -ne $value -and -not [string]::IsNullOrWhiteSpace([string]$value)) {
3183
+ [void]$state.Values.Add([pscustomobject]@{
3184
+ Address = $address
3185
+ Path = $path
3186
+ Column = $absoluteColumn
3187
+ Numeric = $value -is [ValueType] -and $value -isnot [bool]
3188
+ })
3189
+ }
3190
+ if ($checksSheet -and $value -is [bool] -and -not [bool]$value) {
3191
+ Add-CappedOfficeIssue $issues ([ref]$omitted) (Office-Issue 'error' 'failed_check' $path 'A formula or explicit check on the Checks sheet evaluates to FALSE.')
3192
+ }
3193
+ if (($null -ne $value -or $hasFormula) -and $visualProbeCount -lt 32) {
3194
+ $visualProbeCount++
3195
+ $cell = $chunk.Cells.Item($localRow, $localColumn)
3196
+ if ([string]$cell.Text -match '^#{3,}$') {
3197
+ Add-CappedOfficeIssue $issues ([ref]$omitted) (Office-Issue 'warning' 'cell_overflow' $path 'Displayed value is clipped because the column is too narrow.')
3198
+ }
3199
+ $fontIssue = Missing-FontIssue $fonts ([string]$cell.Font.Name) $path
3200
+ if ($fontIssue) { Add-CappedOfficeIssue $issues ([ref]$omitted) $fontIssue }
3201
+ }
3202
+ }
3203
+ }
3204
+ }
3205
+ foreach ($state in $states) {
3206
+ foreach ($entry in @($state.Values)) {
3207
+ if ($entry.Numeric) {
3208
+ if (-not $commentAddresses.ContainsKey([string]$entry.Address)) {
3209
+ Add-CappedOfficeIssue $issues ([ref]$omitted) (Office-Issue 'warning' 'hardcode_missing_source' ([string]$entry.Path) 'Hardcoded numeric input has no source comment.')
3210
+ }
3211
+ if ([int]$state.FormulaCount -gt 0) {
3212
+ Add-CappedOfficeIssue $issues ([ref]$omitted) (Office-Issue 'warning' 'rogue_hardcode' ([string]$entry.Path) 'Numeric hardcode appears inside a row that otherwise contains formulas.')
3213
+ }
3214
+ }
3215
+ if ([int]$state.FormulaCount -ge 3 -and [int]$entry.Column -gt [int]$state.FirstFormula -and [int]$entry.Column -lt [int]$state.LastFormula) {
3216
+ Add-CappedOfficeIssue $issues ([ref]$omitted) (Office-Issue 'warning' 'formula_inconsistency' ([string]$entry.Path) 'A hardcoded value interrupts a row containing three or more formulas.')
3217
+ }
3218
+ }
3219
+ }
3220
+ }
3221
+ if ($omitted -gt 0) {
3222
+ [void]$issues.Add((Office-Issue 'warning' 'audit_issues_truncated' "/sheet[$($sheet.Name)]" "$omitted additional financial-model audit issue(s) were omitted after the 500-issue response cap."))
3223
+ }
3224
+ return [ordered]@{
3225
+ issues = [object[]]$issues.ToArray()
3226
+ coverage = [ordered]@{
3227
+ sheet = [string]$sheet.Name
3228
+ range = [string]$used.Address($false, $false)
3229
+ rows = $rowCount
3230
+ columns = $columnCount
3231
+ totalCells = $totalCells
3232
+ scannedCells = $scannedCells
3233
+ chunks = $chunks
3234
+ complete = $scannedCells -eq $totalCells
3235
+ }
3236
+ }
3237
+ }
3238
+
3239
+ function Issues-Excel($book, $payload) {
3240
+ $issues = @()
3241
+ $coverage = @()
3242
+ $fonts = Installed-OfficeFonts
3243
+ $financialAudit = $payload.auditProfile -eq 'financial-model'
3244
+ if ($financialAudit) {
3245
+ $hasChecks = $false
3246
+ foreach ($candidate in @($book.Worksheets)) {
3247
+ if ([string]::Equals([string]$candidate.Name, 'Checks', [System.StringComparison]::OrdinalIgnoreCase)) { $hasChecks = $true; break }
3248
+ }
3249
+ if (-not $hasChecks) { $issues += Office-Issue 'warning' 'missing_checks_sheet' '/' 'Financial-model audit expects a Checks sheet with explicit tie-out formulas.' }
3250
+ }
3251
+ foreach ($sheet in @($book.Worksheets)) {
3252
+ if ($payload.sheet -and -not [string]::Equals([string]$payload.sheet, [string]$sheet.Name, [System.StringComparison]::OrdinalIgnoreCase)) { continue }
3253
+ $used = $sheet.UsedRange
3254
+ if ($payload.range) { $used = $sheet.Range([string]$payload.range) }
3255
+ if ($financialAudit) {
3256
+ $commentAddresses = @{}
3257
+ try {
3258
+ $commentCells = $used.SpecialCells(-4144)
3259
+ foreach ($commentCell in @($commentCells.Cells)) {
3260
+ $commentAddresses[[string]$commentCell.Address($false, $false)] = $true
3261
+ }
3262
+ } catch {}
3263
+ $inspection = Inspect-ExcelFinancialRange $sheet $used $fonts $commentAddresses
3264
+ $issues += @($inspection.issues)
3265
+ $coverage += $inspection.coverage
3266
+ } else {
3267
+ $columns = [Math]::Min([int]$used.Columns.Count, 100)
3268
+ $sampleRows = [Math]::Min([int]$used.Rows.Count, [Math]::Max(1, [Math]::Floor(500 / [Math]::Max(1, $columns))))
3269
+ $sample = $used.Resize($sampleRows, $columns)
3270
+ $sampleValues = $sample.Value2
3271
+ $sampleFormulas = $sample.FormulaR1C1
3272
+ $visualProbeCount = 0
3273
+ for ($r = 1; $r -le $sampleRows; $r++) {
3274
+ for ($c = 1; $c -le $columns; $c++) {
3275
+ $value = Excel-MatrixValue $sampleValues $r $c
3276
+ $formula = Excel-MatrixValue $sampleFormulas $r $c
3277
+ if (($null -ne $value -or [string]$formula -match '^=') -and $visualProbeCount -lt 32) {
3278
+ $visualProbeCount++
3279
+ $cell = $sample.Cells.Item($r, $c)
3280
+ $path = "/sheet[$($sheet.Name)]/cell[$(Excel-RangeCellAddress $used $r $c)]"
3281
+ if ([string]$cell.Text -match '^#{3,}$') { $issues += Office-Issue 'warning' 'cell_overflow' $path 'Displayed value is clipped because the column is too narrow.' }
3282
+ $fontIssue = Missing-FontIssue $fonts ([string]$cell.Font.Name) $path
3283
+ if ($fontIssue) { $issues += $fontIssue }
3284
+ }
3285
+ }
3286
+ }
3287
+ $coverage += [ordered]@{
3288
+ sheet = [string]$sheet.Name
3289
+ range = [string]$used.Address($false, $false)
3290
+ rows = [int]$used.Rows.Count
3291
+ columns = [int]$used.Columns.Count
3292
+ totalCells = [int64]$used.Rows.Count * [int64]$used.Columns.Count
3293
+ scannedCells = [int64]$sampleRows * [int64]$columns
3294
+ complete = [int64]$sampleRows * [int64]$columns -eq [int64]$used.Rows.Count * [int64]$used.Columns.Count
3295
+ }
3296
+ try {
3297
+ $formulaMatrix = $used.FormulaR1C1
3298
+ $inconsistencies = 0
3299
+ if ($formulaMatrix -is [Array] -and $formulaMatrix.Rank -eq 2) {
3300
+ $rowLower = $formulaMatrix.GetLowerBound(0)
3301
+ $rowUpper = $formulaMatrix.GetUpperBound(0)
3302
+ $columnLower = $formulaMatrix.GetLowerBound(1)
3303
+ $columnUpper = $formulaMatrix.GetUpperBound(1)
3304
+ for ($matrixRow = $rowLower; $matrixRow -le $rowUpper -and $inconsistencies -lt 100; $matrixRow++) {
3305
+ $formulaColumns = @()
3306
+ for ($matrixColumn = $columnLower; $matrixColumn -le $columnUpper; $matrixColumn++) {
3307
+ if ([string]$formulaMatrix.GetValue($matrixRow, $matrixColumn) -match '^=') { $formulaColumns += $matrixColumn }
3308
+ }
3309
+ if ($formulaColumns.Count -lt 3) { continue }
3310
+ $firstFormula = ($formulaColumns | Measure-Object -Minimum).Minimum
3311
+ $lastFormula = ($formulaColumns | Measure-Object -Maximum).Maximum
3312
+ for ($matrixColumn = $firstFormula; $matrixColumn -le $lastFormula -and $inconsistencies -lt 100; $matrixColumn++) {
3313
+ $entry = [string]$formulaMatrix.GetValue($matrixRow, $matrixColumn)
3314
+ if (-not [string]::IsNullOrWhiteSpace($entry) -and $entry -notmatch '^=') {
3315
+ $cell = $used.Cells.Item($matrixRow - $rowLower + 1, $matrixColumn - $columnLower + 1)
3316
+ $issues += Office-Issue 'warning' 'formula_inconsistency' "/sheet[$($sheet.Name)]/cell[$($cell.Address($false, $false))]" 'A hardcoded value interrupts a row containing three or more formulas.'
3317
+ $inconsistencies++
3318
+ }
3319
+ }
3320
+ }
3321
+ }
3322
+ } catch {}
3323
+ }
3324
+ try {
3325
+ $errorCells = $used.SpecialCells(-4123, 16)
3326
+ $errorCount = [int]$errorCells.Cells.Count
3327
+ $limit = [Math]::Min($errorCount, 1000)
3328
+ for ($errorIndex = 1; $errorIndex -le $limit; $errorIndex++) {
3329
+ $cell = $errorCells.Cells.Item($errorIndex)
3330
+ $text = [string]$cell.Text
3331
+ $issues += Office-Issue 'error' 'formula_error' "/sheet[$($sheet.Name)]/cell[$($cell.Address($false, $false))]" "Cell contains formula error $text"
3332
+ }
3333
+ if ($errorCount -gt $limit) {
3334
+ $issues += Office-Issue 'error' 'formula_error_truncated' "/sheet[$($sheet.Name)]" "$($errorCount - $limit) additional formula error(s) were omitted from the report."
3335
+ }
3336
+ } catch {}
3337
+ for ($chartIndex = 1; $chartIndex -le $sheet.ChartObjects().Count; $chartIndex++) {
3338
+ try {
3339
+ $chart = $sheet.ChartObjects().Item($chartIndex).Chart
3340
+ if ($chart.SeriesCollection().Count -eq 0) {
3341
+ $issues += Office-Issue 'error' 'empty_chart' "/sheet[$($sheet.Name)]/chart[$chartIndex]" 'Chart has no data series.'
3342
+ }
3343
+ } catch {
3344
+ $issues += Office-Issue 'error' 'broken_chart' "/sheet[$($sheet.Name)]/chart[$chartIndex]" 'Chart data could not be inspected.'
3345
+ }
3346
+ }
3347
+ }
3348
+ try {
3349
+ $links = @($book.LinkSources(1))
3350
+ foreach ($link in $links) { if ($link) { $issues += Office-Issue 'warning' 'external_link' '/' "Workbook links to external source: $link" } }
3351
+ } catch {}
3352
+ $totalCells = [int64]0
3353
+ $scannedCells = [int64]0
3354
+ foreach ($entry in $coverage) {
3355
+ $totalCells += [int64]$entry.totalCells
3356
+ $scannedCells += [int64]$entry.scannedCells
3357
+ }
3358
+ return [ordered]@{
3359
+ issues = @($issues)
3360
+ auditCoverage = [ordered]@{
3361
+ mode = $(if ($financialAudit) { 'full' } else { 'sampled' })
3362
+ complete = @($coverage | Where-Object { -not $_.complete }).Count -eq 0
3363
+ totalCells = $totalCells
3364
+ scannedCells = $scannedCells
3365
+ sheets = $coverage
3366
+ }
3367
+ }
3368
+ }
3369
+
3370
+ function Ole-ColorChannel([long]$color, [int]$shift) {
3371
+ return [double](($color -shr $shift) -band 255) / 255
3372
+ }
3373
+
3374
+ function Color-Luminance([long]$color) {
3375
+ $channels = @(
3376
+ (Ole-ColorChannel $color 0),
3377
+ (Ole-ColorChannel $color 8),
3378
+ (Ole-ColorChannel $color 16)
3379
+ )
3380
+ $linear = @()
3381
+ foreach ($channel in $channels) {
3382
+ $linear += $(if ($channel -le 0.03928) { $channel / 12.92 } else { [Math]::Pow(($channel + 0.055) / 1.055, 2.4) })
3383
+ }
3384
+ return (0.2126 * $linear[0]) + (0.7152 * $linear[1]) + (0.0722 * $linear[2])
3385
+ }
3386
+
3387
+ function Color-ContrastRatio([long]$left, [long]$right) {
3388
+ $leftLuminance = Color-Luminance $left
3389
+ $rightLuminance = Color-Luminance $right
3390
+ $lighter = [Math]::Max($leftLuminance, $rightLuminance)
3391
+ $darker = [Math]::Min($leftLuminance, $rightLuminance)
3392
+ return ($lighter + 0.05) / ($darker + 0.05)
3393
+ }
3394
+
3395
+ function Issues-PowerPoint($presentation, $payload) {
3396
+ $issues = @()
3397
+ $fonts = Installed-OfficeFonts
3398
+ foreach ($slide in @($presentation.Slides)) {
3399
+ if ($payload.pages -and -not (@($payload.pages | ForEach-Object { [int]$_ }) -contains [int]$slide.SlideIndex)) { continue }
3400
+ $shapeIndex = 0
3401
+ $textShapeCount = 0
3402
+ $visualShapeCount = 0
3403
+ foreach ($shape in @($slide.Shapes)) {
3404
+ $shapeIndex++
3405
+ $path = "/slide[$($slide.SlideIndex)]/shape[$shapeIndex]"
3406
+ try {
3407
+ if (@(1, 3, 6, 13, 21, 28) -contains [int]$shape.Type -or $shape.HasChart -or $shape.HasTable) { $visualShapeCount++ }
3408
+ } catch {}
3409
+ try {
3410
+ if ($shape.Type -eq 13 -and [string]::IsNullOrWhiteSpace([string]$shape.AlternativeText)) {
3411
+ $issues += Office-Issue 'warning' 'missing_alt_text' $path 'Picture has no alternative text.'
3412
+ }
3413
+ } catch {}
3414
+ try {
3415
+ if ($shape.HasTextFrame -and $shape.TextFrame.HasText) {
3416
+ $textShapeCount++
3417
+ $boundWidth = [single]$shape.TextFrame2.TextRange.BoundWidth
3418
+ $boundHeight = [single]$shape.TextFrame2.TextRange.BoundHeight
3419
+ if ($boundWidth -gt ([single]$shape.Width + 1) -or $boundHeight -gt ([single]$shape.Height + 1)) {
3420
+ $issues += Office-Issue 'warning' 'text_overflow' $path 'Text bounds exceed the containing shape.'
3421
+ }
3422
+ $fontIssue = Missing-FontIssue $fonts ([string]$shape.TextFrame.TextRange.Font.Name) $path
3423
+ if ($fontIssue) { $issues += $fontIssue }
3424
+ $fontSize = [single]$shape.TextFrame.TextRange.Font.Size
3425
+ if ($fontSize -gt 0 -and $fontSize -lt 12) {
3426
+ $issues += Office-Issue 'warning' 'small_font' $path "Text uses $fontSize pt; presentation body text should normally be at least 12 pt."
3427
+ }
3428
+ $slideWidth = [single]$presentation.PageSetup.SlideWidth
3429
+ $slideHeight = [single]$presentation.PageSetup.SlideHeight
3430
+ if ($shape.Left -lt 0 -or $shape.Top -lt 0 -or
3431
+ ([single]$shape.Left + [single]$shape.Width) -gt ($slideWidth + 1) -or
3432
+ ([single]$shape.Top + [single]$shape.Height) -gt ($slideHeight + 1)) {
3433
+ $issues += Office-Issue 'warning' 'text_outside_slide' $path 'Text shape extends outside the slide boundary.'
3434
+ }
3435
+ if ($shape.Left -lt 18 -or $shape.Top -lt 18 -or
3436
+ ([single]$shape.Left + [single]$shape.Width) -gt ($slideWidth - 18) -or
3437
+ ([single]$shape.Top + [single]$shape.Height) -gt ($slideHeight - 18)) {
3438
+ $issues += Office-Issue 'warning' 'edge_margin' $path 'Text is within 18 pt of a slide edge.'
3439
+ }
3440
+ try {
3441
+ if ($shape.Fill.Visible -and [long]$shape.Fill.ForeColor.RGB -ge 0 -and [long]$shape.TextFrame.TextRange.Font.Color.RGB -ge 0) {
3442
+ $contrast = Color-ContrastRatio ([long]$shape.TextFrame.TextRange.Font.Color.RGB) ([long]$shape.Fill.ForeColor.RGB)
3443
+ if ($contrast -lt 3) {
3444
+ $issues += Office-Issue 'warning' 'low_contrast' $path "Text-to-fill contrast ratio is $([Math]::Round($contrast, 2)):1."
3445
+ }
3446
+ }
3447
+ } catch {}
3448
+ }
3449
+ } catch {}
3450
+ try {
3451
+ if ($shape.Type -eq 14 -and -not ($shape.HasTextFrame -and $shape.TextFrame.HasText)) {
3452
+ $placeholderType = [int]$shape.PlaceholderFormat.Type
3453
+ if (@(1, 2, 3, 4, 5, 6, 7) -contains $placeholderType) {
3454
+ $issues += Office-Issue 'warning' 'empty_placeholder' $path 'A visible content placeholder is still empty.'
3455
+ }
3456
+ }
3457
+ } catch {}
3458
+ try {
3459
+ if ($shape.HasChart -and $shape.Chart.SeriesCollection().Count -eq 0) {
3460
+ $issues += Office-Issue 'error' 'empty_chart' "$path/chart" 'Chart has no data series.'
3461
+ }
3462
+ } catch {
3463
+ if ($shape.HasChart) { $issues += Office-Issue 'error' 'broken_chart' "$path/chart" 'Chart data could not be inspected.' }
3464
+ }
3465
+ }
3466
+ for ($leftIndex = 1; $leftIndex -le $slide.Shapes.Count; $leftIndex++) {
3467
+ $left = $slide.Shapes.Item($leftIndex)
3468
+ for ($rightIndex = $leftIndex + 1; $rightIndex -le $slide.Shapes.Count; $rightIndex++) {
3469
+ $right = $slide.Shapes.Item($rightIndex)
3470
+ try {
3471
+ $leftHasText = $left.HasTextFrame -and $left.TextFrame.HasText
3472
+ $rightHasText = $right.HasTextFrame -and $right.TextFrame.HasText
3473
+ if (-not ($leftHasText -and $rightHasText)) { continue }
3474
+ $x = [Math]::Max(0, [Math]::Min([double]$left.Left + [double]$left.Width, [double]$right.Left + [double]$right.Width) - [Math]::Max([double]$left.Left, [double]$right.Left))
3475
+ $y = [Math]::Max(0, [Math]::Min([double]$left.Top + [double]$left.Height, [double]$right.Top + [double]$right.Height) - [Math]::Max([double]$left.Top, [double]$right.Top))
3476
+ $intersection = $x * $y
3477
+ $smallest = [Math]::Min([double]$left.Width * [double]$left.Height, [double]$right.Width * [double]$right.Height)
3478
+ if ($smallest -gt 0 -and ($intersection / $smallest) -ge 0.25) {
3479
+ $issues += Office-Issue 'warning' 'shape_overlap' "/slide[$($slide.SlideIndex)]" "Text shapes $leftIndex and $rightIndex overlap by at least 25%."
3480
+ }
3481
+ } catch {}
3482
+ }
3483
+ }
3484
+ if ($textShapeCount -gt 0 -and $visualShapeCount -eq 0) {
3485
+ $issues += Office-Issue 'warning' 'text_only_slide' "/slide[$($slide.SlideIndex)]" 'Slide contains text but no visual shape, chart, table, diagram, or picture.'
3486
+ }
3487
+ if ($payload.auditProfile -eq 'model-backed-deck') {
3488
+ $allText = @($slide.Shapes | ForEach-Object {
3489
+ try { if ($_.HasTextFrame -and $_.TextFrame.HasText) { [string]$_.TextFrame.TextRange.Text } } catch {}
3490
+ }) -join ' '
3491
+ $notes = $(try { [string]$slide.NotesPage.Shapes.Placeholders.Item(2).TextFrame.TextRange.Text } catch { '' })
3492
+ if ($allText -match '\d' -and $notes -notmatch '(?i)source\s*:|[\w .-]+!\$?[A-Z]{1,3}\$?\d+') {
3493
+ $issues += Office-Issue 'warning' 'number_without_source' "/slide[$($slide.SlideIndex)]" 'Model-backed deck slide contains numbers but its notes do not cite a workbook cell or source.'
3494
+ }
3495
+ }
3496
+ }
3497
+ return $issues
3498
+ }
3499
+
3500
+ function Issues-Document($document, [string]$format, $payload = $null) {
3501
+ if ($null -eq $payload) { $payload = [ordered]@{} }
3502
+ $inspection = switch ($format) {
3503
+ 'docx' { @(Issues-Word $document) }
3504
+ 'xlsx' { Issues-Excel $document $payload }
3505
+ 'pptx' { @(Issues-PowerPoint $document $payload) }
3506
+ }
3507
+ $issues = if ($inspection -is [System.Collections.IDictionary] -and $inspection.Contains('issues')) { @($inspection.issues) } else { @($inspection) }
3508
+ $result = [ordered]@{ ok = -not (@($issues | Where-Object severity -eq 'error').Count -gt 0); format = $format; issueCount = @($issues).Count; issues = @($issues) }
3509
+ if ($inspection -is [System.Collections.IDictionary] -and $inspection.Contains('auditCoverage')) {
3510
+ $result.auditCoverage = $inspection.auditCoverage
3511
+ }
3512
+ return $result
3513
+ }
3514
+
3515
+ function Open-ValidationDocument($app, [string]$format, [string]$path) {
3516
+ switch ($format) {
3517
+ 'docx' { return $app.Documents.Open($path, $false, $true, $false) }
3518
+ 'xlsx' { return $app.Workbooks.Open($path, 0, $true) }
3519
+ 'pptx' { return $app.Presentations.Open($path, $true, $true, $false) }
3520
+ }
3521
+ }
3522
+
3523
+ function Validate-NativeDocument([string]$path, [string]$format) {
3524
+ $validationApp = $null
3525
+ $validationDocument = $null
3526
+ try {
3527
+ $validationApp = New-HiddenApplication $format (ProgId-ForFormat $format)
3528
+ $validationDocument = Open-ValidationDocument $validationApp $format $path
3529
+ $snapshot = Snapshot-Document $validationDocument $format ([ordered]@{})
3530
+ $inspection = Issues-Document $validationDocument $format ([ordered]@{})
3531
+ return [ordered]@{
3532
+ ok = [bool]$inspection.ok
3533
+ opened = $true
3534
+ issueCount = [int]$inspection.issueCount
3535
+ issues = @($inspection.issues)
3536
+ snapshotFingerprint = Snapshot-Fingerprint $snapshot
3537
+ }
3538
+ } catch {
3539
+ return [ordered]@{ ok = $false; opened = $false; error = [string]$_.Exception.Message }
3540
+ } finally {
3541
+ if ($null -ne $validationDocument) {
3542
+ try { $validationDocument.Close($false) } catch {}
3543
+ try { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($validationDocument) } catch {}
3544
+ }
3545
+ if ($null -ne $validationApp) {
3546
+ try { $validationApp.Quit() } catch {}
3547
+ try { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($validationApp) } catch {}
3548
+ }
3549
+ }
3550
+ }
3551
+
3552
+ function Save-Document($document, [string]$format) {
3553
+ if ($format -eq 'xlsx') {
3554
+ try { $document.Application.CalculateFullRebuild() } catch {}
3555
+ }
3556
+ $document.Save()
3557
+ }
3558
+
3559
+ function Save-DocumentCopy($document, [string]$format, [string]$output) {
3560
+ switch ($format) {
3561
+ 'xlsx' { $document.SaveCopyAs($output) }
3562
+ 'pptx' { $document.SaveCopyAs($output, (Office-SaveFormatForPath $format $output)) }
3563
+ default { throw "Save-copy is unsupported for .$format" }
3564
+ }
3565
+ }
3566
+
3567
+ function Text-Sha256([string]$text) {
3568
+ $algorithm = [System.Security.Cryptography.SHA256]::Create()
3569
+ try {
3570
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
3571
+ return ([BitConverter]::ToString($algorithm.ComputeHash($bytes))).Replace('-', '').ToLowerInvariant()
3572
+ } finally {
3573
+ $algorithm.Dispose()
3574
+ }
3575
+ }
3576
+
3577
+ function Snapshot-Fingerprint($value) {
3578
+ return Text-Sha256 ($value | ConvertTo-Json -Depth 30 -Compress)
3579
+ }
3580
+
3581
+ function Restore-ExcelCheckpoint($book, [string]$checkpoint) {
3582
+ $app = $book.Application
3583
+ $alerts = $app.DisplayAlerts
3584
+ $app.DisplayAlerts = $false
3585
+ $source = $null
3586
+ try {
3587
+ $source = $app.Workbooks.Open($checkpoint, $null, $true)
3588
+ $originalSheets = @($book.Worksheets)
3589
+ $temporary = $book.Worksheets.Add()
3590
+ $temporary.Name = "__MixdogRestore$([guid]::NewGuid().ToString('N').Substring(0, 8))"
3591
+ foreach ($sheet in $originalSheets) { $sheet.Delete() }
3592
+ foreach ($sheet in @($source.Worksheets)) {
3593
+ $sheet.Copy([Type]::Missing, $book.Worksheets.Item($book.Worksheets.Count))
3594
+ }
3595
+ $temporary.Delete()
3596
+ $book.Saved = $false
3597
+ } finally {
3598
+ if ($null -ne $source) { try { $source.Close($false) } catch {} }
3599
+ $app.DisplayAlerts = $alerts
3600
+ }
3601
+ }
3602
+
3603
+ function Rollback-LiveDocument($document, [string]$format, [string]$checkpoint, [int]$undoUnits) {
3604
+ switch ($format) {
3605
+ 'docx' {
3606
+ if ($undoUnits -gt 0) { $null = $document.Undo($undoUnits) }
3607
+ }
3608
+ 'xlsx' { Restore-ExcelCheckpoint $document $checkpoint }
3609
+ 'pptx' {
3610
+ for ($index = 0; $index -lt $undoUnits; $index++) {
3611
+ $document.Application.CommandBars.ExecuteMso('Undo')
3612
+ }
3613
+ }
3614
+ }
3615
+ }
3616
+
3617
+ function Render-Document($document, [string]$format, [string]$output) {
3618
+ switch ($format) {
3619
+ 'docx' { $document.ExportAsFixedFormat($output, 17) }
3620
+ 'xlsx' {
3621
+ $pageSetups = @()
3622
+ try {
3623
+ foreach ($sheet in @($document.Worksheets)) {
3624
+ try {
3625
+ $pageSetup = $sheet.PageSetup
3626
+ $pageSetups += [ordered]@{
3627
+ PageSetup = $pageSetup
3628
+ Zoom = $pageSetup.Zoom
3629
+ FitToPagesWide = $pageSetup.FitToPagesWide
3630
+ FitToPagesTall = $pageSetup.FitToPagesTall
3631
+ }
3632
+ $pageSetup.Zoom = $false
3633
+ $pageSetup.FitToPagesWide = 1
3634
+ $pageSetup.FitToPagesTall = $false
3635
+ } catch {}
3636
+ }
3637
+ $document.ExportAsFixedFormat(0, $output)
3638
+ } finally {
3639
+ foreach ($state in $pageSetups) {
3640
+ try {
3641
+ $state.PageSetup.Zoom = $state.Zoom
3642
+ $state.PageSetup.FitToPagesWide = $state.FitToPagesWide
3643
+ $state.PageSetup.FitToPagesTall = $state.FitToPagesTall
3644
+ } catch {}
3645
+ }
3646
+ }
3647
+ }
3648
+ 'pptx' { $document.SaveCopyAs($output, 32) }
3649
+ }
3650
+ }
3651
+
3652
+ if ($env:MIXDOG_OFFICE_HOST_LIBRARY -eq '1') { return }
3653
+
3654
+ $app = $null
3655
+ $document = $null
3656
+ $live = $false
3657
+ $createdApp = $false
3658
+ try {
3659
+ $raw = [Console]::In.ReadToEnd()
3660
+ if ([string]::IsNullOrWhiteSpace($raw)) { throw 'Office host received an empty request' }
3661
+ $payload = $raw | ConvertFrom-Json
3662
+ $format = ([string]$payload.format).ToLowerInvariant()
3663
+ if ($payload.action -eq 'detect') {
3664
+ $formats = @('docx', 'xlsx', 'pptx')
3665
+ $items = @()
3666
+ foreach ($candidate in $formats) {
3667
+ $progId = ProgId-ForFormat $candidate
3668
+ $installed = Installed $progId
3669
+ $active = $false
3670
+ $open = $false
3671
+ if ($installed) {
3672
+ $candidateApp = Active-Application $progId
3673
+ if ($null -ne $candidateApp) {
3674
+ $active = $true
3675
+ if ($payload.path -and ($format -eq $candidate)) {
3676
+ $open = $null -ne (Find-OpenDocument $candidateApp $candidate ([string]$payload.path))
3677
+ }
3678
+ [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($candidateApp)
3679
+ }
3680
+ }
3681
+ $items += [ordered]@{ format = $candidate; installed = $installed; active = $active; documentOpen = $open }
3682
+ }
3683
+ Emit-Json ([ordered]@{ ok = $true; backend = 'microsoft-office-com'; available = ($items | Where-Object installed).Count -gt 0; applications = $items })
3684
+ exit 0
3685
+ }
3686
+
3687
+ $progId = ProgId-ForFormat $format
3688
+ if (-not (Installed $progId)) { throw "Microsoft Office application for .$format is not installed" }
3689
+ $path = [System.IO.Path]::GetFullPath([string]$payload.path)
3690
+ if ($payload.mode -eq 'live') {
3691
+ $app = Active-Application $progId
3692
+ if ($null -eq $app) { throw "No running Microsoft Office application for .$format" }
3693
+ $document = Find-OpenDocument $app $format $path
3694
+ if ($null -eq $document) { throw "The exact document is not open in Microsoft Office: $path" }
3695
+ $live = $true
3696
+ } else {
3697
+ $app = New-HiddenApplication $format $progId
3698
+ $createdApp = $true
3699
+ $document = Open-BackgroundDocument $app $format $path
3700
+ }
3701
+
3702
+ switch ([string]$payload.action) {
3703
+ 'snapshot' {
3704
+ $value = Snapshot-Document $document $format $payload
3705
+ Emit-Json ([ordered]@{ ok = $true; backend = 'microsoft-office-com'; mode = $(if ($live) { 'live' } else { 'background' }); value = $value })
3706
+ }
3707
+ 'issues' {
3708
+ $value = Issues-Document $document $format $payload
3709
+ Emit-Json ([ordered]@{ ok = $true; backend = 'microsoft-office-com'; mode = $(if ($live) { 'live' } else { 'background' }); value = $value })
3710
+ }
3711
+ 'validate' {
3712
+ $value = Validate-NativeDocument $path $format
3713
+ $value.documentSaved = [bool]$document.Saved
3714
+ Emit-Json ([ordered]@{ ok = $true; backend = 'microsoft-office-com'; mode = $(if ($live) { 'live' } else { 'background' }); value = $value })
3715
+ }
3716
+ 'checkpoint' {
3717
+ $output = [System.IO.Path]::GetFullPath([string]$payload.output)
3718
+ $value = Snapshot-Document $document $format $payload
3719
+ if ($format -eq 'docx') {
3720
+ Emit-Json ([ordered]@{ ok = $true; backend = 'microsoft-office-com'; mode = $(if ($live) { 'live' } else { 'background' }); fingerprint = Snapshot-Fingerprint $value; saved = [bool]$document.Saved; value = $value })
3721
+ } else {
3722
+ Save-DocumentCopy $document $format $output
3723
+ Emit-Json ([ordered]@{ ok = $true; backend = 'microsoft-office-com'; mode = $(if ($live) { 'live' } else { 'background' }); output = $output; saved = [bool]$document.Saved; value = $value })
3724
+ }
3725
+ }
3726
+ 'batch' {
3727
+ $excelCheckpoint = ''
3728
+ if ($live -and $format -eq 'xlsx') {
3729
+ $extension = [System.IO.Path]::GetExtension($path)
3730
+ $excelCheckpoint = Join-Path ([System.IO.Path]::GetTempPath()) "mixdog-excel-batch-$([guid]::NewGuid().ToString('N'))$extension"
3731
+ Save-DocumentCopy $document $format $excelCheckpoint
3732
+ }
3733
+ try {
3734
+ $applied = Apply-Operations $document $format $payload.operations $live ([bool]$payload.requireChanges)
3735
+ if (-not $live -or $payload.save) { Save-Document $document $format }
3736
+ Emit-Json ([ordered]@{ ok = $true; backend = 'microsoft-office-com'; mode = $(if ($live) { 'live' } else { 'background' }); saved = (-not $live -or [bool]$payload.save); results = $applied.results; undoUnits = $applied.undoUnits })
3737
+ } catch {
3738
+ if ($excelCheckpoint) { try { Restore-ExcelCheckpoint $document $excelCheckpoint } catch {} }
3739
+ throw
3740
+ } finally {
3741
+ if ($excelCheckpoint) { Remove-Item $excelCheckpoint -Force -ErrorAction SilentlyContinue }
3742
+ }
3743
+ }
3744
+ 'rollback' {
3745
+ if (-not $live) { throw 'Office COM rollback is available for live documents only' }
3746
+ Rollback-LiveDocument $document $format ([string]$payload.checkpoint) ([int]$payload.undoUnits)
3747
+ $value = Snapshot-Document $document $format $payload
3748
+ Emit-Json ([ordered]@{ ok = $true; backend = 'microsoft-office-com'; mode = 'live'; rolledBack = $true; value = $value })
3749
+ }
3750
+ 'save' {
3751
+ Save-Document $document $format
3752
+ Emit-Json ([ordered]@{ ok = $true; backend = 'microsoft-office-com'; mode = $(if ($live) { 'live' } else { 'background' }); saved = $true; path = $path })
3753
+ }
3754
+ 'render' {
3755
+ $output = [System.IO.Path]::GetFullPath([string]$payload.output)
3756
+ $wasSaved = [bool]$document.Saved
3757
+ Render-Document $document $format $output
3758
+ if ($wasSaved -and -not [bool]$document.Saved) { $document.Saved = $true }
3759
+ Emit-Json ([ordered]@{ ok = $true; backend = 'microsoft-office-com'; mode = $(if ($live) { 'live' } else { 'background' }); output = $output })
3760
+ }
3761
+ default { throw "Unsupported Office host action: $($payload.action)" }
3762
+ }
3763
+ } catch {
3764
+ Emit-Json ([ordered]@{ ok = $false; backend = 'microsoft-office-com'; error = [string]$_.Exception.Message })
3765
+ exit 1
3766
+ } finally {
3767
+ if ($null -ne $document) {
3768
+ if (-not $live) {
3769
+ try { $document.Close($false) } catch {}
3770
+ }
3771
+ try { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($document) } catch {}
3772
+ }
3773
+ if ($null -ne $app) {
3774
+ if ($createdApp) {
3775
+ try { $app.Quit() } catch {}
3776
+ }
3777
+ try { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($app) } catch {}
3778
+ }
3779
+ [GC]::Collect()
3780
+ [GC]::WaitForPendingFinalizers()
3781
+ }