dsh-office-kit 0.2.0 → 0.3.1

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 +99 -0
  24. package/assets/skills/office-documents/scripts/a11y_audit.py +201 -0
  25. package/assets/skills/office-documents/scripts/accept_tracked_changes.py +158 -0
  26. package/assets/skills/office-documents/scripts/add_tracked_replacements.py +155 -0
  27. package/assets/skills/office-documents/scripts/append_blocks.py +115 -0
  28. package/assets/skills/office-documents/scripts/apply_template_styles.py +111 -0
  29. package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +109 -0
  30. package/assets/skills/office-documents/scripts/comments_add.py +143 -0
  31. package/assets/skills/office-documents/scripts/comments_apply_patch.py +121 -0
  32. package/assets/skills/office-documents/scripts/comments_extract.py +92 -0
  33. package/assets/skills/office-documents/scripts/comments_strip.py +81 -0
  34. package/assets/skills/office-documents/scripts/content_controls.py +199 -0
  35. package/assets/skills/office-documents/scripts/core_properties.py +131 -0
  36. package/assets/skills/office-documents/scripts/docx_kit.py +594 -0
  37. package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +232 -0
  38. package/assets/skills/office-documents/scripts/docx_table_to_csv.py +106 -0
  39. package/assets/skills/office-documents/scripts/fields_materialize.py +73 -0
  40. package/assets/skills/office-documents/scripts/fields_report.py +66 -0
  41. package/assets/skills/office-documents/scripts/flatten_ref_fields.py +69 -0
  42. package/assets/skills/office-documents/scripts/footnotes_report.py +84 -0
  43. package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +103 -0
  44. package/assets/skills/office-documents/scripts/heading_audit.py +88 -0
  45. package/assets/skills/office-documents/scripts/images_audit.py +87 -0
  46. package/assets/skills/office-documents/scripts/insert_note.py +133 -0
  47. package/assets/skills/office-documents/scripts/insert_ref_fields.py +97 -0
  48. package/assets/skills/office-documents/scripts/insert_toc.py +91 -0
  49. package/assets/skills/office-documents/scripts/internal_nav.py +153 -0
  50. package/assets/skills/office-documents/scripts/make_fixtures.py +176 -0
  51. package/assets/skills/office-documents/scripts/merge_docx_append.py +246 -0
  52. package/assets/skills/office-documents/scripts/privacy_scrub.py +138 -0
  53. package/assets/skills/office-documents/scripts/redact_docx.py +120 -0
  54. package/assets/skills/office-documents/scripts/render_and_diff.py +108 -0
  55. package/assets/skills/office-documents/scripts/replace_text.py +161 -0
  56. package/assets/skills/office-documents/scripts/section_audit.py +98 -0
  57. package/assets/skills/office-documents/scripts/set_protection.py +99 -0
  58. package/assets/skills/office-documents/scripts/style_lint.py +118 -0
  59. package/assets/skills/office-documents/scripts/style_normalize.py +110 -0
  60. package/assets/skills/office-documents/scripts/table_geometry.py +174 -0
  61. package/assets/skills/office-documents/scripts/watermark_add.py +55 -0
  62. package/assets/skills/office-documents/scripts/watermark_audit_remove.py +74 -0
  63. package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +124 -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 +143 -0
  114. package/assets/skills/office-presentations/container_tools/core_properties.py +127 -0
  115. package/assets/skills/office-presentations/container_tools/create_montage.py +133 -0
  116. package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +292 -0
  117. package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +164 -0
  118. package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +151 -0
  119. package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +206 -0
  120. package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +219 -0
  121. package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +352 -0
  122. package/assets/skills/office-presentations/container_tools/preview_render.py +382 -0
  123. package/assets/skills/office-presentations/container_tools/render_slides.py +146 -0
  124. package/assets/skills/office-presentations/container_tools/replace_text.py +175 -0
  125. package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +236 -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 +183 -0
  152. package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +161 -0
  153. package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +118 -0
  154. package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +246 -0
  155. package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +192 -0
  156. package/assets/skills/office-spreadsheets/SKILL.md +105 -101
  157. package/assets/skills/office-spreadsheets/container_tools/add_chart.py +243 -0
  158. package/assets/skills/office-spreadsheets/container_tools/add_image.py +114 -0
  159. package/assets/skills/office-spreadsheets/container_tools/append_rows.py +129 -0
  160. package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +313 -0
  161. package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +405 -0
  162. package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +287 -0
  163. package/assets/skills/office-spreadsheets/container_tools/set_cells.py +142 -0
  164. package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +141 -0
  165. package/assets/skills/office-spreadsheets/container_tools/style_cells.py +140 -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 +260 -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 +246 -89
  195. package/package.json +1 -1
  196. package/postinstall.mjs +92 -82
  197. package/python/office_cli.py +213 -254
@@ -0,0 +1,219 @@
1
+ """Check that a table's declared totals actually add up.
2
+
3
+ Purpose: a total row that does not match its column is the fastest way to lose
4
+ a room. The rule that keeps this gate honest: it only enforces totals the
5
+ author explicitly declares. Auto-discovered rows are reported as diagnostics,
6
+ never failed, because "Total" can legitimately mean something else.
7
+
8
+ Arithmetic is done with Decimal so 0.1 + 0.2 style inputs compare as written,
9
+ and the tolerance comes from how many decimals the table displays.
10
+
11
+ Contract file (`--contracts`):
12
+ {"contracts": [{"slide": 4, "table": 1, "total_row": 5, "columns": [1, 2], "tolerance": "0.01"}]}
13
+ `table` is 1-based among tables on that slide; `total_row` is 0-based by default,
14
+ switch to 1-based with `"row_base": 1` on the contract or `--row-base`.
15
+
16
+ Exit codes: 0 pass, 1 fail, 2 unreadable input or bad contract.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import re
23
+ import sys
24
+ from decimal import Decimal, InvalidOperation
25
+ from pathlib import Path
26
+
27
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
28
+ for _stream in (sys.stdout, sys.stderr):
29
+ try:
30
+ _stream.reconfigure(encoding="utf-8", errors="replace")
31
+ except (AttributeError, ValueError):
32
+ pass
33
+
34
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
35
+
36
+ TOTAL_PATTERN = re.compile(r"\btotal\b", re.IGNORECASE)
37
+ SUBTOTAL_PATTERN = re.compile(r"\bsub[\s-]?total\b", re.IGNORECASE)
38
+ NON_ADDITIVE = re.compile(r"(unit\s*price|per\s*share|ratio|rate|%|percent|margin|average|avg|median)", re.IGNORECASE)
39
+ NUMBER = re.compile(r"^[+-]?[0-9][0-9,]*(\.[0-9]+)?$")
40
+
41
+
42
+ def _to_decimal(text: str) -> Decimal | None:
43
+ cleaned = text.strip().replace(",", "").replace("\u2212", "-")
44
+ if cleaned.startswith("(") and cleaned.endswith(")"):
45
+ cleaned = "-" + cleaned[1:-1]
46
+ cleaned = cleaned.replace("$", "").replace("\u20ac", "").replace("\u00a3", "").replace("%", "").strip()
47
+ if not NUMBER.match(cleaned):
48
+ return None
49
+ try:
50
+ return Decimal(cleaned)
51
+ except InvalidOperation:
52
+ return None
53
+
54
+
55
+ def _decimals(text: str) -> int:
56
+ cleaned = text.strip().replace(",", "")
57
+ return len(cleaned.split(".")[1]) if "." in cleaned else 0
58
+
59
+
60
+ def _tables(slide) -> list:
61
+ return [shape for shape in slide.shapes if getattr(shape, "has_table", False)]
62
+
63
+
64
+ def check(presentation_path: str | Path, contracts: list[dict], row_base: int = 0) -> dict:
65
+ path = Path(presentation_path)
66
+ if not path.is_file():
67
+ return {"gate": "table-arithmetic", "passed": False, "findings": [{"kind": "missing_file", "detail": str(path)}]}
68
+
69
+ from pptx import Presentation
70
+
71
+ presentation = Presentation(str(path))
72
+ slides = list(presentation.slides)
73
+ findings: list[dict] = []
74
+ checks: list[dict] = []
75
+ declared: set[tuple[int, int]] = set()
76
+
77
+ for contract in contracts:
78
+ slide_no = int(contract["slide"])
79
+ table_index = int(contract.get("table", 1)) - 1
80
+ base = int(contract.get("row_base", row_base))
81
+ if not 1 <= slide_no <= len(slides):
82
+ findings.append({"kind": "bad_contract", "severity": "fail", "message": f"slide {slide_no} is out of range"})
83
+ continue
84
+ tables = _tables(slides[slide_no - 1])
85
+ if not 1 <= table_index + 1 <= len(tables):
86
+ findings.append({"kind": "bad_contract", "severity": "fail", "slide": slide_no, "message": f"table {table_index + 1} not found"})
87
+ continue
88
+ table = tables[table_index].table
89
+ declared.add((slide_no, table_index))
90
+ total_row = int(contract["total_row"]) - 1 if base == 1 else int(contract["total_row"])
91
+ rows = list(table.rows)
92
+ if not 0 <= total_row < len(rows):
93
+ findings.append({"kind": "bad_contract", "severity": "fail", "slide": slide_no, "message": f"total row {total_row} is out of range"})
94
+ continue
95
+ tolerance = Decimal(str(contract.get("tolerance", "0.01")))
96
+ columns = [int(item) for item in contract.get("columns", range(1, len(table.columns)))]
97
+ header = [cell.text for cell in rows[0].cells] if rows else []
98
+
99
+ for column in columns:
100
+ if not 0 <= column < len(table.columns):
101
+ findings.append({"kind": "bad_contract", "severity": "fail", "slide": slide_no, "message": f"column {column} is out of range"})
102
+ continue
103
+ header_text = header[column] if column < len(header) else ""
104
+ if NON_ADDITIVE.search(header_text):
105
+ checks.append({"slide": slide_no, "column": column, "header": header_text, "status": "skipped_non_additive"})
106
+ continue
107
+ displayed_text = rows[total_row].cells[column].text
108
+ displayed = _to_decimal(displayed_text)
109
+ if displayed is None:
110
+ findings.append(
111
+ {
112
+ "kind": "unparseable_total",
113
+ "severity": "fail",
114
+ "slide": slide_no,
115
+ "column": column,
116
+ "header": header_text,
117
+ "displayed": displayed_text,
118
+ "message": "the declared total cell is not a number this gate can read",
119
+ }
120
+ )
121
+ continue
122
+ addends: list[Decimal] = []
123
+ for row in rows[1:total_row]:
124
+ value = _to_decimal(row.cells[column].text)
125
+ if value is not None:
126
+ addends.append(value)
127
+ computed = sum(addends, Decimal(0))
128
+ unit = Decimal(1).scaleb(-max([_decimals(displayed_text)] + [_decimals(rows[index].cells[column].text) for index in range(1, total_row)] or [0]))
129
+ delta = abs(computed - displayed)
130
+ rounding_slack = max(tolerance, unit / 2)
131
+ passed = delta <= rounding_slack
132
+ checks.append(
133
+ {
134
+ "slide": slide_no,
135
+ "column": column,
136
+ "header": header_text,
137
+ "status": "pass" if passed else "mismatch",
138
+ "displayed": str(displayed),
139
+ "computed": str(computed),
140
+ "delta": str(delta),
141
+ "rounding_slack": str(rounding_slack),
142
+ "addends": [str(item) for item in addends],
143
+ }
144
+ )
145
+ if not passed:
146
+ findings.append(
147
+ {
148
+ "kind": "total_mismatch",
149
+ "severity": "fail",
150
+ "slide": slide_no,
151
+ "column": column,
152
+ "header": header_text,
153
+ "displayed": str(displayed),
154
+ "computed": str(computed),
155
+ "message": f"column '{header_text or column}' totals {computed} but displays {displayed}",
156
+ }
157
+ )
158
+
159
+ # diagnostics only: never fail on a row we were not told to enforce
160
+ for slide_no, slide in enumerate(slides, start=1):
161
+ for table_index, shape in enumerate(_tables(slide)):
162
+ if (slide_no, table_index) in declared:
163
+ continue
164
+ rows = list(shape.table.rows)
165
+ for row_index, row in enumerate(rows):
166
+ first = row.cells[0].text.strip()
167
+ if TOTAL_PATTERN.search(first) or SUBTOTAL_PATTERN.search(first):
168
+ findings.append(
169
+ {
170
+ "kind": "auto_detected_total",
171
+ "severity": "diagnostic",
172
+ "slide": slide_no,
173
+ "row": row_index,
174
+ "label": first,
175
+ "message": "total-looking row is not declared in a contract, so it was not enforced",
176
+ }
177
+ )
178
+
179
+ return {
180
+ "gate": "table-arithmetic",
181
+ "presentation": str(path.resolve()),
182
+ "contracts": len(contracts),
183
+ "checks": checks,
184
+ "findings": findings,
185
+ "passed": not [item for item in findings if item["severity"] == "fail"],
186
+ }
187
+
188
+
189
+ def main(argv: list[str] | None = None) -> int:
190
+ import argparse
191
+
192
+ parser = argparse.ArgumentParser(description="Verify declared table totals against their columns.")
193
+ parser.add_argument("presentation", nargs="?", help="path to the .pptx")
194
+ parser.add_argument("--input", dest="input_path", help="path to the .pptx (same as the positional)")
195
+ parser.add_argument("--contracts", required=True, help="JSON file listing the totals to enforce")
196
+ parser.add_argument("--row-base", type=int, choices=(0, 1), default=0, help="0-based row indices (default) or 1-based")
197
+ parser.add_argument("--json-out", help="also write the report here")
198
+ args = parser.parse_args(argv)
199
+ path = args.input_path or args.presentation
200
+ if not path:
201
+ parser.error("a .pptx path is required (positional or --input)")
202
+
203
+ try:
204
+ payload = json.loads(Path(args.contracts).read_text(encoding="utf-8"))
205
+ except Exception as exc: # noqa: BLE001 - any parse problem is the caller's contract
206
+ json.dump({"gate": "table-arithmetic", "passed": False, "error": {"code": "bad_contracts", "message": str(exc)}}, sys.stdout)
207
+ sys.stdout.write("\n")
208
+ return 2
209
+ contracts = payload.get("contracts", payload if isinstance(payload, list) else [])
210
+ report = check(path, contracts, args.row_base)
211
+ if args.json_out:
212
+ Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
213
+ json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
214
+ sys.stdout.write("\n")
215
+ return 0 if report["passed"] else 1
216
+
217
+
218
+ if __name__ == "__main__":
219
+ raise SystemExit(main())
@@ -0,0 +1,352 @@
1
+ """Shared helpers for the slide-deck container tools.
2
+
3
+ Purpose: one import surface for the small, boring things every checker needs —
4
+ opening a deck, walking shapes (including groups), reading a bounding box in
5
+ inches, guessing how much room a line of text needs, and printing a JSON
6
+ verdict. Nothing here decides anything; the scripts do.
7
+
8
+ Import from a sibling script with:
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
11
+ from ooxml_helpers import ...
12
+
13
+ Exit codes are documented per script; this module only exits when a caller
14
+ passes a bad path to `open_presentation`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import math
21
+ import re
22
+ import sys
23
+ from pathlib import Path
24
+ from typing import Any, Iterable, Iterator
25
+
26
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
27
+ for _stream in (sys.stdout, sys.stderr):
28
+ try:
29
+ _stream.reconfigure(encoding="utf-8", errors="replace")
30
+ except (AttributeError, ValueError):
31
+ pass
32
+
33
+ EMU_PER_INCH = 914400
34
+
35
+ # Anything that renders about a full em wide and wraps mid-token.
36
+ WIDE_CHAR = re.compile(r"[\u1100-\u11ff\u2e80-\ua4cf\uac00-\ud7af\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]")
37
+ WORD = re.compile(r"[A-Za-z0-9][A-Za-z0-9'’.,%+\-]*")
38
+
39
+ TOTAL_WORDS = re.compile(
40
+ r"\b(total|totals|sum|sums|subtotal|sub-total|grand\s+total|overall|all\s+regions?|combined)\b",
41
+ re.IGNORECASE,
42
+ )
43
+ NOT_ADDITIVE = re.compile(
44
+ r"(rate|ratio|percent|percentage|%|share|average|avg|mean|median|margin|margin\s+%|uplift|"
45
+ r"growth|index|price|unit|cpu|latency|duration|per\s+unit|headcount\s+ratio)",
46
+ re.IGNORECASE,
47
+ )
48
+ PLACEHOLDER_TITLE = re.compile(r"^(chart\s*title|title|click\s+to\s+add\s+(a\s+)?title)\s*$", re.IGNORECASE)
49
+
50
+
51
+ def open_presentation(path: str | Path):
52
+ """Open a .pptx; exits 2 with a JSON error if it cannot be read."""
53
+ from pptx import Presentation
54
+
55
+ target = Path(path)
56
+ if not target.is_file():
57
+ emit_error("input_missing", f"no such file: {target}")
58
+ raise SystemExit(2)
59
+ try:
60
+ return Presentation(str(target))
61
+ except Exception as exc: # noqa: BLE001 - report, never traceback at the user
62
+ emit_error("unreadable_presentation", f"{type(exc).__name__}: {exc}")
63
+ raise SystemExit(2)
64
+
65
+
66
+ def slide_size_in(prs) -> tuple[float, float]:
67
+ return prs.slide_width / EMU_PER_INCH, prs.slide_height / EMU_PER_INCH
68
+
69
+
70
+ def iter_shapes(shapes) -> Iterator[Any]:
71
+ """Yield every shape, descending into groups."""
72
+ from pptx.enum.shapes import MSO_SHAPE_TYPE
73
+
74
+ for shape in shapes:
75
+ yield shape
76
+ if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
77
+ yield from iter_shapes(shape.shapes)
78
+
79
+
80
+ def box(shape) -> tuple[float, float, float, float] | None:
81
+ """(left, top, width, height) in inches, or None when the shape has no frame."""
82
+ if shape.left is None or shape.top is None:
83
+ return None
84
+ width = shape.width or 0
85
+ height = shape.height or 0
86
+ return (
87
+ shape.left / EMU_PER_INCH,
88
+ shape.top / EMU_PER_INCH,
89
+ width / EMU_PER_INCH,
90
+ height / EMU_PER_INCH,
91
+ )
92
+
93
+
94
+ def label(shape) -> str:
95
+ name = getattr(shape, "name", "") or ""
96
+ if name:
97
+ return name
98
+ return f"{shape.shape_type}"
99
+
100
+
101
+ def placeholder_kind(shape) -> str | None:
102
+ """Placeholder role as a bare token: TITLE, BODY, SUBTITLE, DATE, ...
103
+
104
+ python-pptx stringifies the enum differently across versions ("TITLE (13)",
105
+ "<PP_PLACEHOLDER.TITLE: 13>"), so the number and decorations are stripped
106
+ here and every caller can compare against one spelling.
107
+ """
108
+ try:
109
+ fmt = shape.placeholder_format
110
+ except (AttributeError, ValueError, KeyError):
111
+ return None
112
+ if fmt is None:
113
+ return None
114
+ kind = getattr(fmt, "type", None)
115
+ if kind is None:
116
+ return "UNKNOWN"
117
+ raw = str(kind).upper().replace("PP_PLACEHOLDER.", " ")
118
+ for token in ("<", ">", "(", ")", ":", "."):
119
+ raw = raw.replace(token, " ")
120
+ tokens = [token for token in raw.split() if token and not token.isdigit()]
121
+ return tokens[-1] if tokens else "UNKNOWN"
122
+
123
+
124
+ def has_text_frame(shape) -> bool:
125
+ return getattr(shape, "has_text_frame", False) and shape.has_text_frame
126
+
127
+
128
+ def text_of(shape) -> str:
129
+ if not has_text_frame(shape):
130
+ return ""
131
+ return "\n".join(p.text for p in shape.text_frame.paragraphs).strip()
132
+
133
+
134
+ def has_visible_text(shape) -> bool:
135
+ return bool(text_of(shape))
136
+
137
+
138
+ def run_size_pt(shape) -> float | None:
139
+ """Largest explicit size in the shape, or None when fully inherited.
140
+
141
+ Size can sit on the run or on the paragraph's default run properties; a
142
+ paragraph-level size still resolves in PowerPoint, so both count.
143
+ """
144
+ if not has_text_frame(shape):
145
+ return None
146
+ sizes: list[float] = []
147
+ for paragraph in shape.text_frame.paragraphs:
148
+ try:
149
+ if paragraph.font.size is not None:
150
+ sizes.append(paragraph.font.size.pt)
151
+ except AttributeError: # older python-pptx without paragraph.font
152
+ pass
153
+ sizes.extend(run.font.size.pt for run in paragraph.runs if run.font.size is not None)
154
+ return max(sizes) if sizes else None
155
+
156
+
157
+ def paragraph_size_pt(paragraph, fallback: float) -> float:
158
+ try:
159
+ if paragraph.font.size is not None:
160
+ return paragraph.font.size.pt
161
+ except AttributeError:
162
+ pass
163
+ for run in paragraph.runs:
164
+ if run.font.size is not None:
165
+ return run.font.size.pt
166
+ return fallback
167
+
168
+
169
+ def default_size_pt(shape) -> float:
170
+ """Approximate the size a viewer resolves for an under-specified text box."""
171
+ kind = (placeholder_kind(shape) or "").upper()
172
+ if "CENTER_TITLE" in kind:
173
+ return 44.0
174
+ if "TITLE" in kind:
175
+ return 32.0
176
+ if "SUBTITLE" in kind:
177
+ return 20.0
178
+ if "BODY" in kind:
179
+ return 18.0
180
+ return 18.0
181
+
182
+
183
+ def line_height_in(font_pt: float, spacing: float = 1.2) -> float:
184
+ return font_pt * spacing / 72.0
185
+
186
+
187
+ def wrap_lines(text: str, font_pt: float, width_in: float) -> int:
188
+ """Crude but stable line count: wide chars cost one em, latin words wrap."""
189
+ if not text.strip():
190
+ return 1
191
+ per_line = max(1.0, width_in * 72.0 / max(font_pt, 1.0))
192
+ used = 0.0
193
+ lines = 1
194
+ for token in re.findall(r"\S+\s*", text):
195
+ cost = 0.0
196
+ for char in token:
197
+ cost += 1.0 if WIDE_CHAR.match(char) else 0.55
198
+ if used + cost > per_line and used > 0:
199
+ lines += 1
200
+ used = cost
201
+ else:
202
+ used += cost
203
+ return lines
204
+
205
+
206
+ def estimated_text_height_in(shape) -> float | None:
207
+ """Rough height the text needs at its declared size, or None if no text."""
208
+ if not has_text_frame(shape):
209
+ return None
210
+ geometry = box(shape)
211
+ if geometry is None:
212
+ return None
213
+ frame = shape.text_frame
214
+ inner_w = max(0.2, geometry[2] - (frame.margin_left + frame.margin_right) / EMU_PER_INCH)
215
+ total = 0.0
216
+ any_text = False
217
+ for paragraph in frame.paragraphs:
218
+ text = paragraph.text
219
+ if not text.strip():
220
+ total += line_height_in(default_size_pt(shape)) * 0.5
221
+ continue
222
+ any_text = True
223
+ size = default_size_pt(shape)
224
+ for run in paragraph.runs:
225
+ if run.font.size is not None:
226
+ size = run.font.size.pt
227
+ break
228
+ else:
229
+ size = paragraph_size_pt(paragraph, size)
230
+ total += wrap_lines(text, size, inner_w) * line_height_in(size)
231
+ return total if any_text else None
232
+
233
+
234
+ def is_picture(shape) -> bool:
235
+ from pptx.enum.shapes import MSO_SHAPE_TYPE
236
+
237
+ return shape.shape_type == MSO_SHAPE_TYPE.PICTURE
238
+
239
+
240
+ def is_table(shape) -> bool:
241
+ return getattr(shape, "has_table", False) and shape.has_table
242
+
243
+
244
+ def is_chart(shape) -> bool:
245
+ return getattr(shape, "has_chart", False) and shape.has_chart
246
+
247
+
248
+ def is_connector(shape) -> bool:
249
+ from pptx.enum.shapes import MSO_SHAPE_TYPE
250
+
251
+ return shape.shape_type in {MSO_SHAPE_TYPE.LINE, MSO_SHAPE_TYPE.FREEFORM} or shape.__class__.__name__ == "Connector"
252
+
253
+
254
+ def is_decorative(shape) -> bool:
255
+ """Shapes a template owner would call furniture: no text, or a picture/line."""
256
+ return is_picture(shape) or is_connector(shape) or not has_text_frame(shape) or not has_visible_text(shape)
257
+
258
+
259
+ def cell_number(value: str) -> float | None:
260
+ """Parse a numeric table cell; None when it is not a number."""
261
+ text = (value or "").strip()
262
+ if not text:
263
+ return None
264
+ negative = text.startswith("(") and text.endswith(")")
265
+ cleaned = re.sub(r"[,\s\u00a0]", "", text.strip("()"))
266
+ cleaned = re.sub(r"^[^\d+\-.]*", "", cleaned)
267
+ match = re.match(r"^[+-]?\d*\.?\d+", cleaned)
268
+ if not match:
269
+ return None
270
+ try:
271
+ number = float(match.group(0))
272
+ except ValueError:
273
+ return None
274
+ return -number if negative else number
275
+
276
+
277
+ def decimals(value: str) -> int:
278
+ match = re.search(r"\.(\d+)", (value or "").replace(",", ""))
279
+ return len(match.group(1)) if match else 0
280
+
281
+
282
+ def rects_intersect(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> bool:
283
+ ax, ay, aw, ah = a
284
+ bx, by, bw, bh = b
285
+ return not (ax + aw <= bx or bx + bw <= ax or ay + ah <= by or by + bh <= ay)
286
+
287
+
288
+ def intersection_area(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> float:
289
+ ax, ay, aw, ah = a
290
+ bx, by, bw, bh = b
291
+ dx = min(ax + aw, bx + bw) - max(ax, bx)
292
+ dy = min(ay + ah, by + bh) - max(ay, by)
293
+ return max(0.0, dx) * max(0.0, dy)
294
+
295
+
296
+ def overlap_ratio(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> float:
297
+ smaller = min(a[2] * a[3], b[2] * b[3])
298
+ if smaller <= 0:
299
+ return 0.0
300
+ return intersection_area(a, b) / smaller
301
+
302
+
303
+ def emit(payload: dict[str, Any]) -> None:
304
+ json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
305
+ sys.stdout.write("\n")
306
+
307
+
308
+ def emit_error(code: str, message: str) -> None:
309
+ emit({"ok": False, "error": {"code": code, "message": message}})
310
+
311
+
312
+ def finding(kind: str, severity: str, message: str, **extra: Any) -> dict[str, Any]:
313
+ payload = {"kind": kind, "severity": severity, "message": message}
314
+ payload.update({key: value for key, value in extra.items() if value is not None})
315
+ return payload
316
+
317
+
318
+ def number_or_none(value: Any) -> float | None:
319
+ try:
320
+ out = float(value)
321
+ except (TypeError, ValueError):
322
+ return None
323
+ return out if math.isfinite(out) else None
324
+
325
+
326
+ def iter_media_parts(prs) -> Iterable[Any]:
327
+ for part in prs.part.package.iter_parts():
328
+ if "/media/" in str(part.partname):
329
+ yield part
330
+
331
+
332
+ def demo() -> int:
333
+ """Self-check: the three estimates every gate leans on. Run with --help too."""
334
+ # A 40-character latin line at 18pt needs ~ (40*0.55*18)/72 = 5.5in.
335
+ assert wrap_lines("x" * 40, 18, 5.5) == 1
336
+ # Words wrap: 20 tokens cost 20 * 1.1em, so a 2in frame splits them.
337
+ assert wrap_lines("ab " * 20, 18, 2.0) > 1
338
+ # Wide characters cost a full em, so the same word count needs more lines.
339
+ assert wrap_lines("物 " * 20, 18, 2.0) > wrap_lines("x " * 20, 18, 2.0)
340
+ assert cell_number("(1,234.50)") == -1234.5
341
+ assert cell_number("n/a") is None
342
+ assert decimals("1.50") == 2
343
+ # Coplanar 2x2 boxes offset by 1in share half the smaller area: ratio 0.5.
344
+ assert abs(overlap_ratio((0, 0, 2, 2), (1, 0, 2, 2)) - 0.5) < 1e-9
345
+ # A box swallowed whole counts as fully covered.
346
+ assert overlap_ratio((0, 0, 2, 2), (0, 0, 2, 1)) == 1.0
347
+ emit({"ok": True, "module": "ooxml_helpers", "helpers": 24, "checks": "wrap_lines, cell_number, decimals, overlap_ratio"})
348
+ return 0
349
+
350
+
351
+ if __name__ == "__main__":
352
+ raise SystemExit(demo())