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,398 @@
1
+ #!/usr/bin/env python3
2
+ """Inventory an existing .xlsx workbook before touching it.
3
+
4
+ Read-only: opens the file, never saves it. Prints one JSON object listing, per
5
+ sheet, the structures that openpyxl can silently rewrite on save (Excel tables,
6
+ pivot caches, shared and array formulas, conditional formats, data validations,
7
+ charts, merged ranges, defined names), plus whether cached formula results are
8
+ present. Use it to plan an edit from facts instead of guesses.
9
+
10
+ Exit codes:
11
+ 0 workbook inspected, JSON printed on stdout
12
+ 1 the file is missing or is not a readable .xlsx container
13
+ 2 usage error (argparse)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import re
21
+ import sys
22
+ import zipfile
23
+ from pathlib import Path
24
+
25
+ if hasattr(sys.stdout, "reconfigure"):
26
+ # Legacy console codepages (cp936, cp1252) must not abort a report.
27
+ sys.stdout.reconfigure(errors="replace")
28
+
29
+ FORMULA_KINDS = {
30
+ "shared": '<f t="shared"',
31
+ "array": '<f t="array"',
32
+ "data_table": 't="dataTable"',
33
+ }
34
+ ATTR = re.compile(r'([\w:]+)="([^"]*)"')
35
+
36
+
37
+ def parse_args() -> argparse.Namespace:
38
+ parser = argparse.ArgumentParser(
39
+ description="Inventory an .xlsx workbook: sheets, formulas, tables, "
40
+ "pivot caches, conditional formats, defined names, cached values.",
41
+ )
42
+ parser.add_argument("--input", required=True, help="path to the .xlsx file")
43
+ parser.add_argument(
44
+ "--no-values",
45
+ action="store_true",
46
+ help="skip the second load that checks cached formula results",
47
+ )
48
+ parser.add_argument(
49
+ "--sample",
50
+ type=int,
51
+ default=5,
52
+ help="how many formula samples to show per sheet (default 5)",
53
+ )
54
+ return parser.parse_args()
55
+
56
+
57
+ # --------------------------------------------------------------------------
58
+ # Raw container scan: what the file holds that a library round-trip may drop.
59
+ # --------------------------------------------------------------------------
60
+
61
+
62
+ def _attrs(tag: str) -> dict[str, str]:
63
+ return dict(ATTR.findall(tag))
64
+
65
+
66
+ def _sheet_xml_names(archive: zipfile.ZipFile) -> dict[str, str]:
67
+ """Map worksheet part path -> sheet name, following workbook relationships."""
68
+ try:
69
+ workbook = archive.read("xl/workbook.xml").decode("utf-8", "replace")
70
+ rels = archive.read("xl/_rels/workbook.xml.rels").decode("utf-8", "replace")
71
+ except KeyError:
72
+ return {}
73
+ rel_targets: dict[str, str] = {}
74
+ for tag in re.findall(r"<Relationship\b[^>]*>", rels):
75
+ attrs = _attrs(tag)
76
+ rel_targets[attrs.get("Id", "")] = attrs.get("Target", "")
77
+
78
+ mapping: dict[str, str] = {}
79
+ for tag in re.findall(r"<sheet\b[^>]*?/?>", workbook):
80
+ attrs = _attrs(tag)
81
+ target = rel_targets.get(attrs.get("r:id") or attrs.get("rId") or "", "")
82
+ if not target:
83
+ continue
84
+ if target.startswith("/"):
85
+ path = target.lstrip("/")
86
+ else:
87
+ path = "xl/" + target
88
+ parts: list[str] = []
89
+ for segment in path.split("/"):
90
+ if segment == "..":
91
+ if parts:
92
+ parts.pop()
93
+ elif segment not in ("", "."):
94
+ parts.append(segment)
95
+ mapping["/".join(parts)] = attrs.get("name", "")
96
+ return mapping
97
+
98
+
99
+ def _formula_kinds(archive: zipfile.ZipFile, part: str) -> dict[str, int]:
100
+ try:
101
+ xml = archive.read(part).decode("utf-8", "replace")
102
+ except KeyError:
103
+ return {kind: 0 for kind in FORMULA_KINDS}
104
+ return {kind: xml.count(marker) for kind, marker in FORMULA_KINDS.items()}
105
+
106
+
107
+ def _container(archive: zipfile.ZipFile) -> dict[str, object]:
108
+ names = archive.namelist()
109
+
110
+ def count(prefix: str, suffix: str = "") -> int:
111
+ return sum(1 for name in names if name.startswith(prefix) and name.endswith(suffix))
112
+
113
+ return {
114
+ "tables": count("xl/tables/", ".xml"),
115
+ "pivot_tables": count("xl/pivotTables/", ".xml"),
116
+ "pivot_caches": count("xl/pivotCache/", ".xml"),
117
+ "charts": count("xl/charts/", ".xml"),
118
+ "drawings": count("xl/drawings/drawing", ".xml"),
119
+ "media": count("xl/media/"),
120
+ "comment_parts": count("xl/comments", ".xml"),
121
+ "external_links": count("xl/externalLinks/", ".xml"),
122
+ "has_vba_project": "xl/vbaProject.bin" in names,
123
+ "has_calc_chain": "xl/calcChain.xml" in names,
124
+ "has_shared_strings": "xl/sharedStrings.xml" in names,
125
+ }
126
+
127
+
128
+ # --------------------------------------------------------------------------
129
+ # Sheet inventory through openpyxl.
130
+ # --------------------------------------------------------------------------
131
+
132
+
133
+ def _anchor(chart) -> str | None:
134
+ from openpyxl.utils import get_column_letter
135
+
136
+ start = getattr(getattr(chart, "anchor", None), "_from", None)
137
+ if start is None:
138
+ return None
139
+ return f"{get_column_letter(start.col + 1)}{start.row + 1}"
140
+
141
+
142
+ def _chart_kind(chart) -> str:
143
+ """Name the chart the way a user would: a column is not a bar."""
144
+ class_name = type(chart).__name__
145
+ kind = class_name[: -len("Chart")].lower() if class_name.endswith("Chart") else class_name.lower()
146
+ if kind == "bar":
147
+ return "column" if getattr(chart, "type", "col") == "col" else "bar"
148
+ return kind
149
+
150
+
151
+ def _date_system(workbook) -> str:
152
+ epoch = getattr(workbook, "epoch", None)
153
+ return "1904" if getattr(epoch, "year", 1900) == 1904 else "1900"
154
+
155
+
156
+ def _defined_names(workbook) -> list[dict[str, object]]:
157
+ found: list[dict[str, object]] = []
158
+ for entry in workbook.defined_names.values():
159
+ scope: object = "workbook"
160
+ index = entry.localSheetId
161
+ if index is not None and index < len(workbook.sheetnames):
162
+ scope = workbook.sheetnames[index]
163
+ found.append(
164
+ {
165
+ "name": entry.name,
166
+ "refers_to": entry.attr_text,
167
+ "scope": scope,
168
+ "hidden": bool(entry.hidden),
169
+ }
170
+ )
171
+ return found
172
+
173
+
174
+ def _sheet_inventory(
175
+ worksheet, raw: dict[str, int], sample: int
176
+ ) -> tuple[dict[str, object], set[str]]:
177
+ formulas: list[tuple[str, str]] = []
178
+ for row in worksheet.iter_rows():
179
+ for cell in row:
180
+ if isinstance(cell.value, str) and cell.value.startswith("="):
181
+ formulas.append((cell.coordinate, cell.value))
182
+
183
+ charts = []
184
+ for chart in getattr(worksheet, "_charts", []):
185
+ charts.append(
186
+ {
187
+ "kind": _chart_kind(chart),
188
+ "class": type(chart).__name__,
189
+ "series": len(getattr(chart, "series", []) or []),
190
+ "anchor": _anchor(chart),
191
+ }
192
+ )
193
+
194
+ tables = []
195
+ for table in worksheet.tables.values():
196
+ tables.append(
197
+ {
198
+ "name": table.name,
199
+ "ref": table.ref,
200
+ "style": getattr(table.tableStyleInfo, "name", None),
201
+ "header_rows": table.headerRowCount,
202
+ "totals_rows": table.totalsRowCount,
203
+ }
204
+ )
205
+
206
+ conditional = []
207
+ for block in worksheet.conditional_formatting:
208
+ rules = []
209
+ for rule in block.rules:
210
+ rules.append(
211
+ {
212
+ "type": rule.type,
213
+ "operator": rule.operator,
214
+ "formula": list(rule.formula or []),
215
+ "priority": rule.priority,
216
+ }
217
+ )
218
+ conditional.append({"range": str(block.sqref), "rules": rules})
219
+
220
+ validations = []
221
+ for validation in worksheet.data_validations.dataValidation:
222
+ validations.append(
223
+ {
224
+ "type": validation.type,
225
+ "cells": str(validation.sqref),
226
+ "formula1": validation.formula1,
227
+ "operator": validation.operator,
228
+ }
229
+ )
230
+
231
+ widths = {
232
+ name: dimension.width
233
+ for name, dimension in worksheet.column_dimensions.items()
234
+ if dimension.width
235
+ }
236
+ if len(widths) > 26:
237
+ widths = dict(list(widths.items())[:26])
238
+
239
+ hidden_rows = [
240
+ index for index, dimension in worksheet.row_dimensions.items() if dimension.hidden
241
+ ]
242
+ hidden_columns = [
243
+ name for name, dimension in worksheet.column_dimensions.items() if dimension.hidden
244
+ ]
245
+
246
+ return {
247
+ "name": worksheet.title,
248
+ "state": worksheet.sheet_state,
249
+ "dimensions": worksheet.dimensions,
250
+ "max_row": worksheet.max_row,
251
+ "max_column": worksheet.max_column,
252
+ "freeze_panes": worksheet.freeze_panes,
253
+ "gridlines": bool(worksheet.sheet_view.showGridLines),
254
+ "auto_filter": getattr(worksheet.auto_filter, "ref", None),
255
+ "protection": bool(worksheet.protection.sheet),
256
+ "print_area": str(worksheet.print_area) if worksheet.print_area else None,
257
+ "merged": [str(item) for item in worksheet.merged_cells.ranges],
258
+ "tables": tables,
259
+ "pivot_tables": len(getattr(worksheet, "_pivots", []) or []),
260
+ "charts": charts,
261
+ "images": len(getattr(worksheet, "_images", [])),
262
+ "conditional_formats": conditional,
263
+ "data_validations": validations,
264
+ "hidden_rows": hidden_rows[:20],
265
+ "hidden_columns": hidden_columns[:20],
266
+ "column_widths": widths,
267
+ "formulas": {
268
+ "count": len(formulas),
269
+ "sample": [f"{address}: {text}" for address, text in formulas[: max(sample, 0)]],
270
+ "shared": raw.get("shared", 0),
271
+ "array": raw.get("array", 0),
272
+ "data_tables": raw.get("data_table", 0),
273
+ },
274
+ }, {address for address, _ in formulas}
275
+
276
+
277
+ def _cached_values(path: Path, formula_cells: dict[str, set[str]]) -> dict[str, dict[str, object]]:
278
+ """Classify stored results for the formula cells found in the formula pass."""
279
+ from openpyxl import load_workbook
280
+
281
+ workbook = load_workbook(str(path), data_only=True)
282
+ result: dict[str, dict[str, object]] = {}
283
+ try:
284
+ for name, coordinates in formula_cells.items():
285
+ if name not in workbook.sheetnames:
286
+ continue
287
+ worksheet = workbook[name]
288
+ cached = 0
289
+ missing = 0
290
+ errors = []
291
+ for coordinate in sorted(coordinates):
292
+ value = worksheet[coordinate].value
293
+ if value is None:
294
+ missing += 1
295
+ elif isinstance(value, str) and value.startswith("#"):
296
+ errors.append({"cell": coordinate, "value": value})
297
+ else:
298
+ cached += 1
299
+ result[name] = {"cached": cached, "missing": missing, "errors": errors}
300
+ finally:
301
+ workbook.close()
302
+ return result
303
+
304
+
305
+ def main() -> int:
306
+ args = parse_args()
307
+ path = Path(args.input).expanduser()
308
+ if not path.is_file():
309
+ print(json.dumps({"ok": False, "error": f"file not found: {path}"}), file=sys.stderr)
310
+ return 1
311
+ if not zipfile.is_zipfile(path):
312
+ print(json.dumps({"ok": False, "error": f"not a zip container: {path}"}), file=sys.stderr)
313
+ return 1
314
+
315
+ try:
316
+ from openpyxl import load_workbook
317
+ except ImportError as error: # pragma: no cover - environment guard
318
+ print(json.dumps({"ok": False, "error": f"openpyxl missing: {error}"}), file=sys.stderr)
319
+ return 1
320
+
321
+ try:
322
+ with zipfile.ZipFile(path) as archive:
323
+ part_names = _sheet_xml_names(archive)
324
+ container = _container(archive)
325
+ raw_by_sheet = {
326
+ name: _formula_kinds(archive, part) for part, name in part_names.items()
327
+ }
328
+ workbook = load_workbook(str(path), data_only=False)
329
+ except Exception as error: # noqa: BLE001 - report any unreadable workbook the same way
330
+ print(
331
+ json.dumps({"ok": False, "error": f"cannot read workbook: {error}"}),
332
+ file=sys.stderr,
333
+ )
334
+ return 1
335
+
336
+ warnings: list[str] = []
337
+ sheets = []
338
+ formula_cells: dict[str, set[str]] = {}
339
+ try:
340
+ for worksheet in workbook.worksheets:
341
+ inventory, coordinates = _sheet_inventory(
342
+ worksheet, raw_by_sheet.get(worksheet.title, {}), args.sample
343
+ )
344
+ formula_cells[worksheet.title] = coordinates
345
+ sheets.append(inventory)
346
+ workbook_meta = {
347
+ "sheets": list(workbook.sheetnames),
348
+ "sheet_count": len(workbook.sheetnames),
349
+ "active_sheet": workbook.active.title if workbook.active else None,
350
+ "defined_names": _defined_names(workbook),
351
+ "full_calc_on_load": bool(
352
+ getattr(getattr(workbook, "calculation", None), "fullCalcOnLoad", False)
353
+ ),
354
+ "calc_mode": getattr(getattr(workbook, "calculation", None), "calcMode", None),
355
+ "date_system": _date_system(workbook),
356
+ }
357
+ finally:
358
+ workbook.close()
359
+
360
+ if not args.no_values:
361
+ values = _cached_values(path, formula_cells)
362
+ for sheet in sheets:
363
+ sheet["values"] = values.get(sheet["name"], {"cached": 0, "missing": 0, "errors": []})
364
+ entry = sheet["values"]
365
+ if entry["missing"]: # type: ignore[index]
366
+ warnings.append(
367
+ f"{sheet['name']}: {entry['missing']} formula cell(s) have no cached value" # type: ignore[index]
368
+ )
369
+ for error in entry["errors"]: # type: ignore[index]
370
+ warnings.append(
371
+ f"{sheet['name']}!{error['cell']} already stores {error['value']}"
372
+ )
373
+
374
+ if container["pivot_tables"]:
375
+ warnings.append(
376
+ f"{container['pivot_tables']} pivot table(s) present: a library round-trip "
377
+ "may drop or re-key them; verify after saving"
378
+ )
379
+ if container["has_calc_chain"]:
380
+ warnings.append(
381
+ "the file carries a calculation chain; a library round-trip may leave it stale"
382
+ )
383
+
384
+ payload = {
385
+ "ok": True,
386
+ "input": str(path),
387
+ "size_bytes": path.stat().st_size,
388
+ "workbook": workbook_meta,
389
+ "container": container,
390
+ "sheets": sheets,
391
+ "warnings": warnings,
392
+ }
393
+ print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
394
+ return 0
395
+
396
+
397
+ if __name__ == "__main__":
398
+ sys.exit(main())
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env python3
2
+ """Gate a workbook on formulas that actually resolve.
3
+
4
+ Recomputes the file through the helper CLI (`xlsx recalc`, which drives Microsoft
5
+ Excel or WPS Spreadsheets over COM), then classifies every formula cell: a real
6
+ value, a spreadsheet error, or no cached value at all. Also flags formulas that
7
+ point at sheets the workbook does not contain, and formulas whose text already
8
+ carries a `#REF!` literal.
9
+
10
+ Without a calculation engine the recomputation cannot happen; `--no-recalc` then
11
+ inspects the cached values a previous producer left in the file instead of failing
12
+ outright. A file whose formulas carry no cached value at all cannot be certified.
13
+
14
+ Exit codes:
15
+ 0 verified: every formula resolved to a value
16
+ 1 failed: error cells, unverified formulas after recomputation, or formulas
17
+ that point at sheets the workbook does not contain
18
+ 2 usage error, missing file, or unreadable workbook
19
+ 3 unverified: no formula engine and no complete set of cached values
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import re
27
+ import subprocess
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ if hasattr(sys.stdout, "reconfigure"):
32
+ # Legacy console codepages (cp936, cp1252) must not abort a report.
33
+ sys.stdout.reconfigure(errors="replace")
34
+
35
+ SHEET_REF = re.compile(r"(?:'((?:[^']|'')+)'|([A-Za-z_][A-Za-z0-9_.]*))!")
36
+
37
+
38
+ def parse_args() -> argparse.Namespace:
39
+ parser = argparse.ArgumentParser(
40
+ description="Verify that a workbook's formulas recalculate to values; "
41
+ "report errors, missing cached results and dangling sheet references.",
42
+ )
43
+ parser.add_argument("--input", required=True, help="workbook to check")
44
+ parser.add_argument(
45
+ "--out",
46
+ help="where to write the recalculated copy (default: <input>-recalculated.xlsx)",
47
+ )
48
+ parser.add_argument(
49
+ "--cli",
50
+ help="path to python/office_cli.py (default: guessed from this script's location)",
51
+ )
52
+ parser.add_argument(
53
+ "--python",
54
+ default=sys.executable,
55
+ help="interpreter that runs the helper CLI (default: this interpreter)",
56
+ )
57
+ parser.add_argument(
58
+ "--no-recalc",
59
+ action="store_true",
60
+ help="do not recompute; check the cached values already stored in the file",
61
+ )
62
+ parser.add_argument(
63
+ "--strict",
64
+ action="store_true",
65
+ help="treat formulas without a cached value as failures even without recalculation",
66
+ )
67
+ parser.add_argument("--ignore", help="comma-separated cells to accept as known errors, e.g. 'Rates!B7,B9'")
68
+ return parser.parse_args()
69
+
70
+
71
+ def _default_cli() -> Path:
72
+ return Path(__file__).resolve().parents[4] / "python" / "office_cli.py"
73
+
74
+
75
+ def _formula_cells(path: Path) -> tuple[list[str], list[tuple[str, str, str]]]:
76
+ from openpyxl import load_workbook
77
+
78
+ workbook = load_workbook(str(path), data_only=False)
79
+ try:
80
+ names = list(workbook.sheetnames)
81
+ cells = [
82
+ (worksheet.title, cell.coordinate, str(cell.value))
83
+ for worksheet in workbook.worksheets
84
+ for row in worksheet.iter_rows()
85
+ for cell in row
86
+ if isinstance(cell.value, str) and cell.value.startswith("=")
87
+ ]
88
+ finally:
89
+ workbook.close()
90
+ return names, cells
91
+
92
+
93
+ def _cached_values(path: Path, cells: list[tuple[str, str, str]]) -> dict[tuple[str, str], object]:
94
+ from openpyxl import load_workbook
95
+
96
+ workbook = load_workbook(str(path), data_only=True)
97
+ try:
98
+ return {
99
+ (sheet, cell): workbook[sheet][cell].value
100
+ for sheet, cell, _ in cells
101
+ if sheet in workbook.sheetnames
102
+ }
103
+ finally:
104
+ workbook.close()
105
+
106
+
107
+ def _referenced_sheets(formula: str) -> list[str]:
108
+ found = []
109
+ for match in SHEET_REF.finditer(formula):
110
+ if match.start() and formula[match.start() - 1] == "#":
111
+ continue # error literal such as #REF!, not a sheet named REF
112
+ name = (match.group(1) or match.group(2)).replace("''", "'")
113
+ if name.startswith("[") or name.startswith("file:"):
114
+ continue
115
+ found.append(name)
116
+ return found
117
+
118
+
119
+ def _recalculate(python: str, cli: Path, source: Path, out: Path) -> tuple[bool, str | None]:
120
+ command = [python, str(cli), "xlsx", "recalc", "--input", str(source), "--out", str(out)]
121
+ try:
122
+ completed = subprocess.run(command, capture_output=True, text=True, timeout=300)
123
+ except FileNotFoundError as error:
124
+ return False, f"cannot start the helper CLI: {error}"
125
+ except subprocess.TimeoutExpired:
126
+ return False, "recalculation timed out after 300 seconds"
127
+ if completed.returncode == 0 and out.is_file():
128
+ return True, None
129
+ message = (completed.stderr or completed.stdout or "").strip()
130
+ try:
131
+ parsed = json.loads(message)
132
+ message = str(parsed.get("error", message))
133
+ except json.JSONDecodeError:
134
+ pass
135
+ return False, message or f"helper CLI exited with code {completed.returncode}"
136
+
137
+
138
+ def _ignored(entries: str | None) -> dict[str, set[str]]:
139
+ allowed: dict[str, set[str]] = {}
140
+ for entry in (entries or "").split(","):
141
+ entry = entry.strip()
142
+ if not entry:
143
+ continue
144
+ if "!" in entry:
145
+ sheet, cell = entry.split("!", 1)
146
+ allowed.setdefault(sheet.strip("'"), set()).add(cell.upper())
147
+ else:
148
+ allowed.setdefault("*", set()).add(entry.upper())
149
+ return allowed
150
+
151
+
152
+ def main() -> int:
153
+ args = parse_args()
154
+ source = Path(args.input).expanduser()
155
+ if not source.is_file():
156
+ print(json.dumps({"ok": False, "error": f"file not found: {source}"}), file=sys.stderr)
157
+ return 2
158
+
159
+ try:
160
+ sheet_names, cells = _formula_cells(source)
161
+ except Exception as error: # noqa: BLE001 - every unreadable workbook fails the same way
162
+ print(json.dumps({"ok": False, "error": f"cannot read workbook: {error}"}), file=sys.stderr)
163
+ return 2
164
+
165
+ recalculated = False
166
+ engine = None
167
+ recalc_error = None
168
+ checked = source
169
+ if args.no_recalc:
170
+ recalc_error = "recalculation skipped by --no-recalc"
171
+ else:
172
+ cli = Path(args.cli).expanduser() if args.cli else _default_cli()
173
+ out = Path(args.out).expanduser() if args.out else source.with_name(
174
+ f"{source.stem}-recalculated{source.suffix}"
175
+ )
176
+ if not cli.is_file():
177
+ recalc_error = f"helper CLI not found: {cli}; pass --cli or use --no-recalc"
178
+ else:
179
+ recalculated, recalc_error = _recalculate(args.python, cli, source, out)
180
+ if recalculated:
181
+ checked = out
182
+ engine = "microsoft excel"
183
+
184
+ try:
185
+ cached = _cached_values(checked, cells) if cells else {}
186
+ except Exception as error: # noqa: BLE001
187
+ print(json.dumps({"ok": False, "error": f"cannot read values: {error}"}), file=sys.stderr)
188
+ return 2
189
+
190
+ allowed = _ignored(args.ignore)
191
+ errors: list[dict[str, object]] = []
192
+ missing: list[dict[str, object]] = []
193
+ dangling: list[dict[str, object]] = []
194
+ ignored_cells: list[str] = []
195
+ sheets_seen: dict[str, int] = {}
196
+ valued = 0
197
+
198
+ for sheet, coordinate, formula in cells:
199
+ sheets_seen[sheet] = sheets_seen.get(sheet, 0) + 1
200
+ value = cached.get((sheet, coordinate))
201
+ accepted = coordinate.upper() in allowed.get(sheet, set()) or coordinate.upper() in allowed.get("*", set())
202
+
203
+ if "#REF!" in formula:
204
+ entry = {"sheet": sheet, "cell": coordinate, "formula": formula, "value": "#REF!"}
205
+ if accepted:
206
+ ignored_cells.append(f"{sheet}!{coordinate}")
207
+ else:
208
+ errors.append(entry)
209
+ elif isinstance(value, str) and value.startswith("#"):
210
+ entry = {"sheet": sheet, "cell": coordinate, "formula": formula, "value": value}
211
+ if accepted:
212
+ ignored_cells.append(f"{sheet}!{coordinate}")
213
+ else:
214
+ errors.append(entry)
215
+ elif value is None:
216
+ entry = {"sheet": sheet, "cell": coordinate, "formula": formula}
217
+ if accepted:
218
+ ignored_cells.append(f"{sheet}!{coordinate}")
219
+ else:
220
+ missing.append(entry)
221
+ else:
222
+ valued += 1
223
+
224
+ if accepted:
225
+ continue
226
+ for reference in _referenced_sheets(formula):
227
+ base = reference.split(":", 1)
228
+ if all(part in sheet_names for part in base):
229
+ continue
230
+ dangling.append(
231
+ {"sheet": sheet, "cell": coordinate, "formula": formula, "references": reference}
232
+ )
233
+
234
+ failures = bool(errors or dangling)
235
+ if missing and (recalculated or args.strict):
236
+ failures = True
237
+
238
+ # Verified means every formula resolved on this run or arrived fully cached;
239
+ # a file whose formulas have never been computed cannot be certified.
240
+ if failures:
241
+ verdict = "failed"
242
+ elif not cells or recalculated or not missing:
243
+ verdict = "verified"
244
+ else:
245
+ verdict = "unverified"
246
+
247
+ payload = {
248
+ "ok": verdict == "verified",
249
+ "input": str(source),
250
+ "checked_against": str(checked),
251
+ "recalculated": recalculated,
252
+ "engine": engine,
253
+ "value_source": "recalculated" if recalculated else ("cached" if cells and not missing else None),
254
+ "recalc_error": recalc_error,
255
+ "verdict": verdict,
256
+ "formulas_by_sheet": sheets_seen,
257
+ "counts": {
258
+ "formulas": len(cells),
259
+ "valued": valued,
260
+ "errors": len(errors),
261
+ "missing": len(missing),
262
+ "dangling": len(dangling),
263
+ "ignored": len(ignored_cells),
264
+ },
265
+ "errors": errors,
266
+ "missing": missing,
267
+ "dangling": dangling,
268
+ "ignored": ignored_cells,
269
+ }
270
+ print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
271
+ return {"verified": 0, "failed": 1, "unverified": 3}[verdict]
272
+
273
+
274
+ if __name__ == "__main__":
275
+ sys.exit(main())