dsh-office-kit 0.2.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 (197) hide show
  1. package/CAPABILITY.md +147 -68
  2. package/README.md +3 -1
  3. package/assets/skills/excel-live-control/SKILL.md +234 -0
  4. package/assets/skills/excel-live-control/container_tools/live_excel.py +1589 -0
  5. package/assets/skills/excel-live-control/domain_guidance/financial_models.md +86 -0
  6. package/assets/skills/excel-live-control/domain_guidance/healthcare.md +58 -0
  7. package/assets/skills/excel-live-control/domain_guidance/marketing_advertising.md +53 -0
  8. package/assets/skills/excel-live-control/domain_guidance/scientific_research.md +53 -0
  9. package/assets/skills/excel-live-control/references/office-automation.md +207 -0
  10. package/assets/skills/excel-live-control/references/read_only_qna.md +59 -0
  11. package/assets/skills/excel-live-control/style_guidelines.md +87 -0
  12. package/assets/skills/excel-live-control/workflows/create_workflows.md +78 -0
  13. package/assets/skills/excel-live-control/workflows/edit_workflows.md +72 -0
  14. package/assets/skills/office-documents/SKILL.md +90 -77
  15. package/assets/skills/office-documents/examples/end_to_end_smoke_test.md +86 -0
  16. package/assets/skills/office-documents/ooxml/comments.md +40 -0
  17. package/assets/skills/office-documents/ooxml/hyperlinks_and_fields.md +66 -0
  18. package/assets/skills/office-documents/ooxml/rels_and_content_types.md +62 -0
  19. package/assets/skills/office-documents/ooxml/tracked_changes.md +53 -0
  20. package/assets/skills/office-documents/references/docx-structure.md +83 -0
  21. package/assets/skills/office-documents/references/template-elicitation.md +49 -0
  22. package/assets/skills/office-documents/references/writing-quality.md +49 -0
  23. package/assets/skills/office-documents/render_docx.py +92 -0
  24. package/assets/skills/office-documents/scripts/a11y_audit.py +194 -0
  25. package/assets/skills/office-documents/scripts/accept_tracked_changes.py +151 -0
  26. package/assets/skills/office-documents/scripts/add_tracked_replacements.py +148 -0
  27. package/assets/skills/office-documents/scripts/append_blocks.py +108 -0
  28. package/assets/skills/office-documents/scripts/apply_template_styles.py +104 -0
  29. package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +102 -0
  30. package/assets/skills/office-documents/scripts/comments_add.py +136 -0
  31. package/assets/skills/office-documents/scripts/comments_apply_patch.py +114 -0
  32. package/assets/skills/office-documents/scripts/comments_extract.py +85 -0
  33. package/assets/skills/office-documents/scripts/comments_strip.py +74 -0
  34. package/assets/skills/office-documents/scripts/content_controls.py +192 -0
  35. package/assets/skills/office-documents/scripts/core_properties.py +124 -0
  36. package/assets/skills/office-documents/scripts/docx_kit.py +587 -0
  37. package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +225 -0
  38. package/assets/skills/office-documents/scripts/docx_table_to_csv.py +99 -0
  39. package/assets/skills/office-documents/scripts/fields_materialize.py +66 -0
  40. package/assets/skills/office-documents/scripts/fields_report.py +59 -0
  41. package/assets/skills/office-documents/scripts/flatten_ref_fields.py +62 -0
  42. package/assets/skills/office-documents/scripts/footnotes_report.py +77 -0
  43. package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +96 -0
  44. package/assets/skills/office-documents/scripts/heading_audit.py +81 -0
  45. package/assets/skills/office-documents/scripts/images_audit.py +80 -0
  46. package/assets/skills/office-documents/scripts/insert_note.py +126 -0
  47. package/assets/skills/office-documents/scripts/insert_ref_fields.py +90 -0
  48. package/assets/skills/office-documents/scripts/insert_toc.py +84 -0
  49. package/assets/skills/office-documents/scripts/internal_nav.py +146 -0
  50. package/assets/skills/office-documents/scripts/make_fixtures.py +169 -0
  51. package/assets/skills/office-documents/scripts/merge_docx_append.py +239 -0
  52. package/assets/skills/office-documents/scripts/privacy_scrub.py +131 -0
  53. package/assets/skills/office-documents/scripts/redact_docx.py +113 -0
  54. package/assets/skills/office-documents/scripts/render_and_diff.py +101 -0
  55. package/assets/skills/office-documents/scripts/replace_text.py +154 -0
  56. package/assets/skills/office-documents/scripts/section_audit.py +91 -0
  57. package/assets/skills/office-documents/scripts/set_protection.py +92 -0
  58. package/assets/skills/office-documents/scripts/style_lint.py +111 -0
  59. package/assets/skills/office-documents/scripts/style_normalize.py +103 -0
  60. package/assets/skills/office-documents/scripts/table_geometry.py +167 -0
  61. package/assets/skills/office-documents/scripts/watermark_add.py +48 -0
  62. package/assets/skills/office-documents/scripts/watermark_audit_remove.py +67 -0
  63. package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +117 -0
  64. package/assets/skills/office-documents/tasks/accessibility_a11y.md +45 -0
  65. package/assets/skills/office-documents/tasks/captions_crossrefs.md +49 -0
  66. package/assets/skills/office-documents/tasks/clean_tracked_changes.md +49 -0
  67. package/assets/skills/office-documents/tasks/comments_manage.md +59 -0
  68. package/assets/skills/office-documents/tasks/compare_diff.md +40 -0
  69. package/assets/skills/office-documents/tasks/create_edit.md +107 -0
  70. package/assets/skills/office-documents/tasks/equations.md +59 -0
  71. package/assets/skills/office-documents/tasks/fields_update.md +44 -0
  72. package/assets/skills/office-documents/tasks/fixtures_edge_cases.md +50 -0
  73. package/assets/skills/office-documents/tasks/footnotes_endnotes.md +38 -0
  74. package/assets/skills/office-documents/tasks/forms_content_controls.md +48 -0
  75. package/assets/skills/office-documents/tasks/headings_numbering.md +55 -0
  76. package/assets/skills/office-documents/tasks/images_figures.md +63 -0
  77. package/assets/skills/office-documents/tasks/multi_doc_merge.md +43 -0
  78. package/assets/skills/office-documents/tasks/navigation_internal_links.md +38 -0
  79. package/assets/skills/office-documents/tasks/privacy_scrub_metadata.md +45 -0
  80. package/assets/skills/office-documents/tasks/protection_restrict_editing.md +41 -0
  81. package/assets/skills/office-documents/tasks/read_review.md +50 -0
  82. package/assets/skills/office-documents/tasks/redaction_anonymization.md +42 -0
  83. package/assets/skills/office-documents/tasks/sections_layout.md +54 -0
  84. package/assets/skills/office-documents/tasks/style_lint_normalize.md +44 -0
  85. package/assets/skills/office-documents/tasks/tables_spreadsheets.md +64 -0
  86. package/assets/skills/office-documents/tasks/templates_style_packs.md +53 -0
  87. package/assets/skills/office-documents/tasks/toc_workflow.md +39 -0
  88. package/assets/skills/office-documents/tasks/verify_render.md +50 -0
  89. package/assets/skills/office-documents/tasks/watermarks_background.md +35 -0
  90. package/assets/skills/office-documents/troubleshooting/libreoffice_headless.md +47 -0
  91. package/assets/skills/office-documents/troubleshooting/run_splitting.md +36 -0
  92. package/assets/skills/office-pdf/SKILL.md +73 -56
  93. package/assets/skills/office-pdf/references/form-fields.md +56 -0
  94. package/assets/skills/office-pdf/references/geometry.md +44 -0
  95. package/assets/skills/office-pdf/references/layout-quality.md +41 -0
  96. package/assets/skills/office-pdf/references/limits.md +58 -0
  97. package/assets/skills/office-pdf/scripts/extract_text.py +172 -0
  98. package/assets/skills/office-pdf/scripts/forms.py +556 -0
  99. package/assets/skills/office-pdf/scripts/metadata.py +161 -0
  100. package/assets/skills/office-pdf/scripts/page_ops.py +198 -0
  101. package/assets/skills/office-pdf/scripts/redact_region.py +347 -0
  102. package/assets/skills/office-pdf/scripts/render_pages.py +207 -0
  103. package/assets/skills/office-pdf/scripts/split_pages.py +155 -0
  104. package/assets/skills/office-pdf/scripts/stamp_overlay.py +199 -0
  105. package/assets/skills/office-pdf/tasks/annotations.md +98 -0
  106. package/assets/skills/office-pdf/tasks/create.md +75 -0
  107. package/assets/skills/office-pdf/tasks/edit-pages.md +71 -0
  108. package/assets/skills/office-pdf/tasks/forms.md +67 -0
  109. package/assets/skills/office-pdf/tasks/read.md +62 -0
  110. package/assets/skills/office-pdf/tasks/redact.md +72 -0
  111. package/assets/skills/office-pdf/tasks/stamp.md +60 -0
  112. package/assets/skills/office-presentations/SKILL.md +141 -87
  113. package/assets/skills/office-presentations/container_tools/add_slides.py +136 -0
  114. package/assets/skills/office-presentations/container_tools/core_properties.py +120 -0
  115. package/assets/skills/office-presentations/container_tools/create_montage.py +126 -0
  116. package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +285 -0
  117. package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +157 -0
  118. package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +144 -0
  119. package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +199 -0
  120. package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +212 -0
  121. package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +345 -0
  122. package/assets/skills/office-presentations/container_tools/preview_render.py +375 -0
  123. package/assets/skills/office-presentations/container_tools/render_slides.py +139 -0
  124. package/assets/skills/office-presentations/container_tools/replace_text.py +168 -0
  125. package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +229 -0
  126. package/assets/skills/office-presentations/domain_guidance/finance.md +66 -0
  127. package/assets/skills/office-presentations/recipes/README.md +71 -0
  128. package/assets/skills/office-presentations/recipes/autolayout.md +88 -0
  129. package/assets/skills/office-presentations/recipes/connectors.md +83 -0
  130. package/assets/skills/office-presentations/recipes/images.md +80 -0
  131. package/assets/skills/office-presentations/recipes/master-and-layouts.md +127 -0
  132. package/assets/skills/office-presentations/recipes/native-charts.md +87 -0
  133. package/assets/skills/office-presentations/recipes/rich-text-runs.md +86 -0
  134. package/assets/skills/office-presentations/recipes/speaker-notes.md +65 -0
  135. package/assets/skills/office-presentations/recipes/tables.md +88 -0
  136. package/assets/skills/office-presentations/recipes/textbox-at-coordinate.md +71 -0
  137. package/assets/skills/office-presentations/recipes/theme-tokens.md +87 -0
  138. package/assets/skills/office-presentations/references/authoring-workflow.md +65 -0
  139. package/assets/skills/office-presentations/references/finalization.md +63 -0
  140. package/assets/skills/office-presentations/references/native-bullets.md +78 -0
  141. package/assets/skills/office-presentations/references/native-evidence.md +67 -0
  142. package/assets/skills/office-presentations/references/style-guidelines.md +66 -0
  143. package/assets/skills/office-presentations/references/templates.md +76 -0
  144. package/assets/skills/office-presentations/routing/google_slides.md +45 -0
  145. package/assets/skills/office-presentations/tasks/apply-annotations.md +83 -0
  146. package/assets/skills/office-presentations/tasks/charts-tables-notes.md +79 -0
  147. package/assets/skills/office-presentations/tasks/create-deck.md +106 -0
  148. package/assets/skills/office-presentations/tasks/edit-deck.md +93 -0
  149. package/assets/skills/office-presentations/tasks/inspect-and-verify.md +107 -0
  150. package/assets/skills/office-presentations/tasks/template-following.md +132 -0
  151. package/assets/skills/office-presentations/template_following_scripts/check_template_fidelity.py +176 -0
  152. package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +154 -0
  153. package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +111 -0
  154. package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +239 -0
  155. package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +185 -0
  156. package/assets/skills/office-spreadsheets/SKILL.md +105 -101
  157. package/assets/skills/office-spreadsheets/container_tools/add_chart.py +236 -0
  158. package/assets/skills/office-spreadsheets/container_tools/add_image.py +107 -0
  159. package/assets/skills/office-spreadsheets/container_tools/append_rows.py +122 -0
  160. package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +306 -0
  161. package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +398 -0
  162. package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +275 -0
  163. package/assets/skills/office-spreadsheets/container_tools/set_cells.py +135 -0
  164. package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +134 -0
  165. package/assets/skills/office-spreadsheets/container_tools/style_cells.py +133 -0
  166. package/assets/skills/office-spreadsheets/recipes/charts.md +66 -0
  167. package/assets/skills/office-spreadsheets/recipes/conditional-formats.md +69 -0
  168. package/assets/skills/office-spreadsheets/recipes/data-tables.md +58 -0
  169. package/assets/skills/office-spreadsheets/recipes/named-ranges.md +41 -0
  170. package/assets/skills/office-spreadsheets/recipes/number-formats.md +72 -0
  171. package/assets/skills/office-spreadsheets/recipes/sparklines.md +43 -0
  172. package/assets/skills/office-spreadsheets/references/charts.md +63 -0
  173. package/assets/skills/office-spreadsheets/references/domain/financial-models.md +40 -0
  174. package/assets/skills/office-spreadsheets/references/domain/healthcare.md +22 -0
  175. package/assets/skills/office-spreadsheets/references/domain/marketing.md +28 -0
  176. package/assets/skills/office-spreadsheets/references/domain/research.md +22 -0
  177. package/assets/skills/office-spreadsheets/references/formula-patterns.md +78 -0
  178. package/assets/skills/office-spreadsheets/references/style-guidelines.md +69 -0
  179. package/assets/skills/office-spreadsheets/references/workbook-structure.md +60 -0
  180. package/assets/skills/office-spreadsheets/routing/google-sheets.md +36 -0
  181. package/assets/skills/office-spreadsheets/tasks/analyze-workbook.md +37 -0
  182. package/assets/skills/office-spreadsheets/tasks/annotations.md +51 -0
  183. package/assets/skills/office-spreadsheets/tasks/create-workbook.md +66 -0
  184. package/assets/skills/office-spreadsheets/tasks/diff-workbooks.md +38 -0
  185. package/assets/skills/office-spreadsheets/tasks/edit-workbook.md +69 -0
  186. package/assets/skills/office-spreadsheets/tasks/inspect-workbook.md +38 -0
  187. package/assets/skills/office-spreadsheets/tasks/recalc-and-check.md +57 -0
  188. package/assets/skills/shared/annotation-recipes.md +252 -0
  189. package/assets/skills/shared/check_runtime.py +253 -0
  190. package/assets/skills/shared/common-workflow.md +139 -0
  191. package/assets/skills/shared/scripts/ensure_raster_image.py +187 -0
  192. package/dist/client.js +4 -4
  193. package/dist/client.js.map +1 -1
  194. package/dist/index.js +239 -89
  195. package/package.json +1 -1
  196. package/postinstall.mjs +92 -82
  197. package/python/office_cli.py +171 -246
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env python3
2
+ """Append rows to the bottom of an existing worksheet.
3
+
4
+ Rows are JSON arrays. The new rows start one row below the sheet's current last
5
+ row, so a table that ends at row 13 grows at row 14. Pass `null` for a blank
6
+ cell; do not pad with empty strings, which turn into text.
7
+
8
+ Because appending a row does not extend Excel Tables, conditional formats, or
9
+ chart ranges, follow up with the checks in the workbook's own conventions:
10
+ extend `tables[].ref`, copy the format of the row above, and widen any chart
11
+ that is supposed to include the new data.
12
+
13
+ Examples:
14
+ append_rows.py --input book.xlsx --sheet Pipeline --rows '[["Delta Co","R. Osei","Proposal",98000,0.4]]'
15
+ append_rows.py --input book.xlsx --sheet Pipeline --rows-file rows.json
16
+
17
+ rows.json: [["Delta Co", "R. Osei", "Proposal", 98000, 0.4]]
18
+
19
+ Exit codes:
20
+ 0 rows appended (the JSON on stdout reports the new dimensions)
21
+ 1 refused: unknown sheet or malformed rows
22
+ 2 usage error, missing file, or unreadable workbook
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ if hasattr(sys.stdout, "reconfigure"):
33
+ sys.stdout.reconfigure(errors="replace")
34
+
35
+
36
+ def parse_args() -> argparse.Namespace:
37
+ parser = argparse.ArgumentParser(description="Append rows to the bottom of an .xlsx worksheet.")
38
+ parser.add_argument("--input", required=True, help="source workbook")
39
+ parser.add_argument("--out", help="destination workbook (default: <input>-edited.xlsx)")
40
+ parser.add_argument("--sheet", required=True, help="target worksheet")
41
+ parser.add_argument("--rows", metavar="JSON", help="rows as a JSON array of arrays")
42
+ parser.add_argument("--rows-file", help="JSON file holding the rows array")
43
+ return parser.parse_args()
44
+
45
+
46
+ def _error(message: str, code: int) -> int:
47
+ print(json.dumps({"ok": False, "error": message}), file=sys.stderr)
48
+ return code
49
+
50
+
51
+ def read_rows(args: argparse.Namespace) -> list[list[object]]:
52
+ if args.rows_file:
53
+ payload = json.loads(Path(args.rows_file).expanduser().read_text(encoding="utf-8-sig"))
54
+ elif args.rows:
55
+ payload = json.loads(args.rows)
56
+ else:
57
+ raise ValueError("nothing to do: pass --rows or --rows-file")
58
+ if isinstance(payload, dict):
59
+ payload = payload.get("rows")
60
+ if not isinstance(payload, list) or not all(isinstance(row, list) for row in payload):
61
+ raise ValueError("rows must be a JSON array of arrays")
62
+ if not payload:
63
+ raise ValueError("rows array is empty")
64
+ return payload
65
+
66
+
67
+ def main() -> int:
68
+ args = parse_args()
69
+ source = Path(args.input).expanduser()
70
+ if not source.is_file():
71
+ return _error(f"input file not found: {source}", 2)
72
+
73
+ try:
74
+ from openpyxl import load_workbook
75
+ except ImportError as error: # pragma: no cover - environment guard
76
+ return _error(f"openpyxl missing: {error}", 2)
77
+
78
+ try:
79
+ rows = read_rows(args)
80
+ except (OSError, ValueError, json.JSONDecodeError) as error:
81
+ return _error(str(error), 1)
82
+
83
+ workbook = load_workbook(str(source))
84
+ if args.sheet not in workbook.sheetnames:
85
+ return _error(f"sheet not found: {args.sheet}", 1)
86
+
87
+ worksheet = workbook[args.sheet]
88
+ before = worksheet.dimensions
89
+ start = worksheet.max_row + 1
90
+ for offset, row in enumerate(rows):
91
+ for column, value in enumerate(row, start=1):
92
+ worksheet.cell(row=start + offset, column=column, value=value)
93
+
94
+ out = Path(args.out).expanduser() if args.out else source.with_name(
95
+ f"{source.stem}-edited{source.suffix}"
96
+ )
97
+ out.parent.mkdir(parents=True, exist_ok=True)
98
+ workbook.save(str(out))
99
+ after = worksheet.dimensions
100
+ workbook.close()
101
+
102
+ print(
103
+ json.dumps(
104
+ {
105
+ "ok": True,
106
+ "input": str(source),
107
+ "output": str(out),
108
+ "sheet": args.sheet,
109
+ "rows_appended": len(rows),
110
+ "first_row": start,
111
+ "dimensions_before": before,
112
+ "dimensions_after": after,
113
+ },
114
+ ensure_ascii=False,
115
+ indent=2,
116
+ )
117
+ )
118
+ return 0
119
+
120
+
121
+ if __name__ == "__main__":
122
+ sys.exit(main())
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env python3
2
+ """Compare two .xlsx workbooks and report every difference.
3
+
4
+ Compares what the files store, not what they display: cell values, formula
5
+ text, number formats and font/fill/alignment, merged ranges, column widths,
6
+ freeze panes, tables, conditional formats, charts and defined names. Use it to
7
+ prove that an edit touched only the cells that were asked for.
8
+
9
+ Computed results are not compared here; run recalc_gate.py for that.
10
+
11
+ Exit codes:
12
+ 0 the compared scope is identical
13
+ 1 differences found (the JSON on stdout lists them)
14
+ 2 usage error, missing file, or unreadable workbook
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ if hasattr(sys.stdout, "reconfigure"):
25
+ # Legacy console codepages (cp936, cp1252) must not abort a report.
26
+ sys.stdout.reconfigure(errors="replace")
27
+
28
+
29
+ def parse_args() -> argparse.Namespace:
30
+ parser = argparse.ArgumentParser(
31
+ description="Diff two .xlsx workbooks: sheets, cells, formulas, "
32
+ "formatting, merges, widths, tables, charts, defined names.",
33
+ )
34
+ parser.add_argument("--before", required=True, help="baseline .xlsx file")
35
+ parser.add_argument("--after", required=True, help="edited .xlsx file")
36
+ parser.add_argument(
37
+ "--limit",
38
+ type=int,
39
+ default=200,
40
+ help="maximum cell changes reported per sheet (default 200)",
41
+ )
42
+ parser.add_argument(
43
+ "--no-formats",
44
+ action="store_true",
45
+ help="compare values and formulas only, ignore styling and number formats",
46
+ )
47
+ parser.add_argument("--sheet", action="append", help="restrict to this sheet (repeatable)")
48
+ return parser.parse_args()
49
+
50
+
51
+ def _color_signature(color) -> str | None:
52
+ if color is None:
53
+ return None
54
+ rgb = getattr(color, "rgb", None)
55
+ if isinstance(rgb, str) and rgb.upper() not in ("00000000", "000000"):
56
+ return rgb.upper()
57
+ theme = getattr(color, "theme", None)
58
+ if theme is not None:
59
+ return f"theme{theme}"
60
+ return None
61
+
62
+
63
+ def _style(cell) -> dict[str, object]:
64
+ font = cell.font
65
+ fill = cell.fill
66
+ return {
67
+ "format": cell.number_format,
68
+ "bold": bool(font.bold),
69
+ "italic": bool(font.italic),
70
+ "size": font.sz,
71
+ "color": _color_signature(font.color),
72
+ "fill": _color_signature(fill.fgColor) if getattr(fill, "patternType", None) else None,
73
+ "align": cell.alignment.horizontal,
74
+ }
75
+
76
+
77
+ def _cells(worksheet) -> dict[str, tuple[object, dict[str, object]]]:
78
+ found: dict[str, tuple[object, dict[str, object]]] = {}
79
+ for row in worksheet.iter_rows():
80
+ for cell in row:
81
+ if cell.value is None and not cell.has_style:
82
+ continue
83
+ found[cell.coordinate] = (cell.value, _style(cell))
84
+ return found
85
+
86
+
87
+ def _chart_entry(chart) -> dict[str, object]:
88
+ from openpyxl.utils import get_column_letter
89
+
90
+ refs = []
91
+ for series in getattr(chart, "series", None) or []:
92
+ value = getattr(series, "val", None)
93
+ reference = getattr(getattr(value, "numRef", None), "f", None) or getattr(
94
+ getattr(value, "strRef", None), "f", None
95
+ )
96
+ if reference:
97
+ refs.append(reference)
98
+ anchor = getattr(chart, "anchor", None)
99
+ start = getattr(anchor, "_from", None)
100
+ position = (
101
+ f"{get_column_letter(start.col + 1)}{start.row + 1}" if start is not None else type(anchor).__name__
102
+ )
103
+ return {"anchor": position, "series": refs}
104
+
105
+
106
+ def _structure(worksheet) -> dict[str, object]:
107
+ conditions = [
108
+ {"range": str(item.sqref), "rules": [rule.type for rule in item.rules]}
109
+ for item in worksheet.conditional_formatting
110
+ ]
111
+ return {
112
+ "dimensions": worksheet.dimensions,
113
+ "freeze_panes": worksheet.freeze_panes,
114
+ "merged": sorted(str(item) for item in worksheet.merged_cells.ranges),
115
+ "auto_filter": getattr(worksheet.auto_filter, "ref", None),
116
+ "gridlines": bool(worksheet.sheet_view.showGridLines),
117
+ "conditional_formats": conditions,
118
+ "data_validations": len(worksheet.data_validations.dataValidation),
119
+ "tables": {table.name: str(table.ref) for table in worksheet.tables.values()},
120
+ "charts": [_chart_entry(chart) for chart in getattr(worksheet, "_charts", [])],
121
+ "images": len(getattr(worksheet, "_images", [])),
122
+ "column_widths": {
123
+ name: round(dimension.width, 2)
124
+ for name, dimension in worksheet.column_dimensions.items()
125
+ if dimension.width
126
+ },
127
+ }
128
+
129
+
130
+ def _is_formula(value: object) -> bool:
131
+ return isinstance(value, str) and value.startswith("=")
132
+
133
+
134
+ def _cell_changes(before, after, *, formats: bool, limit: int) -> list[dict[str, object]]:
135
+ left = _cells(before)
136
+ right = _cells(after)
137
+ changes: list[dict[str, object]] = []
138
+ for coordinate in sorted(set(left) | set(right)):
139
+ a_value, a_style = left.get(coordinate, (None, None))
140
+ b_value, b_style = right.get(coordinate, (None, None))
141
+ if coordinate not in left:
142
+ changes.append({"cell": coordinate, "kind": "added", "after": b_value})
143
+ elif coordinate not in right:
144
+ changes.append({"cell": coordinate, "kind": "removed", "before": a_value})
145
+ elif a_value != b_value:
146
+ if _is_formula(a_value) and not _is_formula(b_value):
147
+ kind = "formula_removed"
148
+ elif _is_formula(b_value) and not _is_formula(a_value):
149
+ kind = "formula_added"
150
+ elif _is_formula(a_value) and _is_formula(b_value):
151
+ kind = "formula"
152
+ else:
153
+ kind = "value"
154
+ changes.append(
155
+ {"cell": coordinate, "kind": kind, "before": a_value, "after": b_value}
156
+ )
157
+ elif formats and a_style != b_style:
158
+ fields = [
159
+ key for key in a_style if a_style.get(key) != b_style.get(key) # type: ignore[union-attr]
160
+ ]
161
+ changes.append(
162
+ {
163
+ "cell": coordinate,
164
+ "kind": "format",
165
+ "fields": fields,
166
+ "before": {key: a_style[key] for key in fields}, # type: ignore[index]
167
+ "after": {key: b_style[key] for key in fields}, # type: ignore[index]
168
+ }
169
+ )
170
+ if len(changes) > limit:
171
+ break
172
+ return changes
173
+
174
+
175
+ def _structure_changes(before, after) -> dict[str, object]:
176
+ left = _structure(before)
177
+ right = _structure(after)
178
+ return {
179
+ key: {"before": left[key], "after": right[key]}
180
+ for key in left
181
+ if left[key] != right[key]
182
+ }
183
+
184
+
185
+ def _defined_names(workbook) -> dict[str, str | None]:
186
+ return {
187
+ entry.name: (entry.attr_text if entry.localSheetId is None else f"[{entry.localSheetId}] {entry.attr_text}")
188
+ for entry in workbook.defined_names.values()
189
+ }
190
+
191
+
192
+ def _names_changes(before, after) -> dict[str, object]:
193
+ left = _defined_names(before)
194
+ right = _defined_names(after)
195
+ return {
196
+ "added": sorted(set(right) - set(left)),
197
+ "removed": sorted(set(left) - set(right)),
198
+ "changed": {
199
+ name: {"before": left[name], "after": right[name]}
200
+ for name in sorted(set(left) & set(right))
201
+ if left[name] != right[name]
202
+ },
203
+ }
204
+
205
+
206
+ def main() -> int:
207
+ args = parse_args()
208
+ before_path = Path(args.before).expanduser()
209
+ after_path = Path(args.after).expanduser()
210
+ for label, path in (("before", before_path), ("after", after_path)):
211
+ if not path.is_file():
212
+ print(json.dumps({"ok": False, "error": f"{label} file not found: {path}"}), file=sys.stderr)
213
+ return 2
214
+
215
+ try:
216
+ from openpyxl import load_workbook
217
+ except ImportError as error: # pragma: no cover - environment guard
218
+ print(json.dumps({"ok": False, "error": f"openpyxl missing: {error}"}), file=sys.stderr)
219
+ return 2
220
+
221
+ try:
222
+ before = load_workbook(str(before_path), data_only=False)
223
+ after = load_workbook(str(after_path), data_only=False)
224
+ except Exception as error: # noqa: BLE001 - any unreadable file is reported the same way
225
+ print(json.dumps({"ok": False, "error": f"cannot read workbook: {error}"}), file=sys.stderr)
226
+ return 2
227
+
228
+ try:
229
+ before_names = list(before.sheetnames)
230
+ after_names = list(after.sheetnames)
231
+ added = [name for name in after_names if name not in before_names]
232
+ removed = [name for name in before_names if name not in after_names]
233
+ common = [name for name in before_names if name in after_names]
234
+ if args.sheet:
235
+ common = [name for name in common if name in set(args.sheet)]
236
+
237
+ sheet_reports = []
238
+ for name in common:
239
+ before_sheet = before[name]
240
+ after_sheet = after[name]
241
+ changes = _cell_changes(
242
+ before_sheet, after_sheet, formats=not args.no_formats, limit=args.limit
243
+ )
244
+ structure = _structure_changes(before_sheet, after_sheet)
245
+ if changes or structure:
246
+ kinds: dict[str, int] = {}
247
+ for change in changes:
248
+ kinds[change["kind"]] = kinds.get(change["kind"], 0) + 1
249
+ sheet_reports.append(
250
+ {
251
+ "name": name,
252
+ "change_count": len(changes),
253
+ "kinds": kinds,
254
+ "truncated": len(changes) > args.limit,
255
+ "structure": structure,
256
+ "changes": changes,
257
+ }
258
+ )
259
+
260
+ workbook_names = _names_changes(before, after)
261
+ identical = not (
262
+ added
263
+ or removed
264
+ or sheet_reports
265
+ or any(workbook_names.values()) # type: ignore[arg-type]
266
+ )
267
+ by_kind: dict[str, int] = {}
268
+ for report in sheet_reports:
269
+ for kind, count in report["kinds"].items(): # type: ignore[union-attr]
270
+ by_kind[kind] = by_kind.get(kind, 0) + count
271
+
272
+ payload = {
273
+ "ok": True,
274
+ "before": str(before_path),
275
+ "after": str(after_path),
276
+ "identical": identical,
277
+ "workbook": {
278
+ "sheets_added": added,
279
+ "sheets_removed": removed,
280
+ "sheet_order_changed": [
281
+ name
282
+ for name in after_names
283
+ if name in before_names and after_names.index(name) != before_names.index(name)
284
+ ],
285
+ "defined_names": workbook_names,
286
+ },
287
+ "sheets": sheet_reports,
288
+ "summary": {
289
+ "sheets_compared": len(common),
290
+ "sheets_added": len(added),
291
+ "sheets_removed": len(removed),
292
+ "sheets_changed": len(sheet_reports),
293
+ "cells_changed": sum(report["change_count"] for report in sheet_reports), # type: ignore[arg-type]
294
+ "by_kind": by_kind,
295
+ },
296
+ }
297
+ finally:
298
+ before.close()
299
+ after.close()
300
+
301
+ print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
302
+ return 0 if payload["identical"] else 1
303
+
304
+
305
+ if __name__ == "__main__":
306
+ sys.exit(main())