dsh-office-kit 0.1.0 → 0.3.0

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 (198) hide show
  1. package/CAPABILITY.md +147 -68
  2. package/LICENSE.md +21 -21
  3. package/README.md +108 -106
  4. package/assets/skills/excel-live-control/SKILL.md +234 -0
  5. package/assets/skills/excel-live-control/container_tools/live_excel.py +1589 -0
  6. package/assets/skills/excel-live-control/domain_guidance/financial_models.md +86 -0
  7. package/assets/skills/excel-live-control/domain_guidance/healthcare.md +58 -0
  8. package/assets/skills/excel-live-control/domain_guidance/marketing_advertising.md +53 -0
  9. package/assets/skills/excel-live-control/domain_guidance/scientific_research.md +53 -0
  10. package/assets/skills/excel-live-control/references/office-automation.md +207 -0
  11. package/assets/skills/excel-live-control/references/read_only_qna.md +59 -0
  12. package/assets/skills/excel-live-control/style_guidelines.md +87 -0
  13. package/assets/skills/excel-live-control/workflows/create_workflows.md +78 -0
  14. package/assets/skills/excel-live-control/workflows/edit_workflows.md +72 -0
  15. package/assets/skills/office-documents/SKILL.md +90 -77
  16. package/assets/skills/office-documents/examples/end_to_end_smoke_test.md +86 -0
  17. package/assets/skills/office-documents/ooxml/comments.md +40 -0
  18. package/assets/skills/office-documents/ooxml/hyperlinks_and_fields.md +66 -0
  19. package/assets/skills/office-documents/ooxml/rels_and_content_types.md +62 -0
  20. package/assets/skills/office-documents/ooxml/tracked_changes.md +53 -0
  21. package/assets/skills/office-documents/references/docx-structure.md +83 -0
  22. package/assets/skills/office-documents/references/template-elicitation.md +49 -0
  23. package/assets/skills/office-documents/references/writing-quality.md +49 -0
  24. package/assets/skills/office-documents/render_docx.py +92 -0
  25. package/assets/skills/office-documents/scripts/a11y_audit.py +194 -0
  26. package/assets/skills/office-documents/scripts/accept_tracked_changes.py +151 -0
  27. package/assets/skills/office-documents/scripts/add_tracked_replacements.py +148 -0
  28. package/assets/skills/office-documents/scripts/append_blocks.py +108 -0
  29. package/assets/skills/office-documents/scripts/apply_template_styles.py +104 -0
  30. package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +102 -0
  31. package/assets/skills/office-documents/scripts/comments_add.py +136 -0
  32. package/assets/skills/office-documents/scripts/comments_apply_patch.py +114 -0
  33. package/assets/skills/office-documents/scripts/comments_extract.py +85 -0
  34. package/assets/skills/office-documents/scripts/comments_strip.py +74 -0
  35. package/assets/skills/office-documents/scripts/content_controls.py +192 -0
  36. package/assets/skills/office-documents/scripts/core_properties.py +124 -0
  37. package/assets/skills/office-documents/scripts/docx_kit.py +587 -0
  38. package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +225 -0
  39. package/assets/skills/office-documents/scripts/docx_table_to_csv.py +99 -0
  40. package/assets/skills/office-documents/scripts/fields_materialize.py +66 -0
  41. package/assets/skills/office-documents/scripts/fields_report.py +59 -0
  42. package/assets/skills/office-documents/scripts/flatten_ref_fields.py +62 -0
  43. package/assets/skills/office-documents/scripts/footnotes_report.py +77 -0
  44. package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +96 -0
  45. package/assets/skills/office-documents/scripts/heading_audit.py +81 -0
  46. package/assets/skills/office-documents/scripts/images_audit.py +80 -0
  47. package/assets/skills/office-documents/scripts/insert_note.py +126 -0
  48. package/assets/skills/office-documents/scripts/insert_ref_fields.py +90 -0
  49. package/assets/skills/office-documents/scripts/insert_toc.py +84 -0
  50. package/assets/skills/office-documents/scripts/internal_nav.py +146 -0
  51. package/assets/skills/office-documents/scripts/make_fixtures.py +169 -0
  52. package/assets/skills/office-documents/scripts/merge_docx_append.py +239 -0
  53. package/assets/skills/office-documents/scripts/privacy_scrub.py +131 -0
  54. package/assets/skills/office-documents/scripts/redact_docx.py +113 -0
  55. package/assets/skills/office-documents/scripts/render_and_diff.py +101 -0
  56. package/assets/skills/office-documents/scripts/replace_text.py +154 -0
  57. package/assets/skills/office-documents/scripts/section_audit.py +91 -0
  58. package/assets/skills/office-documents/scripts/set_protection.py +92 -0
  59. package/assets/skills/office-documents/scripts/style_lint.py +111 -0
  60. package/assets/skills/office-documents/scripts/style_normalize.py +103 -0
  61. package/assets/skills/office-documents/scripts/table_geometry.py +167 -0
  62. package/assets/skills/office-documents/scripts/watermark_add.py +48 -0
  63. package/assets/skills/office-documents/scripts/watermark_audit_remove.py +67 -0
  64. package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +117 -0
  65. package/assets/skills/office-documents/tasks/accessibility_a11y.md +45 -0
  66. package/assets/skills/office-documents/tasks/captions_crossrefs.md +49 -0
  67. package/assets/skills/office-documents/tasks/clean_tracked_changes.md +49 -0
  68. package/assets/skills/office-documents/tasks/comments_manage.md +59 -0
  69. package/assets/skills/office-documents/tasks/compare_diff.md +40 -0
  70. package/assets/skills/office-documents/tasks/create_edit.md +107 -0
  71. package/assets/skills/office-documents/tasks/equations.md +59 -0
  72. package/assets/skills/office-documents/tasks/fields_update.md +44 -0
  73. package/assets/skills/office-documents/tasks/fixtures_edge_cases.md +50 -0
  74. package/assets/skills/office-documents/tasks/footnotes_endnotes.md +38 -0
  75. package/assets/skills/office-documents/tasks/forms_content_controls.md +48 -0
  76. package/assets/skills/office-documents/tasks/headings_numbering.md +55 -0
  77. package/assets/skills/office-documents/tasks/images_figures.md +63 -0
  78. package/assets/skills/office-documents/tasks/multi_doc_merge.md +43 -0
  79. package/assets/skills/office-documents/tasks/navigation_internal_links.md +38 -0
  80. package/assets/skills/office-documents/tasks/privacy_scrub_metadata.md +45 -0
  81. package/assets/skills/office-documents/tasks/protection_restrict_editing.md +41 -0
  82. package/assets/skills/office-documents/tasks/read_review.md +50 -0
  83. package/assets/skills/office-documents/tasks/redaction_anonymization.md +42 -0
  84. package/assets/skills/office-documents/tasks/sections_layout.md +54 -0
  85. package/assets/skills/office-documents/tasks/style_lint_normalize.md +44 -0
  86. package/assets/skills/office-documents/tasks/tables_spreadsheets.md +64 -0
  87. package/assets/skills/office-documents/tasks/templates_style_packs.md +53 -0
  88. package/assets/skills/office-documents/tasks/toc_workflow.md +39 -0
  89. package/assets/skills/office-documents/tasks/verify_render.md +50 -0
  90. package/assets/skills/office-documents/tasks/watermarks_background.md +35 -0
  91. package/assets/skills/office-documents/troubleshooting/libreoffice_headless.md +47 -0
  92. package/assets/skills/office-documents/troubleshooting/run_splitting.md +36 -0
  93. package/assets/skills/office-pdf/SKILL.md +73 -56
  94. package/assets/skills/office-pdf/references/form-fields.md +56 -0
  95. package/assets/skills/office-pdf/references/geometry.md +44 -0
  96. package/assets/skills/office-pdf/references/layout-quality.md +41 -0
  97. package/assets/skills/office-pdf/references/limits.md +58 -0
  98. package/assets/skills/office-pdf/scripts/extract_text.py +172 -0
  99. package/assets/skills/office-pdf/scripts/forms.py +556 -0
  100. package/assets/skills/office-pdf/scripts/metadata.py +161 -0
  101. package/assets/skills/office-pdf/scripts/page_ops.py +198 -0
  102. package/assets/skills/office-pdf/scripts/redact_region.py +347 -0
  103. package/assets/skills/office-pdf/scripts/render_pages.py +207 -0
  104. package/assets/skills/office-pdf/scripts/split_pages.py +155 -0
  105. package/assets/skills/office-pdf/scripts/stamp_overlay.py +199 -0
  106. package/assets/skills/office-pdf/tasks/annotations.md +98 -0
  107. package/assets/skills/office-pdf/tasks/create.md +75 -0
  108. package/assets/skills/office-pdf/tasks/edit-pages.md +71 -0
  109. package/assets/skills/office-pdf/tasks/forms.md +67 -0
  110. package/assets/skills/office-pdf/tasks/read.md +62 -0
  111. package/assets/skills/office-pdf/tasks/redact.md +72 -0
  112. package/assets/skills/office-pdf/tasks/stamp.md +60 -0
  113. package/assets/skills/office-presentations/SKILL.md +141 -87
  114. package/assets/skills/office-presentations/container_tools/add_slides.py +136 -0
  115. package/assets/skills/office-presentations/container_tools/core_properties.py +120 -0
  116. package/assets/skills/office-presentations/container_tools/create_montage.py +126 -0
  117. package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +285 -0
  118. package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +157 -0
  119. package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +144 -0
  120. package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +199 -0
  121. package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +212 -0
  122. package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +345 -0
  123. package/assets/skills/office-presentations/container_tools/preview_render.py +375 -0
  124. package/assets/skills/office-presentations/container_tools/render_slides.py +139 -0
  125. package/assets/skills/office-presentations/container_tools/replace_text.py +168 -0
  126. package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +229 -0
  127. package/assets/skills/office-presentations/domain_guidance/finance.md +66 -0
  128. package/assets/skills/office-presentations/recipes/README.md +71 -0
  129. package/assets/skills/office-presentations/recipes/autolayout.md +88 -0
  130. package/assets/skills/office-presentations/recipes/connectors.md +83 -0
  131. package/assets/skills/office-presentations/recipes/images.md +80 -0
  132. package/assets/skills/office-presentations/recipes/master-and-layouts.md +127 -0
  133. package/assets/skills/office-presentations/recipes/native-charts.md +87 -0
  134. package/assets/skills/office-presentations/recipes/rich-text-runs.md +86 -0
  135. package/assets/skills/office-presentations/recipes/speaker-notes.md +65 -0
  136. package/assets/skills/office-presentations/recipes/tables.md +88 -0
  137. package/assets/skills/office-presentations/recipes/textbox-at-coordinate.md +71 -0
  138. package/assets/skills/office-presentations/recipes/theme-tokens.md +87 -0
  139. package/assets/skills/office-presentations/references/authoring-workflow.md +65 -0
  140. package/assets/skills/office-presentations/references/finalization.md +63 -0
  141. package/assets/skills/office-presentations/references/native-bullets.md +78 -0
  142. package/assets/skills/office-presentations/references/native-evidence.md +67 -0
  143. package/assets/skills/office-presentations/references/style-guidelines.md +66 -0
  144. package/assets/skills/office-presentations/references/templates.md +76 -0
  145. package/assets/skills/office-presentations/routing/google_slides.md +45 -0
  146. package/assets/skills/office-presentations/tasks/apply-annotations.md +83 -0
  147. package/assets/skills/office-presentations/tasks/charts-tables-notes.md +79 -0
  148. package/assets/skills/office-presentations/tasks/create-deck.md +106 -0
  149. package/assets/skills/office-presentations/tasks/edit-deck.md +93 -0
  150. package/assets/skills/office-presentations/tasks/inspect-and-verify.md +107 -0
  151. package/assets/skills/office-presentations/tasks/template-following.md +132 -0
  152. package/assets/skills/office-presentations/template_following_scripts/check_template_fidelity.py +176 -0
  153. package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +154 -0
  154. package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +111 -0
  155. package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +239 -0
  156. package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +185 -0
  157. package/assets/skills/office-spreadsheets/SKILL.md +105 -101
  158. package/assets/skills/office-spreadsheets/container_tools/add_chart.py +236 -0
  159. package/assets/skills/office-spreadsheets/container_tools/add_image.py +107 -0
  160. package/assets/skills/office-spreadsheets/container_tools/append_rows.py +122 -0
  161. package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +306 -0
  162. package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +398 -0
  163. package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +275 -0
  164. package/assets/skills/office-spreadsheets/container_tools/set_cells.py +135 -0
  165. package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +134 -0
  166. package/assets/skills/office-spreadsheets/container_tools/style_cells.py +133 -0
  167. package/assets/skills/office-spreadsheets/recipes/charts.md +66 -0
  168. package/assets/skills/office-spreadsheets/recipes/conditional-formats.md +69 -0
  169. package/assets/skills/office-spreadsheets/recipes/data-tables.md +58 -0
  170. package/assets/skills/office-spreadsheets/recipes/named-ranges.md +41 -0
  171. package/assets/skills/office-spreadsheets/recipes/number-formats.md +72 -0
  172. package/assets/skills/office-spreadsheets/recipes/sparklines.md +43 -0
  173. package/assets/skills/office-spreadsheets/references/charts.md +63 -0
  174. package/assets/skills/office-spreadsheets/references/domain/financial-models.md +40 -0
  175. package/assets/skills/office-spreadsheets/references/domain/healthcare.md +22 -0
  176. package/assets/skills/office-spreadsheets/references/domain/marketing.md +28 -0
  177. package/assets/skills/office-spreadsheets/references/domain/research.md +22 -0
  178. package/assets/skills/office-spreadsheets/references/formula-patterns.md +78 -0
  179. package/assets/skills/office-spreadsheets/references/style-guidelines.md +69 -0
  180. package/assets/skills/office-spreadsheets/references/workbook-structure.md +60 -0
  181. package/assets/skills/office-spreadsheets/routing/google-sheets.md +36 -0
  182. package/assets/skills/office-spreadsheets/tasks/analyze-workbook.md +37 -0
  183. package/assets/skills/office-spreadsheets/tasks/annotations.md +51 -0
  184. package/assets/skills/office-spreadsheets/tasks/create-workbook.md +66 -0
  185. package/assets/skills/office-spreadsheets/tasks/diff-workbooks.md +38 -0
  186. package/assets/skills/office-spreadsheets/tasks/edit-workbook.md +69 -0
  187. package/assets/skills/office-spreadsheets/tasks/inspect-workbook.md +38 -0
  188. package/assets/skills/office-spreadsheets/tasks/recalc-and-check.md +57 -0
  189. package/assets/skills/shared/annotation-recipes.md +252 -0
  190. package/assets/skills/shared/check_runtime.py +253 -0
  191. package/assets/skills/shared/common-workflow.md +139 -0
  192. package/assets/skills/shared/scripts/ensure_raster_image.py +187 -0
  193. package/dist/client.js +4 -4
  194. package/dist/client.js.map +1 -1
  195. package/dist/index.js +239 -89
  196. package/package.json +146 -149
  197. package/postinstall.mjs +92 -82
  198. package/python/office_cli.py +171 -246
@@ -0,0 +1,1589 @@
1
+ #!/usr/bin/env python3
2
+ """Change a workbook that is already open in Excel or WPS Spreadsheets.
3
+
4
+ The file on disk is not the target here: the user is looking at a live
5
+ document, possibly with unsaved edits. This tool attaches to the running
6
+ application through COM and issues commands against the workbook window, so
7
+ what the user sees after the call is what the script changed.
8
+
9
+ Mechanism: `powershell.exe` (Windows PowerShell 5.1) is started with a script
10
+ on stdin. That script calls
11
+ `[Runtime.InteropServices.Marshal]::GetActiveObject(...)` for 'Excel.Application'
12
+ and then 'Ket.Application' (WPS Spreadsheets) and reports one JSON object.
13
+ pywin32 and comtypes are not used and are not required.
14
+
15
+ Normal operation never starts a spreadsheet application. If neither ProgID is
16
+ in the running-object table the call fails and prints what the user should do
17
+ instead. `--launch-hidden` is the one exception and exists for self-tests.
18
+
19
+ Every mutating operation writes through `Range.Value2`, `Range.Formula` or a
20
+ named COM object (chart, table). None of them is transactional: a failure part
21
+ way through leaves what already landed in place, so a retry must be preceded by
22
+ a read, not by a repeat of the same call.
23
+
24
+ Exit codes:
25
+ 0 success: one JSON object on stdout
26
+ 1 the operation failed (attach, COM, validation); a JSON error object is
27
+ printed on stdout and stderr
28
+ 2 usage error (argparse)
29
+ 3 powershell.exe is unavailable or timed out; a JSON error object is printed
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import base64
36
+ import json
37
+ import locale
38
+ import os
39
+ import re
40
+ import shutil
41
+ import subprocess
42
+ import sys
43
+ import time
44
+ from pathlib import Path
45
+
46
+ if hasattr(sys.stdout, "reconfigure"):
47
+ # JSON is UTF-8 by definition; a cp936 console would mangle sheet names.
48
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
49
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
50
+
51
+ CHART_TYPES = {
52
+ "column": 51,
53
+ "column-stacked": 52,
54
+ "bar": 57,
55
+ "bar-stacked": 58,
56
+ "line": 4,
57
+ "line-markers": 65,
58
+ "area": 1,
59
+ "area-stacked": 76,
60
+ "pie": 5,
61
+ "doughnut": -4120,
62
+ "scatter": -4169,
63
+ "scatter-lines": 74,
64
+ }
65
+
66
+ ALIGN_H = {"left": -4131, "center": -4108, "right": -4152, "general": 1, "justify": -4130}
67
+ ALIGN_V = {"top": -4160, "center": -4108, "bottom": -4104}
68
+
69
+ STYLE_KEYS = {
70
+ "font_name",
71
+ "font_size",
72
+ "bold",
73
+ "italic",
74
+ "color",
75
+ "fill",
76
+ "number_format",
77
+ "h_align",
78
+ "v_align",
79
+ "wrap",
80
+ "indent",
81
+ "border",
82
+ "column_width",
83
+ }
84
+
85
+ CELL = re.compile(r"^([A-Za-z]{1,3})([0-9]{1,7})$")
86
+ HEX = re.compile(r"^#?[0-9A-Fa-f]{6}$")
87
+
88
+ ATTACH_REMEDIATION = (
89
+ "Open the workbook in Excel or WPS Spreadsheets and keep it open, then run the "
90
+ "command again. Nothing was started: this tool never creates a hidden instance "
91
+ "for normal work (--launch-hidden exists only for self-tests)."
92
+ )
93
+
94
+
95
+ class ToolError(Exception):
96
+ """A failure this script can explain, with the stage it happened in."""
97
+
98
+ def __init__(self, message: str, stage: str = "request", remediation: str | None = None):
99
+ super().__init__(message)
100
+ self.message = message
101
+ self.stage = stage
102
+ self.remediation = remediation
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Arguments
107
+ # ---------------------------------------------------------------------------
108
+
109
+
110
+ def build_parser() -> argparse.ArgumentParser:
111
+ common = argparse.ArgumentParser(add_help=False)
112
+ common.add_argument(
113
+ "--workbook",
114
+ metavar="NAME|PATH",
115
+ help="which open workbook to drive (default: the only one open)",
116
+ )
117
+ common.add_argument(
118
+ "--launch-hidden",
119
+ action="store_true",
120
+ help="start a hidden spreadsheet application when none is running "
121
+ "(self-tests only; normal use must attach to the user's window)",
122
+ )
123
+ common.add_argument(
124
+ "--timeout",
125
+ type=int,
126
+ default=180,
127
+ metavar="SECONDS",
128
+ help="how long to wait for the COM call (default 180)",
129
+ )
130
+
131
+ parser = argparse.ArgumentParser(
132
+ prog="live_excel.py",
133
+ description="Drive a workbook that is already open in Excel or WPS "
134
+ "Spreadsheets: read it, write values and formulas, style ranges, add "
135
+ "charts and tables, recalculate, save and close.",
136
+ )
137
+ parser.add_argument(
138
+ "--diagnose",
139
+ action="store_true",
140
+ help="report the attach mechanism, application, workbooks and sheets; "
141
+ "no operation is run",
142
+ )
143
+
144
+ commands = parser.add_subparsers(dest="command", metavar="COMMAND")
145
+
146
+ def add(name: str, help_text: str) -> argparse.ArgumentParser:
147
+ return commands.add_parser(name, parents=[common], help=help_text, description=help_text)
148
+
149
+ add("info", "list the open workbooks and the structure of the target workbook")
150
+ add(
151
+ "read_range",
152
+ "read values (and optionally formulas or displayed text) from one range",
153
+ ).add_argument("--sheet", required=True)
154
+ read = commands.choices["read_range"]
155
+ read.add_argument("--range", required=True, help="A1 range, or 'used' for the used range")
156
+ read.add_argument("--formulas", action="store_true", help="also return the formula grid")
157
+ read.add_argument(
158
+ "--display",
159
+ action="store_true",
160
+ help="also return the displayed text of every cell (max 500 cells)",
161
+ )
162
+ read.add_argument("--max-cells", type=int, default=5000, help="refuse ranges above this size")
163
+
164
+ write = add("write_range", "write a rectangular block of values into one range")
165
+ write.add_argument("--sheet", required=True)
166
+ write.add_argument("--range", required=True, help="rectangular A1 range, for example A1:B3")
167
+ write.add_argument(
168
+ "--data",
169
+ help='JSON 2D array, for example \'[[1,2],[3,4]]\'; one row per spreadsheet row',
170
+ )
171
+ write.add_argument("--data-file", help="path to a JSON file holding the 2D array")
172
+ write.add_argument(
173
+ "--clear",
174
+ action="store_true",
175
+ help="clear the contents of the range instead of writing (formats survive)",
176
+ )
177
+
178
+ formula = add("set_formula", "write formulas into one range or into named cells")
179
+ formula.add_argument("--sheet", required=True)
180
+ formula.add_argument("--range", help="range the --formula applies to")
181
+ formula.add_argument("--formula", help="formula text starting with '='")
182
+ formula.add_argument(
183
+ "--cells",
184
+ help='JSON object mapping addresses to formulas: \'{"C1":"=A1+B1"}\'',
185
+ )
186
+ formula.add_argument("--cells-file", help="path to a JSON file holding that object")
187
+ formula.add_argument(
188
+ "--r1c1",
189
+ action="store_true",
190
+ help="treat the formulas as R1C1 notation (FormulaR1C1)",
191
+ )
192
+
193
+ style = add("style_range", "apply font, fill, number format, alignment or borders")
194
+ style.add_argument("--sheet", required=True)
195
+ style.add_argument("--range", required=True)
196
+ style.add_argument("--style", help="JSON style object, for example '{\"bold\":true}'")
197
+ style.add_argument("--style-file", help="path to a JSON file holding the style object")
198
+ style.add_argument(
199
+ "--merge",
200
+ action="store_true",
201
+ help="merge the range after styling it (one value survives)",
202
+ )
203
+
204
+ chart = add("add_chart", "embed a chart on a sheet and point it at a range")
205
+ chart.add_argument("--sheet", required=True)
206
+ chart.add_argument(
207
+ "--type",
208
+ required=True,
209
+ choices=sorted(CHART_TYPES),
210
+ help="chart type",
211
+ )
212
+ chart.add_argument(
213
+ "--data",
214
+ required=True,
215
+ help="source range on --sheet, or 'OtherSheet!A1:B10' for another sheet",
216
+ )
217
+ chart.add_argument("--anchor", required=True, help="top-left cell the chart is placed at")
218
+ chart.add_argument("--title", help="chart title")
219
+ chart.add_argument("--width", type=float, default=420.0, help="points (default 420)")
220
+ chart.add_argument("--height", type=float, default=260.0, help="points (default 260)")
221
+
222
+ table = add("add_table", "turn a range into a native Excel/WPS table (ListObject)")
223
+ table.add_argument("--sheet", required=True)
224
+ table.add_argument("--range", required=True)
225
+ table.add_argument("--name", help="table name (letters, digits, underscore)")
226
+ table.add_argument("--style", help="table style name, for example TableStyleMedium2")
227
+ table.add_argument(
228
+ "--no-headers",
229
+ action="store_true",
230
+ help="treat the first row as data and generate header names",
231
+ )
232
+
233
+ add("recalc", "recalculate every open workbook (CalculateFull)")
234
+
235
+ save = add("save", "save the workbook over its own file, or save a copy elsewhere")
236
+ save.add_argument("--path", help="save a copy here instead of saving in place")
237
+
238
+ close = add("close", "close the target workbook, optionally quitting the application")
239
+ close.add_argument("--save", action="store_true", help="save before closing")
240
+ close.add_argument(
241
+ "--discard",
242
+ action="store_true",
243
+ help="close without saving, discarding unsaved changes",
244
+ )
245
+ close.add_argument(
246
+ "--quit-app",
247
+ action="store_true",
248
+ help="quit the application after closing (refused while other workbooks are open)",
249
+ )
250
+
251
+ return parser
252
+
253
+
254
+ def _require_sheet(args: argparse.Namespace) -> str:
255
+ if not args.sheet or not args.sheet.strip():
256
+ raise ToolError("--sheet is required: this tool never falls back to the active sheet", "usage")
257
+ return args.sheet
258
+
259
+
260
+ def _json_payload(inline: str | None, path: str | None, name: str):
261
+ if inline and path:
262
+ raise ToolError(f"pass either {name} or the matching file flag, not both", "usage")
263
+ if path:
264
+ source = Path(path).expanduser()
265
+ if not source.is_file():
266
+ raise ToolError(f"{name}: file not found: {source}", "usage")
267
+ try:
268
+ return json.loads(source.read_text(encoding="utf-8-sig"))
269
+ except json.JSONDecodeError as error:
270
+ raise ToolError(f"{name}: {source} is not valid JSON: {error}", "usage") from error
271
+ if inline is None:
272
+ raise ToolError(f"{name} is required", "usage")
273
+ try:
274
+ return json.loads(inline)
275
+ except json.JSONDecodeError as error:
276
+ raise ToolError(f"{name} is not valid JSON: {error}", "usage") from error
277
+
278
+
279
+ def _column_number(letters: str) -> int:
280
+ value = 0
281
+ for character in letters.upper():
282
+ value = value * 26 + (ord(character) - ord("A") + 1)
283
+ return value
284
+
285
+
286
+ def parse_rectangle(address: str) -> tuple[int, int, int, int]:
287
+ """Return (top_row, left_column, rows, columns) for 'A1:C12'; -1 marks a bad address."""
288
+ parts = address.split(":")
289
+ if len(parts) not in (1, 2):
290
+ raise ToolError(f"not a rectangular A1 range: '{address}'", "usage")
291
+ first = CELL.match(parts[0].strip())
292
+ second = CELL.match(parts[-1].strip())
293
+ if not first or not second:
294
+ raise ToolError(
295
+ f"not a rectangular A1 range: '{address}' (use rows and columns, not A:A)",
296
+ "usage",
297
+ )
298
+ row_one, column_one = int(first.group(2)), _column_number(first.group(1))
299
+ row_two, column_two = int(second.group(2)), _column_number(second.group(1))
300
+ top, bottom = min(row_one, row_two), max(row_one, row_two)
301
+ left, right = min(column_one, column_two), max(column_one, column_two)
302
+ return top, left, bottom - top + 1, right - left + 1
303
+
304
+
305
+ def _two_dimensional(data, field: str) -> list[list[object]]:
306
+ if not isinstance(data, list) or not data:
307
+ raise ToolError(f"{field} must be a non-empty JSON array of rows", "usage")
308
+ rows: list[list[object]] = []
309
+ for index, row in enumerate(data, start=1):
310
+ if not isinstance(row, list):
311
+ row = [row]
312
+ rows.append(list(row))
313
+ width = len(rows[0])
314
+ for index, row in enumerate(rows, start=1):
315
+ if len(row) != width:
316
+ raise ToolError(
317
+ f"{field}: row {index} has {len(row)} values but row 1 has {width}; "
318
+ "every row must be the same length",
319
+ "usage",
320
+ )
321
+ return rows
322
+
323
+
324
+ def _check_color(value, key: str) -> str:
325
+ if not isinstance(value, str) or not HEX.match(value):
326
+ raise ToolError(f"style.{key} must be a 6-digit hex RGB string like '#1F3864'", "usage")
327
+ return value
328
+
329
+
330
+ def build_params(args: argparse.Namespace) -> dict[str, object]:
331
+ command = args.command
332
+
333
+ if command == "info":
334
+ return {}
335
+
336
+ if command == "read_range":
337
+ if not args.range:
338
+ raise ToolError("read_range needs --range (an A1 range, or 'used')", "usage")
339
+ if args.max_cells < 1:
340
+ raise ToolError("--max-cells must be at least 1", "usage")
341
+ return {
342
+ "sheet": _require_sheet(args),
343
+ "range": args.range.strip(),
344
+ "formulas": bool(args.formulas),
345
+ "display": bool(args.display),
346
+ "max_cells": int(args.max_cells),
347
+ }
348
+
349
+ if command == "write_range":
350
+ sheet = _require_sheet(args)
351
+ if not args.range:
352
+ raise ToolError("write_range needs --range (a rectangular A1 range)", "usage")
353
+ _, _, rows, columns = parse_rectangle(args.range)
354
+ if args.clear:
355
+ if args.data is not None or args.data_file is not None:
356
+ raise ToolError("--clear writes nothing; drop --data and --data-file", "usage")
357
+ return {
358
+ "sheet": sheet,
359
+ "range": args.range.strip(),
360
+ "clear": True,
361
+ "rows": 0,
362
+ "cols": 0,
363
+ "cells": [],
364
+ }
365
+ data = _two_dimensional(_json_payload(args.data, args.data_file, "--data"), "--data")
366
+ if len(data) != rows or len(data[0]) != columns:
367
+ raise ToolError(
368
+ f"data is {len(data)} rows by {len(data[0])} columns but {args.range} is "
369
+ f"{rows} rows by {columns} columns; make them match",
370
+ "usage",
371
+ )
372
+ flat = [value for row in data for value in row]
373
+ return {
374
+ "sheet": sheet,
375
+ "range": args.range.strip(),
376
+ "clear": False,
377
+ "rows": rows,
378
+ "cols": columns,
379
+ "cells": flat,
380
+ }
381
+
382
+ if command == "set_formula":
383
+ sheet = _require_sheet(args)
384
+ if bool(args.formula) == bool(args.cells or args.cells_file):
385
+ raise ToolError("pass exactly one of --formula or --cells/--cells-file", "usage")
386
+ if args.formula:
387
+ if not args.range:
388
+ raise ToolError("--formula needs --range", "usage")
389
+ text = args.formula.strip()
390
+ if not text.startswith("="):
391
+ raise ToolError(f"formula must start with '=': '{args.formula}'", "usage")
392
+ return {
393
+ "sheet": sheet,
394
+ "range": args.range.strip(),
395
+ "formula": text,
396
+ "r1c1": bool(args.r1c1),
397
+ }
398
+ mapping = _json_payload(args.cells, args.cells_file, "--cells")
399
+ if not isinstance(mapping, dict) or not mapping:
400
+ raise ToolError('--cells must be a JSON object such as {"C1":"=A1+B1"}', "usage")
401
+ cleaned: dict[str, str] = {}
402
+ for address, text in mapping.items():
403
+ if not isinstance(text, str) or not text.strip().startswith("="):
404
+ raise ToolError(f"formula for {address} must start with '='", "usage")
405
+ if not CELL.match(address.strip()):
406
+ raise ToolError(f"not a single-cell A1 address: '{address}'", "usage")
407
+ cleaned[address.strip().upper()] = text.strip()
408
+ return {"sheet": sheet, "cells": cleaned, "r1c1": bool(args.r1c1)}
409
+
410
+ if command == "style_range":
411
+ sheet = _require_sheet(args)
412
+ if not args.range:
413
+ raise ToolError("style_range needs --range", "usage")
414
+ style = _json_payload(args.style, args.style_file, "--style")
415
+ if not isinstance(style, dict) or not style:
416
+ raise ToolError("--style must be a non-empty JSON object", "usage")
417
+ unknown = sorted(set(style) - STYLE_KEYS)
418
+ if unknown:
419
+ raise ToolError(
420
+ f"unknown style key(s): {', '.join(unknown)}; allowed: {', '.join(sorted(STYLE_KEYS))}",
421
+ "usage",
422
+ )
423
+ if "color" in style:
424
+ _check_color(style["color"], "color")
425
+ if "fill" in style:
426
+ _check_color(style["fill"], "fill")
427
+ if "h_align" in style and style["h_align"] not in ALIGN_H:
428
+ raise ToolError(
429
+ f"style.h_align must be one of {', '.join(sorted(ALIGN_H))}", "usage"
430
+ )
431
+ if "v_align" in style and style["v_align"] not in ALIGN_V:
432
+ raise ToolError(
433
+ f"style.v_align must be one of {', '.join(sorted(ALIGN_V))}", "usage"
434
+ )
435
+ if "border" in style and style["border"] not in ("outline", "all", "none"):
436
+ raise ToolError("style.border must be 'outline', 'all' or 'none'", "usage")
437
+ return {"sheet": sheet, "range": args.range.strip(), "style": style, "merge": bool(args.merge)}
438
+
439
+ if command == "add_chart":
440
+ if args.width < 40 or args.height < 40:
441
+ raise ToolError("--width and --height are in points and must be at least 40", "usage")
442
+ return {
443
+ "sheet": _require_sheet(args),
444
+ "chart_type": CHART_TYPES[args.type],
445
+ "type": args.type,
446
+ "data": args.data.strip(),
447
+ "anchor": args.anchor.strip().upper(),
448
+ "title": args.title,
449
+ "width": float(args.width),
450
+ "height": float(args.height),
451
+ }
452
+
453
+ if command == "add_table":
454
+ if args.name and not re.match(r"^[A-Za-z_][A-Za-z0-9_.]*$", args.name):
455
+ raise ToolError(
456
+ "--name must start with a letter or underscore and contain only letters, "
457
+ "digits, underscores or dots",
458
+ "usage",
459
+ )
460
+ return {
461
+ "sheet": _require_sheet(args),
462
+ "range": args.range.strip(),
463
+ "name": args.name,
464
+ "style": args.style,
465
+ "no_headers": bool(args.no_headers),
466
+ }
467
+
468
+ if command == "recalc":
469
+ return {}
470
+
471
+ if command == "save":
472
+ target = None
473
+ existed = False
474
+ if args.path:
475
+ target = Path(args.path).expanduser().resolve()
476
+ existed = target.exists()
477
+ if target.is_dir():
478
+ raise ToolError(f"--path is a directory, expected a file: {target}", "usage")
479
+ return {"path": str(target) if target else "", "path_existed": existed}
480
+
481
+ if command == "close":
482
+ if args.save and args.discard:
483
+ raise ToolError("--save and --discard are mutually exclusive", "usage")
484
+ return {"save": bool(args.save), "discard": bool(args.discard), "quit_app": bool(args.quit_app)}
485
+
486
+ raise ToolError(f"unknown command '{command}'", "usage")
487
+
488
+
489
+ # ---------------------------------------------------------------------------
490
+ # PowerShell bridge
491
+ # ---------------------------------------------------------------------------
492
+
493
+
494
+ def powershell_exe() -> str:
495
+ found = shutil.which("powershell.exe") or shutil.which("powershell")
496
+ if found:
497
+ return found
498
+ windows = Path(os.environ.get("SystemRoot", r"C:\Windows"))
499
+ candidate = windows / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
500
+ if candidate.is_file():
501
+ return str(candidate)
502
+ raise ToolError(
503
+ "Windows PowerShell 5.1 (powershell.exe) was not found; this tool drives COM "
504
+ "through it because PowerShell 7 has no Marshal.GetActiveObject",
505
+ "powershell",
506
+ )
507
+
508
+
509
+ def decode_output(raw: bytes) -> str:
510
+ for encoding in ("utf-8", locale.getpreferredencoding(False), "cp1252"):
511
+ try:
512
+ return raw.decode(encoding)
513
+ except (UnicodeDecodeError, LookupError):
514
+ continue
515
+ return raw.decode("utf-8", "replace")
516
+
517
+
518
+ def extract_json(text: str) -> dict[str, object] | None:
519
+ start, end = text.find("{"), text.rfind("}")
520
+ if start < 0 or end <= start:
521
+ return None
522
+ try:
523
+ parsed = json.loads(text[start : end + 1])
524
+ except json.JSONDecodeError:
525
+ return None
526
+ return parsed if isinstance(parsed, dict) else None
527
+
528
+
529
+ def run_powershell(request: dict[str, object], timeout: int) -> dict[str, object]:
530
+ payload = json.dumps(request, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
531
+ encoded = base64.b64encode(payload).decode("ascii")
532
+ try:
533
+ # A trailing blank line is load-bearing: `powershell.exe -Command -` reads
534
+ # statements from stdin and never runs the very last one when the input
535
+ # ends on it, so the JSON would be swallowed.
536
+ script = (_POWERSHELL_SCRIPT.replace("__REQUEST_B64__", encoded) + "\n").encode("ascii")
537
+ except UnicodeEncodeError as error: # pragma: no cover - guard against edited constants
538
+ raise ToolError(f"the embedded PowerShell script is not ASCII: {error}", "internal") from error
539
+
540
+ command = [powershell_exe(), "-NoProfile", "-NonInteractive", "-Command", "-"]
541
+ try:
542
+ completed = subprocess.run(
543
+ command, input=script, capture_output=True, timeout=max(1, timeout)
544
+ )
545
+ except FileNotFoundError as error:
546
+ raise ToolError(f"cannot start powershell.exe: {error}", "powershell") from error
547
+ except subprocess.TimeoutExpired as error:
548
+ raise ToolError(
549
+ f"the COM call did not finish within {timeout}s; a dialog may be waiting for "
550
+ "input in the spreadsheet application, or the operation was too large to batch",
551
+ "timeout",
552
+ ) from error
553
+
554
+ stdout = decode_output(completed.stdout).strip()
555
+ stderr = decode_output(completed.stderr).strip()
556
+ result = extract_json(stdout)
557
+ if result is None:
558
+ detail = stderr or stdout or "(no output)"
559
+ raise ToolError(
560
+ f"PowerShell returned no JSON (exit {completed.returncode}): {detail[:900]}",
561
+ "powershell",
562
+ )
563
+ if not result.get("ok") and stderr and "powershell_stderr" not in result:
564
+ result["powershell_stderr"] = stderr[-900:]
565
+ return result
566
+
567
+
568
+ # ---------------------------------------------------------------------------
569
+ # Main
570
+ # ---------------------------------------------------------------------------
571
+
572
+
573
+ def failure_payload(op: str, error: ToolError) -> dict[str, object]:
574
+ payload: dict[str, object] = {
575
+ "ok": False,
576
+ "op": op,
577
+ "stage": error.stage,
578
+ "error": error.message,
579
+ }
580
+ if error.remediation:
581
+ payload["remediation"] = error.remediation
582
+ return payload
583
+
584
+
585
+ def emit(payload: dict[str, object], code: int) -> int:
586
+ text = json.dumps(payload, ensure_ascii=False, indent=2, default=str)
587
+ print(text)
588
+ if code:
589
+ print(text, file=sys.stderr)
590
+ return code
591
+
592
+
593
+ def main() -> int:
594
+ parser = build_parser()
595
+ args = parser.parse_args()
596
+
597
+ if args.diagnose and args.command:
598
+ parser.error("--diagnose runs on its own; drop the command")
599
+ if not args.diagnose and not args.command:
600
+ parser.error("choose a command (info, read_range, ...) or pass --diagnose")
601
+
602
+ op = "diagnose" if args.diagnose else str(args.command)
603
+ try:
604
+ params = {} if args.diagnose else build_params(args)
605
+ except ToolError as error:
606
+ return emit(failure_payload(op, error), 2)
607
+
608
+ request = {
609
+ "op": op,
610
+ "launch_hidden": bool(getattr(args, "launch_hidden", False)),
611
+ "workbook": getattr(args, "workbook", None),
612
+ "params": params,
613
+ }
614
+
615
+ started = time.monotonic()
616
+ try:
617
+ result = run_powershell(request, int(getattr(args, "timeout", 180)))
618
+ except ToolError as error:
619
+ if error.stage == "attach":
620
+ payload = failure_payload(op, error)
621
+ payload["remediation"] = ATTACH_REMEDIATION
622
+ return emit(payload, 1)
623
+ code = 3 if error.stage in ("powershell", "timeout") else 1
624
+ return emit(failure_payload(op, error), code)
625
+
626
+ if not result.get("ok"):
627
+ payload = dict(result)
628
+ payload.setdefault("op", op)
629
+ if payload.get("stage") == "attach" and "remediation" not in payload:
630
+ payload["remediation"] = ATTACH_REMEDIATION
631
+ return emit(payload, 1)
632
+
633
+ result["op"] = op
634
+ result["elapsed_seconds"] = round(time.monotonic() - started, 2)
635
+ return emit(result, 0)
636
+
637
+
638
+ # ---------------------------------------------------------------------------
639
+ # The PowerShell side. ASCII only: it travels through stdin, which Windows
640
+ # PowerShell decodes with the console codepage; all request data is base64.
641
+ # ---------------------------------------------------------------------------
642
+
643
+ _POWERSHELL_SCRIPT = r"""
644
+ $ErrorActionPreference = 'Stop'
645
+ $ProgressPreference = 'SilentlyContinue'
646
+ try { [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) } catch { }
647
+
648
+ function Get-OleColor {
649
+ param([string]$Hex)
650
+ $text = $Hex.Trim()
651
+ if ($text.StartsWith('#')) { $text = $text.Substring(1) }
652
+ if ($text.Length -ne 6 -or $text -notmatch '^[0-9A-Fa-f]{6}$') {
653
+ throw "colour must be a 6-digit hex RGB string like '#1F3864'; got '$Hex'"
654
+ }
655
+ $r = [Convert]::ToInt32($text.Substring(0, 2), 16)
656
+ $g = [Convert]::ToInt32($text.Substring(2, 2), 16)
657
+ $b = [Convert]::ToInt32($text.Substring(4, 2), 16)
658
+ return ($r + ($g * 256) + ($b * 65536))
659
+ }
660
+
661
+ function Get-SaveFormat {
662
+ param([string]$Path)
663
+ $extension = [IO.Path]::GetExtension($Path).ToLowerInvariant()
664
+ if ($extension -eq '.xlsx') { return 51 }
665
+ if ($extension -eq '.xlsm') { return 52 }
666
+ if ($extension -eq '.xls') { return 56 }
667
+ if ($extension -eq '.csv') { return 6 }
668
+ if ($extension -eq '.txt') { return -4158 }
669
+ if ($extension -eq '.html' -or $extension -eq '.htm') { return 44 }
670
+ throw "cannot save to '$Path': supported extensions are .xlsx, .xlsm, .xls, .csv, .txt, .html"
671
+ }
672
+
673
+ function Get-WorkbookList {
674
+ param($App)
675
+ $list = New-Object System.Collections.ArrayList
676
+ $count = 0
677
+ try { $count = [int]$App.Workbooks.Count } catch { $count = 0 }
678
+ for ($i = 1; $i -le $count; $i++) {
679
+ $book = $null
680
+ try { $book = $App.Workbooks.Item($i) } catch { $book = $null }
681
+ if ($null -ne $book) { [void]$list.Add($book) }
682
+ }
683
+ # Workbook objects are not enumerable, so the list can flow out unrolled.
684
+ return $list
685
+ }
686
+
687
+ function Get-BookInfo {
688
+ param($Book)
689
+ $full = ''
690
+ try { $full = [string]$Book.FullName } catch { $full = '' }
691
+ $directory = ''
692
+ try { $directory = [string]$Book.Path } catch { $directory = '' }
693
+ $saved = $null
694
+ try { $saved = [bool]$Book.Saved } catch { $saved = $null }
695
+ $names = New-Object System.Collections.ArrayList
696
+ try {
697
+ for ($i = 1; $i -le $Book.Sheets.Count; $i++) {
698
+ [void]$names.Add([string]$Book.Sheets.Item($i).Name)
699
+ }
700
+ } catch { }
701
+ $path = $null
702
+ if ($directory -ne '') { $path = $full }
703
+ return [ordered]@{
704
+ name = [string]$Book.Name
705
+ full_name = $full
706
+ path = $path
707
+ saved = $saved
708
+ sheets = @($names)
709
+ }
710
+ }
711
+
712
+ function Get-SheetInfo {
713
+ param($Sheet)
714
+ $used = ''
715
+ try { $used = [string]$Sheet.UsedRange.Address() } catch { $used = '' }
716
+ $charts = 0
717
+ try { $charts = [int]$Sheet.ChartObjects().Count } catch { $charts = 0 }
718
+ $tables = New-Object System.Collections.ArrayList
719
+ try {
720
+ for ($i = 1; $i -le $Sheet.ListObjects.Count; $i++) {
721
+ [void]$tables.Add([string]$Sheet.ListObjects.Item($i).Name)
722
+ }
723
+ } catch { }
724
+ $pivots = 0
725
+ try { $pivots = [int]$Sheet.PivotTables().Count } catch { $pivots = 0 }
726
+ $visible = 1
727
+ try { $visible = [int]$Sheet.Visible } catch { $visible = 1 }
728
+ $protected = $false
729
+ try { $protected = [bool]$Sheet.ProtectContents } catch { $protected = $false }
730
+ return [ordered]@{
731
+ name = [string]$Sheet.Name
732
+ used_range = $used
733
+ charts = $charts
734
+ tables = @($tables)
735
+ pivot_tables = $pivots
736
+ visible = $visible
737
+ protected = $protected
738
+ }
739
+ }
740
+
741
+ function Resolve-Workbook {
742
+ param($App, [string]$Selector)
743
+ $books = @(Get-WorkbookList $App)
744
+ if ($books.Count -eq 0) { throw 'no workbook is open in the attached application' }
745
+ if ([string]::IsNullOrEmpty($Selector)) {
746
+ if ($books.Count -eq 1) { return $books[0] }
747
+ $names = @()
748
+ foreach ($book in $books) { $names += [string]$book.Name }
749
+ throw "several workbooks are open ($($names -join ', ')); pass --workbook with the file name or full path"
750
+ }
751
+ $hits = @()
752
+ foreach ($book in $books) {
753
+ $full = ''
754
+ try { $full = [string]$book.FullName } catch { $full = '' }
755
+ $name = [string]$book.Name
756
+ if ($full -ne '' -and ($full -eq $Selector -or $full -ieq $Selector)) { $hits += $book; continue }
757
+ if ([string]::Equals($name, $Selector, [StringComparison]::OrdinalIgnoreCase)) { $hits += $book; continue }
758
+ if ($full -ne '' -and $full.IndexOf($Selector, [StringComparison]::OrdinalIgnoreCase) -ge 0) { $hits += $book; continue }
759
+ if ($name.IndexOf($Selector, [StringComparison]::OrdinalIgnoreCase) -ge 0) { $hits += $book }
760
+ }
761
+ if ($hits.Count -eq 1) { return $hits[0] }
762
+ if ($hits.Count -eq 0) { throw "no open workbook matches '$Selector'" }
763
+ throw "'$Selector' matches $($hits.Count) open workbooks; pass the full path instead"
764
+ }
765
+
766
+ function Resolve-Sheet {
767
+ param($Book, [string]$Name)
768
+ if ([string]::IsNullOrEmpty($Name)) {
769
+ throw '--sheet is required: this tool never falls back to the active sheet'
770
+ }
771
+ $sheet = $null
772
+ try { $sheet = $Book.Worksheets.Item($Name) } catch { $sheet = $null }
773
+ if ($null -eq $sheet) {
774
+ $names = @()
775
+ try {
776
+ for ($i = 1; $i -le $Book.Worksheets.Count; $i++) {
777
+ $names += [string]$Book.Worksheets.Item($i).Name
778
+ }
779
+ } catch { }
780
+ throw "the workbook has no sheet named '$Name'; its sheets are: $($names -join ', ')"
781
+ }
782
+ return $sheet
783
+ }
784
+
785
+ function Resolve-Range {
786
+ param($Sheet, [string]$Address)
787
+ # A Range is enumerable, so returning it bare would hand the caller one
788
+ # object per cell; the unary comma keeps it whole.
789
+ if ([string]::IsNullOrEmpty($Address) -or $Address -eq 'used') {
790
+ return ,$Sheet.UsedRange
791
+ }
792
+ $range = $null
793
+ try { $range = $Sheet.Range($Address) } catch { $range = $null }
794
+ if ($null -eq $range) {
795
+ throw "not a valid A1 range on sheet '$($Sheet.Name)': '$Address'"
796
+ }
797
+ return ,$range
798
+ }
799
+
800
+ function Get-RangeFacts {
801
+ param($Range)
802
+ return [ordered]@{
803
+ address = [string]$Range.Address()
804
+ rows = [int]$Range.Rows.Count
805
+ columns = [int]$Range.Columns.Count
806
+ cells = [int]$Range.Count
807
+ }
808
+ }
809
+
810
+ function Get-RawCell {
811
+ param($Raw, [int]$Rows, [int]$Columns, [int]$Row, [int]$Column)
812
+ if ($Rows -eq 1 -and $Columns -eq 1) { return $Raw }
813
+ return $Raw[$Row, $Column]
814
+ }
815
+
816
+ function Read-Grid {
817
+ param($Range, [int]$Rows, [int]$Columns, [bool]$AsFormula)
818
+ $raw = $null
819
+ if ($AsFormula) { $raw = $Range.Formula } else { $raw = $Range.Value2 }
820
+ $grid = New-Object System.Collections.ArrayList
821
+ $errors = New-Object System.Collections.ArrayList
822
+ for ($r = 1; $r -le $Rows; $r++) {
823
+ $line = New-Object System.Collections.ArrayList
824
+ for ($c = 1; $c -le $Columns; $c++) {
825
+ $value = Get-RawCell $raw $Rows $Columns $r $c
826
+ if (-not $AsFormula -and $value -is [int]) {
827
+ # A live value only ever arrives as Double, String, Boolean or null;
828
+ # an integer is the marshalled CVErr variant behind #DIV/0! and friends.
829
+ $text = ''
830
+ $formula = ''
831
+ $address = ''
832
+ try {
833
+ $cell = $Range.Cells.Item($r, $c)
834
+ $text = [string]$cell.Text
835
+ $formula = [string]$cell.Formula
836
+ $address = [string]$cell.Address(0, 0)
837
+ } catch { $text = '#ERROR' }
838
+ [void]$errors.Add([ordered]@{ cell = $address; value = $text; formula = $formula })
839
+ $value = $text
840
+ }
841
+ [void]$line.Add($value)
842
+ }
843
+ [void]$grid.Add(@($line))
844
+ }
845
+ return @{ grid = @($grid); errors = @($errors) }
846
+ }
847
+
848
+ function Read-TextGrid {
849
+ param($Range, [int]$Rows, [int]$Columns)
850
+ $grid = New-Object System.Collections.ArrayList
851
+ for ($r = 1; $r -le $Rows; $r++) {
852
+ $line = New-Object System.Collections.ArrayList
853
+ for ($c = 1; $c -le $Columns; $c++) {
854
+ $text = ''
855
+ try { $text = [string]$Range.Cells.Item($r, $c).Text } catch { $text = '' }
856
+ [void]$line.Add($text)
857
+ }
858
+ [void]$grid.Add(@($line))
859
+ }
860
+ return @($grid)
861
+ }
862
+
863
+ $script:Result = $null
864
+
865
+ function Invoke-Request {
866
+ try {
867
+ $requestJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__REQUEST_B64__'))
868
+ $request = $requestJson | ConvertFrom-Json
869
+ $script:Request = $request
870
+ $operation = [string]$request.op
871
+ $params = $request.params
872
+
873
+ # ---- attach ----------------------------------------------------
874
+ $attempts = New-Object System.Collections.ArrayList
875
+ $app = $null
876
+ $progid = $null
877
+ $mechanism = ''
878
+ $launched = $false
879
+ foreach ($candidate in @('Excel.Application', 'Ket.Application')) {
880
+ try {
881
+ $app = [Runtime.InteropServices.Marshal]::GetActiveObject($candidate)
882
+ $progid = $candidate
883
+ $mechanism = 'GetActiveObject'
884
+ break
885
+ } catch {
886
+ [void]$attempts.Add([ordered]@{ progid = $candidate; error = $_.Exception.Message })
887
+ $app = $null
888
+ }
889
+ }
890
+ if ($null -eq $app -and [bool]$request.launch_hidden) {
891
+ foreach ($candidate in @('Ket.Application', 'Excel.Application')) {
892
+ try {
893
+ $app = New-Object -ComObject $candidate
894
+ $progid = $candidate
895
+ $mechanism = 'New-Object -ComObject'
896
+ $launched = $true
897
+ break
898
+ } catch {
899
+ [void]$attempts.Add([ordered]@{ progid = $candidate; error = "launch failed: $($_.Exception.Message)" })
900
+ $app = $null
901
+ }
902
+ }
903
+ if ($null -ne $app) {
904
+ try { $app.Visible = $false } catch { }
905
+ try {
906
+ if ([int]$app.Workbooks.Count -eq 0) { [void]$app.Workbooks.Add() }
907
+ } catch { }
908
+ }
909
+ }
910
+ if ($null -eq $app) {
911
+ $script:Result = [ordered]@{
912
+ ok = $false
913
+ stage = 'attach'
914
+ error = 'no running Excel or WPS Spreadsheets instance was found'
915
+ attach_attempts = @($attempts)
916
+ remediation = 'Open the workbook in Excel or WPS Spreadsheets and keep it open, then run the command again. Nothing was started: this tool never creates a hidden instance for normal work (--launch-hidden exists only for self-tests).'
917
+ }
918
+ return
919
+ }
920
+
921
+ $applicationPath = ''
922
+ try { $applicationPath = [string]$app.Path } catch { $applicationPath = '' }
923
+ $vendor = 'Microsoft Excel'
924
+ if ($applicationPath -match '(?i)kingsoft|wps') { $vendor = 'WPS Spreadsheets' }
925
+ $engine = [ordered]@{
926
+ mechanism = 'com'
927
+ attach = $mechanism
928
+ progid = $progid
929
+ launched = $launched
930
+ powershell = $PSVersionTable.PSVersion.ToString()
931
+ app_name = ''
932
+ version = ''
933
+ vendor = $vendor
934
+ path = $applicationPath
935
+ }
936
+ try { $engine['app_name'] = [string]$app.Name } catch { }
937
+ try { $engine['version'] = [string]$app.Version } catch { }
938
+
939
+ $alertsBefore = 1
940
+ $screenBefore = $true
941
+ try { $alertsBefore = [int]$app.DisplayAlerts } catch { }
942
+ try { $screenBefore = [bool]$app.ScreenUpdating } catch { }
943
+
944
+ if ($operation -eq 'diagnose') {
945
+ $rotPresent = $false
946
+ try { $rotPresent = [bool]([Runtime.InteropServices.Marshal].GetMethod('GetActiveObject')) } catch { }
947
+ $books = New-Object System.Collections.ArrayList
948
+ $sheets = New-Object System.Collections.ArrayList
949
+ foreach ($book in @(Get-WorkbookList $app)) {
950
+ [void]$books.Add((Get-BookInfo $book))
951
+ for ($i = 1; $i -le $book.Sheets.Count; $i++) {
952
+ $sheet = $book.Sheets.Item($i)
953
+ $used = ''
954
+ try { $used = [string]$sheet.UsedRange.Address() } catch { }
955
+ [void]$sheets.Add([ordered]@{
956
+ workbook = [string]$book.Name
957
+ sheet = [string]$sheet.Name
958
+ used_range = $used
959
+ })
960
+ }
961
+ }
962
+ $script:Result = [ordered]@{
963
+ ok = $true
964
+ op = 'diagnose'
965
+ engine = $engine
966
+ attach_attempts = @($attempts)
967
+ get_active_object = $rotPresent
968
+ workbooks = @($books)
969
+ sheets = @($sheets)
970
+ commands = @('info', 'read_range', 'write_range', 'set_formula', 'style_range', 'add_chart', 'add_table', 'recalc', 'save', 'close')
971
+ notes = @(
972
+ 'attach uses [Runtime.InteropServices.Marshal]::GetActiveObject in Windows PowerShell 5.1; PowerShell 7 removed that method',
973
+ 'WPS Spreadsheets registers Excel.Application as an alias, so the application name is not proof of which product is running; vendor is inferred from Application.Path'
974
+ )
975
+ }
976
+ return
977
+ }
978
+
979
+ $book = Resolve-Workbook $app ([string]$request.workbook)
980
+
981
+ # ---- info ------------------------------------------------------
982
+ if ($operation -eq 'info') {
983
+ $warnings = New-Object System.Collections.ArrayList
984
+ $activeName = ''
985
+ try { $activeName = [string]$app.ActiveWorkbook.Name } catch { }
986
+ $books = New-Object System.Collections.ArrayList
987
+ foreach ($openBook in @(Get-WorkbookList $app)) {
988
+ $entry = Get-BookInfo $openBook
989
+ $entry['active'] = ($entry['name'] -eq $activeName)
990
+ [void]$books.Add($entry)
991
+ }
992
+ $target = Get-BookInfo $book
993
+ $details = New-Object System.Collections.ArrayList
994
+ for ($i = 1; $i -le $book.Sheets.Count; $i++) {
995
+ [void]$details.Add((Get-SheetInfo $book.Sheets.Item($i)))
996
+ }
997
+ $target['sheet_details'] = @($details)
998
+ try {
999
+ if ([bool]$book.ReadOnly) {
1000
+ [void]$warnings.Add('the workbook is open read-only; a save will fail or ask for another path')
1001
+ }
1002
+ } catch { }
1003
+ $script:Result = [ordered]@{
1004
+ ok = $true
1005
+ op = 'info'
1006
+ engine = $engine
1007
+ workbook = $target
1008
+ workbooks = @($books)
1009
+ warnings = @($warnings)
1010
+ }
1011
+ return
1012
+ }
1013
+
1014
+ # ---- read_range ------------------------------------------------
1015
+ if ($operation -eq 'read_range') {
1016
+ $sheet = Resolve-Sheet $book ([string]$params.sheet)
1017
+ $range = Resolve-Range $sheet ([string]$params.range)
1018
+ $facts = Get-RangeFacts $range
1019
+ if ($facts['cells'] -gt [int]$params.max_cells) {
1020
+ throw "range $($facts['address']) has $($facts['cells']) cells, over the limit of $($params.max_cells); narrow --range or raise --max-cells"
1021
+ }
1022
+ $read = Read-Grid $range $facts['rows'] $facts['columns'] $false
1023
+ $result = [ordered]@{
1024
+ ok = $true
1025
+ op = 'read_range'
1026
+ engine = $engine
1027
+ workbook = (Get-BookInfo $book)
1028
+ sheet = [string]$sheet.Name
1029
+ range = [string]$params.range
1030
+ address = $facts['address']
1031
+ rows = $facts['rows']
1032
+ columns = $facts['columns']
1033
+ cells = $facts['cells']
1034
+ values = $read.grid
1035
+ errors = $read.errors
1036
+ merged = @()
1037
+ }
1038
+ try {
1039
+ if ([bool]$range.MergeCells) { $result['merged'] = @([string]$range.MergeArea.Address()) }
1040
+ } catch { }
1041
+ if ([bool]$params.formulas) {
1042
+ $formulas = Read-Grid $range $facts['rows'] $facts['columns'] $true
1043
+ $result['formulas'] = $formulas.grid
1044
+ }
1045
+ if ([bool]$params.display) {
1046
+ if ([int]$facts['cells'] -gt 500) {
1047
+ throw '--display is limited to 500 cells per read; read a smaller range'
1048
+ }
1049
+ $result['display'] = Read-TextGrid $range $facts['rows'] $facts['columns']
1050
+ }
1051
+ $formulaCount = 0
1052
+ try {
1053
+ for ($r = 1; $r -le $facts['rows']; $r++) {
1054
+ for ($c = 1; $c -le $facts['columns']; $c++) {
1055
+ $text = [string]$range.Cells.Item($r, $c).Formula
1056
+ if ($text.StartsWith('=')) { $formulaCount = $formulaCount + 1 }
1057
+ }
1058
+ }
1059
+ } catch { $formulaCount = -1 }
1060
+ $result['formula_cells'] = $formulaCount
1061
+ $script:Result = $result
1062
+ return
1063
+ }
1064
+
1065
+ # ---- write_range -----------------------------------------------
1066
+ if ($operation -eq 'write_range') {
1067
+ $sheet = Resolve-Sheet $book ([string]$params.sheet)
1068
+ $range = Resolve-Range $sheet ([string]$params.range)
1069
+ $facts = Get-RangeFacts $range
1070
+ $warnings = New-Object System.Collections.ArrayList
1071
+ $cleared = [bool]$params.clear
1072
+ $written = 0
1073
+ try {
1074
+ $app.DisplayAlerts = 0
1075
+ $app.ScreenUpdating = $false
1076
+ if ($cleared) {
1077
+ $range.ClearContents()
1078
+ $written = 0
1079
+ } else {
1080
+ if ([int]$params.rows -ne $facts['rows'] -or [int]$params.cols -ne $facts['columns']) {
1081
+ throw "the data block is $($params.rows)x$($params.cols) but $($facts['address']) is $($facts['rows']) rows by $($facts['columns']) columns"
1082
+ }
1083
+ $values = @($params.cells)
1084
+ $grid = New-Object 'object[,]' $facts['rows'], $facts['columns']
1085
+ $index = 0
1086
+ for ($r = 0; $r -lt $facts['rows']; $r++) {
1087
+ for ($c = 0; $c -lt $facts['columns']; $c++) {
1088
+ $value = $values[$index]
1089
+ $index = $index + 1
1090
+ if ($value -is [int] -or $value -is [long] -or $value -is [double] -or $value -is [decimal]) {
1091
+ $grid[$r, $c] = [double]$value
1092
+ } else {
1093
+ $grid[$r, $c] = $value
1094
+ }
1095
+ }
1096
+ }
1097
+ $range.Value2 = $grid
1098
+ $written = $facts['cells']
1099
+ }
1100
+ } finally {
1101
+ try { $app.ScreenUpdating = $screenBefore } catch { }
1102
+ try { $app.DisplayAlerts = $alertsBefore } catch { }
1103
+ }
1104
+ if (-not $cleared) {
1105
+ $stored = $range.Value2
1106
+ $index = 0
1107
+ $warned = 0
1108
+ foreach ($value in @($params.cells)) {
1109
+ $row = [int][math]::Floor($index / $facts['columns'])
1110
+ $column = $index % $facts['columns']
1111
+ $index = $index + 1
1112
+ if (-not ($value -is [string]) -or $warned -ge 10) { continue }
1113
+ $after = Get-RawCell $stored $facts['rows'] $facts['columns'] ($row + 1) ($column + 1)
1114
+ $address = ''
1115
+ try { $address = [string]$range.Cells.Item($row + 1, $column + 1).Address(0, 0) } catch { $address = '' }
1116
+ if ($value.StartsWith('=')) {
1117
+ [void]$warnings.Add("$address starts with '=' so it was stored as a formula, not as text: $value")
1118
+ $warned = $warned + 1
1119
+ continue
1120
+ }
1121
+ if ($null -ne $after -and -not ($after -is [string])) {
1122
+ [void]$warnings.Add("$address was sent as text '$value' but the application stored $after; set a number format or send a serial for dates")
1123
+ $warned = $warned + 1
1124
+ }
1125
+ }
1126
+ }
1127
+ $script:Result = [ordered]@{
1128
+ ok = $true
1129
+ op = 'write_range'
1130
+ engine = $engine
1131
+ workbook = (Get-BookInfo $book)
1132
+ sheet = [string]$sheet.Name
1133
+ range = [string]$params.range
1134
+ address = $facts['address']
1135
+ rows = $facts['rows']
1136
+ columns = $facts['columns']
1137
+ cells = $facts['cells']
1138
+ action = 'cleared'
1139
+ written = $written
1140
+ warnings = @($warnings)
1141
+ }
1142
+ if (-not $cleared) { $script:Result['action'] = 'wrote' }
1143
+ return
1144
+ }
1145
+
1146
+ # ---- set_formula -----------------------------------------------
1147
+ if ($operation -eq 'set_formula') {
1148
+ $sheet = Resolve-Sheet $book ([string]$params.sheet)
1149
+ $writtenFormulas = New-Object System.Collections.ArrayList
1150
+ $rangeText = ''
1151
+ $cellCount = 0
1152
+ try {
1153
+ $app.DisplayAlerts = 0
1154
+ $app.ScreenUpdating = $false
1155
+ if ($null -ne $params.cells) {
1156
+ $properties = $params.cells.PSObject.Properties
1157
+ foreach ($property in $properties) {
1158
+ $address = [string]$property.Name
1159
+ $formulaText = [string]$property.Value
1160
+ if (-not $formulaText.StartsWith('=')) {
1161
+ throw "formula for $address must start with '=': $formulaText"
1162
+ }
1163
+ $target = Resolve-Range $sheet $address
1164
+ if ([bool]$params.r1c1) { $target.FormulaR1C1 = $formulaText }
1165
+ else { $target.Formula = $formulaText }
1166
+ $back = ''
1167
+ try { $back = [string]$target.Formula } catch { $back = '' }
1168
+ [void]$writtenFormulas.Add([ordered]@{
1169
+ cell = [string]$target.Address(0, 0)
1170
+ formula = $formulaText
1171
+ stored = $back
1172
+ })
1173
+ }
1174
+ $cellCount = $writtenFormulas.Count
1175
+ $rangeText = "single cells"
1176
+ } else {
1177
+ $range = Resolve-Range $sheet ([string]$params.range)
1178
+ if (-not ([string]$params.formula).StartsWith('=')) {
1179
+ throw "formula must start with '=': $($params.formula)"
1180
+ }
1181
+ if ([bool]$params.r1c1) { $range.FormulaR1C1 = [string]$params.formula }
1182
+ else { $range.Formula = [string]$params.formula }
1183
+ $cellCount = [int]$range.Count
1184
+ $rangeText = [string]$range.Address()
1185
+ $sample = $range.Cells.Item(1, 1)
1186
+ [void]$writtenFormulas.Add([ordered]@{
1187
+ cell = [string]$sample.Address(0, 0)
1188
+ formula = [string]$params.formula
1189
+ stored = [string]$sample.Formula
1190
+ })
1191
+ }
1192
+ } finally {
1193
+ try { $app.ScreenUpdating = $screenBefore } catch { }
1194
+ try { $app.DisplayAlerts = $alertsBefore } catch { }
1195
+ }
1196
+ $warnings = New-Object System.Collections.ArrayList
1197
+ $calculation = $null
1198
+ try { $calculation = [int]$app.Calculation } catch { $calculation = $null }
1199
+ if ($calculation -eq -4135) {
1200
+ [void]$warnings.Add('the workbook is in manual calculation mode; run recalc before reading these numbers')
1201
+ }
1202
+ $script:Result = [ordered]@{
1203
+ ok = $true
1204
+ op = 'set_formula'
1205
+ engine = $engine
1206
+ workbook = (Get-BookInfo $book)
1207
+ sheet = [string]$sheet.Name
1208
+ range = $rangeText
1209
+ cells = $cellCount
1210
+ formulas = @($writtenFormulas)
1211
+ calculation = $calculation
1212
+ warnings = @($warnings)
1213
+ }
1214
+ return
1215
+ }
1216
+
1217
+ # ---- style_range -----------------------------------------------
1218
+ if ($operation -eq 'style_range') {
1219
+ $sheet = Resolve-Sheet $book ([string]$params.sheet)
1220
+ $range = Resolve-Range $sheet ([string]$params.range)
1221
+ $facts = Get-RangeFacts $range
1222
+ $style = $params.style
1223
+ $applied = New-Object System.Collections.ArrayList
1224
+ $colors = [ordered]@{}
1225
+ try {
1226
+ $app.DisplayAlerts = 0
1227
+ $app.ScreenUpdating = $false
1228
+ if ($null -ne $style.font_name) { $range.Font.Name = [string]$style.font_name; [void]$applied.Add('font_name') }
1229
+ if ($null -ne $style.font_size) { $range.Font.Size = [double]$style.font_size; [void]$applied.Add('font_size') }
1230
+ if ($null -ne $style.bold) { $range.Font.Bold = [bool]$style.bold; [void]$applied.Add('bold') }
1231
+ if ($null -ne $style.italic) { $range.Font.Italic = [bool]$style.italic; [void]$applied.Add('italic') }
1232
+ if ($null -ne $style.color) {
1233
+ $ole = Get-OleColor ([string]$style.color)
1234
+ $range.Font.Color = $ole
1235
+ $colors['font_bgr'] = $ole
1236
+ $colors['font_hex'] = [string]$style.color
1237
+ [void]$applied.Add('color')
1238
+ }
1239
+ if ($null -ne $style.fill) {
1240
+ $ole = Get-OleColor ([string]$style.fill)
1241
+ $range.Interior.Color = $ole
1242
+ $range.Interior.Pattern = 1
1243
+ $colors['fill_bgr'] = $ole
1244
+ $colors['fill_hex'] = [string]$style.fill
1245
+ [void]$applied.Add('fill')
1246
+ }
1247
+ if ($null -ne $style.number_format) {
1248
+ $range.NumberFormat = [string]$style.number_format
1249
+ [void]$applied.Add('number_format')
1250
+ }
1251
+ if ($null -ne $style.h_align) {
1252
+ $map = @{ left = -4131; center = -4108; right = -4152; general = 1; justify = -4130 }
1253
+ $range.HorizontalAlignment = $map[[string]$style.h_align]
1254
+ [void]$applied.Add('h_align')
1255
+ }
1256
+ if ($null -ne $style.v_align) {
1257
+ $map = @{ top = -4160; center = -4108; bottom = -4104 }
1258
+ $range.VerticalAlignment = $map[[string]$style.v_align]
1259
+ [void]$applied.Add('v_align')
1260
+ }
1261
+ if ($null -ne $style.wrap) { $range.WrapText = [bool]$style.wrap; [void]$applied.Add('wrap') }
1262
+ if ($null -ne $style.indent) { $range.IndentLevel = [int]$style.indent; [void]$applied.Add('indent') }
1263
+ if ($null -ne $style.border) {
1264
+ $border = [string]$style.border
1265
+ if ($border -eq 'outline') {
1266
+ $range.BorderAround(1, 2)
1267
+ } elseif ($border -eq 'all') {
1268
+ $range.Borders.LineStyle = 1
1269
+ } else {
1270
+ $range.Borders.LineStyle = -4142
1271
+ }
1272
+ [void]$applied.Add('border')
1273
+ }
1274
+ if ($null -ne $style.column_width) {
1275
+ $width = [double]$style.column_width
1276
+ if ($width -lt 0.5) { $width = 0.5 }
1277
+ if ($width -gt 60) { $width = 60 }
1278
+ $range.EntireColumn.ColumnWidth = $width
1279
+ [void]$applied.Add('column_width')
1280
+ }
1281
+ if ([bool]$params.merge) {
1282
+ $range.Merge()
1283
+ [void]$applied.Add('merge')
1284
+ }
1285
+ } finally {
1286
+ try { $app.ScreenUpdating = $screenBefore } catch { }
1287
+ try { $app.DisplayAlerts = $alertsBefore } catch { }
1288
+ }
1289
+ $numberFormat = ''
1290
+ try { $numberFormat = [string]$range.NumberFormat } catch { }
1291
+ $mergeArea = ''
1292
+ try { if ([bool]$range.MergeCells) { $mergeArea = [string]$range.MergeArea.Address() } } catch { }
1293
+ $script:Result = [ordered]@{
1294
+ ok = $true
1295
+ op = 'style_range'
1296
+ engine = $engine
1297
+ workbook = (Get-BookInfo $book)
1298
+ sheet = [string]$sheet.Name
1299
+ range = [string]$params.range
1300
+ address = $facts['address']
1301
+ cells = $facts['cells']
1302
+ applied = @($applied)
1303
+ colors = $colors
1304
+ number_format = $numberFormat
1305
+ merged = $mergeArea
1306
+ }
1307
+ return
1308
+ }
1309
+
1310
+ # ---- add_chart -------------------------------------------------
1311
+ if ($operation -eq 'add_chart') {
1312
+ $sheet = Resolve-Sheet $book ([string]$params.sheet)
1313
+ $dataSheet = $sheet
1314
+ $dataAddress = [string]$params.data
1315
+ if ($dataAddress.Contains('!')) {
1316
+ $split = $dataAddress.Split('!')
1317
+ $dataSheet = Resolve-Sheet $book $split[0].Trim("'")
1318
+ $dataAddress = $split[1]
1319
+ }
1320
+ $source = Resolve-Range $dataSheet $dataAddress
1321
+ $anchor = Resolve-Range $sheet ([string]$params.anchor)
1322
+ $warnings = New-Object System.Collections.ArrayList
1323
+ $blank = $true
1324
+ $sourceCells = [int]$source.Count
1325
+ if ($sourceCells -le 5000) {
1326
+ try {
1327
+ $raw = $source.Value2
1328
+ for ($r = 1; $r -le [int]$source.Rows.Count; $r++) {
1329
+ for ($c = 1; $c -le [int]$source.Columns.Count; $c++) {
1330
+ $value = Get-RawCell $raw ([int]$source.Rows.Count) ([int]$source.Columns.Count) $r $c
1331
+ if ($null -ne $value) { $blank = $false; break }
1332
+ }
1333
+ if (-not $blank) { break }
1334
+ }
1335
+ } catch { $blank = $false }
1336
+ } else {
1337
+ $blank = $false
1338
+ }
1339
+ if ($blank) {
1340
+ [void]$warnings.Add('every source cell is empty; the chart will render blank until the range holds data')
1341
+ }
1342
+ try {
1343
+ $app.DisplayAlerts = 0
1344
+ $app.ScreenUpdating = $false
1345
+ $chartObject = $sheet.ChartObjects().Add(
1346
+ [double]$anchor.Left,
1347
+ [double]$anchor.Top,
1348
+ [double]$params.width,
1349
+ [double]$params.height
1350
+ )
1351
+ $chart = $chartObject.Chart
1352
+ $chart.SetSourceData($source)
1353
+ $chart.ChartType = [int]$params.chart_type
1354
+ if ([string]::IsNullOrEmpty([string]$params.title)) {
1355
+ $chart.HasTitle = $false
1356
+ } else {
1357
+ $chart.HasTitle = $true
1358
+ $chart.ChartTitle.Text = [string]$params.title
1359
+ }
1360
+ } finally {
1361
+ try { $app.ScreenUpdating = $screenBefore } catch { }
1362
+ try { $app.DisplayAlerts = $alertsBefore } catch { }
1363
+ }
1364
+ $series = 0
1365
+ try { $series = [int]$chart.SeriesCollection().Count } catch { $series = 0 }
1366
+ $script:Result = [ordered]@{
1367
+ ok = $true
1368
+ op = 'add_chart'
1369
+ engine = $engine
1370
+ workbook = (Get-BookInfo $book)
1371
+ sheet = [string]$sheet.Name
1372
+ chart = [string]$chartObject.Name
1373
+ type = [string]$params.type
1374
+ chart_type = [int]$params.chart_type
1375
+ anchor = [string]$anchor.Address()
1376
+ source = "$([string]$dataSheet.Name)!$([string]$source.Address())"
1377
+ source_cells = $sourceCells
1378
+ series = $series
1379
+ title = [string]$params.title
1380
+ warnings = @($warnings)
1381
+ }
1382
+ return
1383
+ }
1384
+
1385
+ # ---- add_table -------------------------------------------------
1386
+ if ($operation -eq 'add_table') {
1387
+ $sheet = Resolve-Sheet $book ([string]$params.sheet)
1388
+ $range = Resolve-Range $sheet ([string]$params.range)
1389
+ $conflict = ''
1390
+ try {
1391
+ for ($i = 1; $i -le $sheet.ListObjects.Count; $i++) {
1392
+ $existing = $sheet.ListObjects.Item($i)
1393
+ $overlap = $app.Intersect($range, $existing.Range)
1394
+ if ($null -ne $overlap) { $conflict = [string]$existing.Name; break }
1395
+ }
1396
+ } catch { $conflict = '' }
1397
+ if ($conflict -ne '') {
1398
+ throw "range $([string]$range.Address()) overlaps the existing table '$conflict'; extend that table instead of adding a second one"
1399
+ }
1400
+ $hasHeaders = 1
1401
+ if ([bool]$params.no_headers) { $hasHeaders = 0 }
1402
+ $styleWarning = ''
1403
+ try {
1404
+ $app.DisplayAlerts = 0
1405
+ $app.ScreenUpdating = $false
1406
+ $table = $sheet.ListObjects.Add(1, $range, [Type]::Missing, $hasHeaders)
1407
+ if (-not [string]::IsNullOrEmpty([string]$params.name)) { $table.Name = [string]$params.name }
1408
+ if (-not [string]::IsNullOrEmpty([string]$params.style)) {
1409
+ try { $table.TableStyle = [string]$params.style }
1410
+ catch { $styleWarning = "the table was created but the style '$($params.style)' was refused" }
1411
+ }
1412
+ } finally {
1413
+ try { $app.ScreenUpdating = $screenBefore } catch { }
1414
+ try { $app.DisplayAlerts = $alertsBefore } catch { }
1415
+ }
1416
+ $bodyRows = 0
1417
+ try { $bodyRows = [int]$table.DataBodyRange.Rows.Count } catch { $bodyRows = 0 }
1418
+ $tableRange = $range
1419
+ try { $tableRange = $table.Range } catch { $tableRange = $range }
1420
+ $warnings = New-Object System.Collections.ArrayList
1421
+ if (-not [string]::IsNullOrEmpty($styleWarning)) { [void]$warnings.Add($styleWarning) }
1422
+ if ($hasHeaders -eq 0) {
1423
+ [void]$warnings.Add('with no headers the application generated a header row above the data, so the values moved down one row and the table starts at the header')
1424
+ }
1425
+ $script:Result = [ordered]@{
1426
+ ok = $true
1427
+ op = 'add_table'
1428
+ engine = $engine
1429
+ workbook = (Get-BookInfo $book)
1430
+ sheet = [string]$sheet.Name
1431
+ table = [string]$table.Name
1432
+ requested_range = [string]$params.range
1433
+ range = [string]$tableRange.Address()
1434
+ cells = [int]$tableRange.Count
1435
+ rows = [int]$tableRange.Rows.Count
1436
+ columns = [int]$tableRange.Columns.Count
1437
+ body_rows = $bodyRows
1438
+ has_headers = ($hasHeaders -eq 1)
1439
+ style = [string]$params.style
1440
+ warnings = @($warnings)
1441
+ }
1442
+ return
1443
+ }
1444
+
1445
+ # ---- recalc ----------------------------------------------------
1446
+ if ($operation -eq 'recalc') {
1447
+ $calculating = $null
1448
+ try { $calculating = [int]$app.Calculation } catch { $calculating = $null }
1449
+ $iteration = $false
1450
+ try { $iteration = [bool]$app.Iteration } catch { $iteration = $false }
1451
+ try {
1452
+ $app.ScreenUpdating = $false
1453
+ $app.CalculateFull()
1454
+ } finally {
1455
+ try { $app.ScreenUpdating = $screenBefore } catch { }
1456
+ }
1457
+ $schema = New-Object System.Collections.ArrayList
1458
+ for ($i = 1; $i -le $book.Sheets.Count; $i++) { [void]$schema.Add([string]$book.Sheets.Item($i).Name) }
1459
+ $script:Result = [ordered]@{
1460
+ ok = $true
1461
+ op = 'recalc'
1462
+ engine = $engine
1463
+ workbook = (Get-BookInfo $book)
1464
+ calculated = $true
1465
+ sheets = @($schema)
1466
+ calculation = $calculating
1467
+ iteration = $iteration
1468
+ }
1469
+ return
1470
+ }
1471
+
1472
+ # ---- save ------------------------------------------------------
1473
+ if ($operation -eq 'save') {
1474
+ $target = [string]$params.path
1475
+ $info = Get-BookInfo $book
1476
+ $action = 'saved'
1477
+ if ($target -eq '' -and $null -eq $info['path']) {
1478
+ throw 'the workbook has never been saved; pass --path to choose where the file goes'
1479
+ }
1480
+ try {
1481
+ $app.DisplayAlerts = 0
1482
+ $app.ScreenUpdating = $false
1483
+ if ($target -eq '') {
1484
+ $book.Save()
1485
+ } else {
1486
+ $format = Get-SaveFormat $target
1487
+ $book.SaveAs($target, $format)
1488
+ $action = 'save_as'
1489
+ }
1490
+ } finally {
1491
+ try { $app.ScreenUpdating = $screenBefore } catch { }
1492
+ try { $app.DisplayAlerts = $alertsBefore } catch { }
1493
+ }
1494
+ $after = Get-BookInfo $book
1495
+ $savedPath = [string]$after['path']
1496
+ $exists = $false
1497
+ $size = 0
1498
+ if ($savedPath -ne '') {
1499
+ $exists = Test-Path -LiteralPath $savedPath
1500
+ if ($exists) { $size = (Get-Item -LiteralPath $savedPath).Length }
1501
+ }
1502
+ $overwrote = $false
1503
+ if ($action -eq 'saved' -and $null -ne $info['path']) { $overwrote = $true }
1504
+ if ($action -eq 'save_as' -and [bool]$params.path_existed) { $overwrote = $true }
1505
+ $script:Result = [ordered]@{
1506
+ ok = $true
1507
+ op = 'save'
1508
+ engine = $engine
1509
+ workbook = $after
1510
+ action = $action
1511
+ path = $savedPath
1512
+ file_exists = $exists
1513
+ size_bytes = $size
1514
+ overwrote = $overwrote
1515
+ original_path = $info['path']
1516
+ }
1517
+ return
1518
+ }
1519
+
1520
+ # ---- close -----------------------------------------------------
1521
+ if ($operation -eq 'close') {
1522
+ $info = Get-BookInfo $book
1523
+ $others = @()
1524
+ foreach ($openBook in @(Get-WorkbookList $app)) {
1525
+ if ([string]$openBook.Name -ne [string]$info['name']) { $others += [string]$openBook.Name }
1526
+ }
1527
+ $saveChanges = 0
1528
+ if ([bool]$params.save) {
1529
+ if ($null -eq $info['path']) {
1530
+ throw 'the workbook has never been saved, so close cannot save it; save it with --path first, or close with --discard'
1531
+ }
1532
+ try {
1533
+ $app.DisplayAlerts = 0
1534
+ $book.Save()
1535
+ } finally {
1536
+ try { $app.DisplayAlerts = $alertsBefore } catch { }
1537
+ }
1538
+ } elseif (-not [bool]$params.discard) {
1539
+ if ($info['saved'] -ne $true -or $null -eq $info['path']) {
1540
+ throw 'the workbook has unsaved changes; pass --save to keep them or --discard to throw them away'
1541
+ }
1542
+ }
1543
+ if ([bool]$params.quit_app -and $others.Count -gt 0) {
1544
+ throw "--quit-app would close these other workbooks too: $($others -join ', '); close them yourself and close without --quit-app"
1545
+ }
1546
+ try {
1547
+ $app.DisplayAlerts = 0
1548
+ $book.Close($saveChanges)
1549
+ if ([bool]$params.quit_app) { $app.Quit() }
1550
+ } finally {
1551
+ try { $app.DisplayAlerts = $alertsBefore } catch { }
1552
+ }
1553
+ $script:Result = [ordered]@{
1554
+ ok = $true
1555
+ op = 'close'
1556
+ engine = $engine
1557
+ workbook = $info
1558
+ saved = [bool]$params.save
1559
+ discarded = (-not [bool]$params.save)
1560
+ quit_app = [bool]$params.quit_app
1561
+ other_workbooks = @($others)
1562
+ }
1563
+ return
1564
+ }
1565
+
1566
+ throw "unknown operation '$operation'"
1567
+ } catch {
1568
+ $script:Result = [ordered]@{
1569
+ ok = $false
1570
+ stage = 'com'
1571
+ error = $_.Exception.Message
1572
+ }
1573
+ }
1574
+ }
1575
+
1576
+ # The body relays to Out-Null: property assignments emit their value, and the
1577
+ # only thing allowed on stdout is the JSON written below.
1578
+ Invoke-Request | Out-Null
1579
+
1580
+ if ($null -eq $script:Result) {
1581
+ $script:Result = [ordered]@{ ok = $false; stage = 'powershell'; error = 'the script produced no result' }
1582
+ }
1583
+ [Console]::Out.Write(($script:Result | ConvertTo-Json -Depth 12 -Compress))
1584
+ exit 0
1585
+ """
1586
+
1587
+
1588
+ if __name__ == "__main__":
1589
+ sys.exit(main())