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,144 @@
1
+ """Decide whether every native chart carries a title the author meant to ship.
2
+
3
+ Purpose: a chart with the literal "Chart Title" placeholder is an unfinished
4
+ chart. PowerPoint will happily render it, so no render check catches it. This
5
+ reads the chart parts and classifies each title, then fails only on the two
6
+ states that mean "nobody decided here": a default placeholder and an empty
7
+ title element.
8
+
9
+ Statuses: absent, present, default_placeholder, linked_title_unresolved,
10
+ explicitly_suppressed, empty_title.
11
+
12
+ Exit codes: 0 pass, 1 fail, 2 unreadable input.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import sys
19
+ import zipfile
20
+ from pathlib import Path
21
+ from xml.etree import ElementTree
22
+
23
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
24
+
25
+ C_NS = "{http://schemas.openxmlformats.org/drawingml/2006/chart}"
26
+ A_NS = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
27
+ R_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
28
+
29
+ DEFAULT_TITLE_STRINGS = ("chart title", "chart 1 title")
30
+ BLOCKING = {"default_placeholder", "empty_title"}
31
+
32
+
33
+ def _chart_title_status(chart_xml: bytes) -> tuple[str, str]:
34
+ root = ElementTree.fromstring(chart_xml)
35
+ chart = root.find(f"{C_NS}chart")
36
+ if chart is None:
37
+ return "absent", ""
38
+ deleted = chart.find(f"{C_NS}autoTitleDeleted")
39
+ suppressed = deleted is not None and deleted.get("val", "1") in {"1", "true"}
40
+ title = chart.find(f"{C_NS}title")
41
+ if title is None:
42
+ return ("explicitly_suppressed", "") if suppressed else ("absent", "")
43
+
44
+ if title.find(f"{C_NS}tx/{C_NS}strRef") is not None:
45
+ return "linked_title_unresolved", ""
46
+ rich = title.find(f"{C_NS}tx/{C_NS}rich")
47
+ if rich is None:
48
+ return "empty_title", ""
49
+ pieces = [node.text or "" for node in rich.iter(f"{A_NS}t")]
50
+ text = "".join(pieces).strip()
51
+ if not text:
52
+ return "empty_title", ""
53
+ if " ".join(text.split()).lower() in DEFAULT_TITLE_STRINGS:
54
+ return "default_placeholder", text
55
+ return "present", text
56
+
57
+
58
+ def inspect_chart_titles(pptx_path: str | Path) -> dict:
59
+ path = Path(pptx_path)
60
+ if not path.is_file():
61
+ return {"gate": "native-chart-title", "passed": False, "findings": [{"kind": "missing_file", "detail": str(path)}]}
62
+
63
+ from pptx import Presentation
64
+
65
+ presentation = Presentation(str(path))
66
+ slides: list[dict] = []
67
+ checks: list[dict] = []
68
+ chart_parts: dict[str, bytes] = {}
69
+ with zipfile.ZipFile(path) as archive:
70
+ for name in archive.namelist():
71
+ if name.startswith("ppt/charts/") and name.endswith(".xml"):
72
+ chart_parts[name] = archive.read(name)
73
+
74
+ for slide_no, slide in enumerate(presentation.slides, start=1):
75
+ chart_count = 0
76
+ for shape in slide.shapes:
77
+ if not getattr(shape, "has_chart", False):
78
+ continue
79
+ chart_count += 1
80
+ part = shape.chart.part
81
+ part_name = str(part.partname).lstrip("/")
82
+ payload = chart_parts.get(part_name)
83
+ if payload is None:
84
+ checks.append({"slide": slide_no, "chart": shape.name, "part": part_name, "status": "part_missing", "title": ""})
85
+ continue
86
+ status, text = _chart_title_status(payload)
87
+ checks.append({"slide": slide_no, "chart": shape.name, "part": part_name, "status": status, "title": text})
88
+ slides.append({"slide": slide_no, "charts": chart_count})
89
+
90
+ unused = sorted(set(chart_parts) - {check["part"] for check in checks})
91
+ findings = [
92
+ {
93
+ "kind": "chart_title",
94
+ "severity": "fail",
95
+ "slide": check["slide"],
96
+ "chart": check["chart"],
97
+ "status": check["status"],
98
+ "message": (
99
+ "chart still shows the placeholder title"
100
+ if check["status"] == "default_placeholder"
101
+ else "chart has an empty title element"
102
+ ),
103
+ }
104
+ for check in checks
105
+ if check["status"] in BLOCKING
106
+ ]
107
+ for part in unused:
108
+ findings.append({"kind": "orphan_chart_part", "severity": "review", "part": part, "message": "chart part is not placed on any slide"})
109
+
110
+ return {
111
+ "gate": "native-chart-title",
112
+ "presentation": str(path.resolve()),
113
+ "slide_count": len(slides),
114
+ "native_chart_count": sum(item["charts"] for item in slides),
115
+ "checks": checks,
116
+ "findings": findings,
117
+ "native_powerpoint_verified": False,
118
+ "source_text_emitted": False,
119
+ "passed": not [item for item in findings if item["severity"] == "fail"],
120
+ }
121
+
122
+
123
+ def main(argv: list[str] | None = None) -> int:
124
+ import argparse
125
+
126
+ parser = argparse.ArgumentParser(description="Fail when a native chart keeps a placeholder or empty title.")
127
+ parser.add_argument("presentation", nargs="?", help="path to the .pptx")
128
+ parser.add_argument("--input", dest="input_path", help="path to the .pptx (same as the positional)")
129
+ parser.add_argument("--json-out", help="also write the report here")
130
+ args = parser.parse_args(argv)
131
+ path = args.input_path or args.presentation
132
+ if not path:
133
+ parser.error("a .pptx path is required (positional or --input)")
134
+
135
+ report = inspect_chart_titles(path)
136
+ if args.json_out:
137
+ Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
138
+ json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
139
+ sys.stdout.write("\n")
140
+ return 0 if report["passed"] else 1
141
+
142
+
143
+ if __name__ == "__main__":
144
+ raise SystemExit(main())
@@ -0,0 +1,199 @@
1
+ """Check that a quantitative chart is backed by real data, not just shapes.
2
+
3
+ Purpose: a chart can look right and carry nothing editable — an empty cache, a
4
+ series with no categories, points that exist as a count but not as values. This
5
+ gate only governs slides the caller declares as quantitative (`--slide`), so a
6
+ decorative series is not nagged.
7
+
8
+ Two modes, ported from the original behaviour:
9
+ portable (default) a durable embedded workbook must ship with the chart
10
+ powerpoint also accept a complete native literal cache (`c:numCache` with a
11
+ point count that matches the category count)
12
+
13
+ Exit codes: 0 pass, 1 fail, 2 unreadable input.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import sys
20
+ import zipfile
21
+ from decimal import Decimal, InvalidOperation
22
+ from pathlib import Path
23
+ from xml.etree import ElementTree
24
+
25
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
26
+
27
+ C_NS = "{http://schemas.openxmlformats.org/drawingml/2006/chart}"
28
+ R_NS = "{http://schemas.openxmlformats.org/package/2006/relationships}"
29
+
30
+
31
+ def _embedded_workbooks(archive: zipfile.ZipFile, chart_part: str) -> list[str]:
32
+ folder, _, name = chart_part.rpartition("/")
33
+ rels = f"{folder}/_rels/{name}.rels"
34
+ if rels not in archive.namelist():
35
+ return []
36
+ try:
37
+ root = ElementTree.fromstring(archive.read(rels))
38
+ except ElementTree.ParseError:
39
+ return []
40
+ found: list[str] = []
41
+ for relationship in root.findall(f"{R_NS}Relationship"):
42
+ target = relationship.get("Target", "")
43
+ if target.endswith(".xlsx") or target.endswith(".xlsb"):
44
+ resolved = target.lstrip("/") if target.startswith("/") else f"{folder}/{target}"
45
+ parts: list[str] = []
46
+ for segment in resolved.split("/"):
47
+ if segment in {"", "."}:
48
+ continue
49
+ if segment == "..":
50
+ if parts:
51
+ parts.pop()
52
+ continue
53
+ parts.append(segment)
54
+ found.append("/".join(parts))
55
+ return found
56
+
57
+
58
+ def _series_report(chart_xml: bytes) -> list[dict]:
59
+ root = ElementTree.fromstring(chart_xml)
60
+ report: list[dict] = []
61
+ for series in root.iter(f"{C_NS}ser"):
62
+ name_node = series.find(f"{C_NS}tx/{C_NS}strRef/{C_NS}strCache/{C_NS}pt/{C_NS}v")
63
+ if name_node is None:
64
+ name_node = series.find(f"{C_NS}tx/{C_NS}v")
65
+ categories = [node.text for node in series.findall(f"{C_NS}cat//{C_NS}pt/{C_NS}v")]
66
+ points = [node.text for node in series.findall(f"{C_NS}val//{C_NS}pt/{C_NS}v")]
67
+ if not points:
68
+ # some producers store values as c:v children of c:numCache without pt wrappers
69
+ points = [node.text for node in series.findall(f"{C_NS}val//{C_NS}v")]
70
+ numeric = 0
71
+ for value in points:
72
+ if value is None:
73
+ continue
74
+ try:
75
+ Decimal(str(value))
76
+ numeric += 1
77
+ except InvalidOperation:
78
+ continue
79
+ report.append(
80
+ {
81
+ "name": (name_node.text if name_node is not None else "") or "",
82
+ "categories": len(categories),
83
+ "points": len(points),
84
+ "numeric_points": numeric,
85
+ }
86
+ )
87
+ return report
88
+
89
+
90
+ def inspect_charts(pptx_path: str | Path, declared_slides: list[int], mode: str = "portable") -> dict:
91
+ path = Path(pptx_path)
92
+ if not path.is_file():
93
+ return {"gate": "native-quantitative-chart", "passed": False, "findings": [{"kind": "missing_file", "detail": str(path)}]}
94
+
95
+ from pptx import Presentation
96
+
97
+ presentation = Presentation(str(path))
98
+ checks: list[dict] = []
99
+ findings: list[dict] = []
100
+ with zipfile.ZipFile(path) as archive:
101
+ names = set(archive.namelist())
102
+ for slide_no, slide in enumerate(presentation.slides, start=1):
103
+ if slide_no not in declared_slides:
104
+ continue
105
+ for shape in slide.shapes:
106
+ if not getattr(shape, "has_chart", False):
107
+ continue
108
+ chart_part = str(shape.chart.part.partname).lstrip("/")
109
+ if chart_part not in names:
110
+ findings.append({"kind": "chart_part_missing", "severity": "fail", "slide": slide_no, "part": chart_part})
111
+ continue
112
+ payload = archive.read(chart_part)
113
+ workbooks = [item for item in _embedded_workbooks(archive, chart_part) if item in names]
114
+ series = _series_report(payload)
115
+ problems: list[str] = []
116
+ if not series:
117
+ problems.append("the chart declares no series")
118
+ for entry in series:
119
+ if entry["categories"] == 0:
120
+ problems.append(f"series '{entry['name'] or '(unnamed)'}' has no categories")
121
+ if entry["points"] == 0:
122
+ problems.append(f"series '{entry['name'] or '(unnamed)'}' has no values")
123
+ elif entry["numeric_points"] != entry["points"]:
124
+ problems.append(
125
+ f"series '{entry['name'] or '(unnamed)'}' has {entry['points'] - entry['numeric_points']} non-numeric value(s)"
126
+ )
127
+ elif entry["categories"] and entry["points"] and entry["categories"] != entry["points"]:
128
+ problems.append(
129
+ f"series '{entry['name'] or '(unnamed)'}' has {entry['categories']} categories but {entry['points']} values"
130
+ )
131
+ has_cache = bool(series) and all(entry["points"] > 0 for entry in series)
132
+ if mode == "portable" and not workbooks:
133
+ problems.append("no embedded workbook: the chart would not be editable after export")
134
+ if mode == "powerpoint" and not has_cache and not workbooks:
135
+ problems.append("neither an embedded workbook nor a complete literal cache")
136
+ checks.append(
137
+ {
138
+ "slide": slide_no,
139
+ "chart": shape.name,
140
+ "part": chart_part,
141
+ "mode": mode,
142
+ "embedded_workbooks": workbooks,
143
+ "series": series,
144
+ "problems": problems,
145
+ }
146
+ )
147
+ for problem in problems:
148
+ findings.append(
149
+ {
150
+ "kind": "quantitative_chart",
151
+ "severity": "fail",
152
+ "slide": slide_no,
153
+ "chart": shape.name,
154
+ "message": problem,
155
+ }
156
+ )
157
+
158
+ return {
159
+ "gate": "native-quantitative-chart",
160
+ "presentation": str(path.resolve()),
161
+ "mode": mode,
162
+ "declared_slides": declared_slides,
163
+ "checks": checks,
164
+ "findings": findings,
165
+ "passed": not [item for item in findings if item["severity"] == "fail"],
166
+ }
167
+
168
+
169
+ def main(argv: list[str] | None = None) -> int:
170
+ import argparse
171
+
172
+ parser = argparse.ArgumentParser(description="Require real, editable data behind charts on declared slides.")
173
+ parser.add_argument("presentation", nargs="?", help="path to the .pptx")
174
+ parser.add_argument("--input", dest="input_path", help="path to the .pptx (same as the positional)")
175
+ parser.add_argument("--slide", type=int, action="append", default=[], help="1-based slide to govern (repeatable)")
176
+ parser.add_argument("--all-slides", action="store_true", help="govern every slide that holds a chart")
177
+ parser.add_argument("--mode", choices=("portable", "powerpoint"), default="portable", help="acceptance mode (default portable)")
178
+ parser.add_argument("--json-out", help="also write the report here")
179
+ args = parser.parse_args(argv)
180
+ path = args.input_path or args.presentation
181
+ if not path:
182
+ parser.error("a .pptx path is required (positional or --input)")
183
+
184
+ declared = list(args.slide)
185
+ if args.all_slides or not declared:
186
+ from pptx import Presentation
187
+
188
+ presentation = Presentation(path)
189
+ declared = [index for index, slide in enumerate(presentation.slides, start=1) if any(getattr(shape, "has_chart", False) for shape in slide.shapes)]
190
+ report = inspect_charts(path, declared, args.mode)
191
+ if args.json_out:
192
+ Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
193
+ json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
194
+ sys.stdout.write("\n")
195
+ return 0 if report["passed"] else 1
196
+
197
+
198
+ if __name__ == "__main__":
199
+ raise SystemExit(main())
@@ -0,0 +1,212 @@
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
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
28
+
29
+ TOTAL_PATTERN = re.compile(r"\btotal\b", re.IGNORECASE)
30
+ SUBTOTAL_PATTERN = re.compile(r"\bsub[\s-]?total\b", re.IGNORECASE)
31
+ NON_ADDITIVE = re.compile(r"(unit\s*price|per\s*share|ratio|rate|%|percent|margin|average|avg|median)", re.IGNORECASE)
32
+ NUMBER = re.compile(r"^[+-]?[0-9][0-9,]*(\.[0-9]+)?$")
33
+
34
+
35
+ def _to_decimal(text: str) -> Decimal | None:
36
+ cleaned = text.strip().replace(",", "").replace("\u2212", "-")
37
+ if cleaned.startswith("(") and cleaned.endswith(")"):
38
+ cleaned = "-" + cleaned[1:-1]
39
+ cleaned = cleaned.replace("$", "").replace("\u20ac", "").replace("\u00a3", "").replace("%", "").strip()
40
+ if not NUMBER.match(cleaned):
41
+ return None
42
+ try:
43
+ return Decimal(cleaned)
44
+ except InvalidOperation:
45
+ return None
46
+
47
+
48
+ def _decimals(text: str) -> int:
49
+ cleaned = text.strip().replace(",", "")
50
+ return len(cleaned.split(".")[1]) if "." in cleaned else 0
51
+
52
+
53
+ def _tables(slide) -> list:
54
+ return [shape for shape in slide.shapes if getattr(shape, "has_table", False)]
55
+
56
+
57
+ def check(presentation_path: str | Path, contracts: list[dict], row_base: int = 0) -> dict:
58
+ path = Path(presentation_path)
59
+ if not path.is_file():
60
+ return {"gate": "table-arithmetic", "passed": False, "findings": [{"kind": "missing_file", "detail": str(path)}]}
61
+
62
+ from pptx import Presentation
63
+
64
+ presentation = Presentation(str(path))
65
+ slides = list(presentation.slides)
66
+ findings: list[dict] = []
67
+ checks: list[dict] = []
68
+ declared: set[tuple[int, int]] = set()
69
+
70
+ for contract in contracts:
71
+ slide_no = int(contract["slide"])
72
+ table_index = int(contract.get("table", 1)) - 1
73
+ base = int(contract.get("row_base", row_base))
74
+ if not 1 <= slide_no <= len(slides):
75
+ findings.append({"kind": "bad_contract", "severity": "fail", "message": f"slide {slide_no} is out of range"})
76
+ continue
77
+ tables = _tables(slides[slide_no - 1])
78
+ if not 1 <= table_index + 1 <= len(tables):
79
+ findings.append({"kind": "bad_contract", "severity": "fail", "slide": slide_no, "message": f"table {table_index + 1} not found"})
80
+ continue
81
+ table = tables[table_index].table
82
+ declared.add((slide_no, table_index))
83
+ total_row = int(contract["total_row"]) - 1 if base == 1 else int(contract["total_row"])
84
+ rows = list(table.rows)
85
+ if not 0 <= total_row < len(rows):
86
+ findings.append({"kind": "bad_contract", "severity": "fail", "slide": slide_no, "message": f"total row {total_row} is out of range"})
87
+ continue
88
+ tolerance = Decimal(str(contract.get("tolerance", "0.01")))
89
+ columns = [int(item) for item in contract.get("columns", range(1, len(table.columns)))]
90
+ header = [cell.text for cell in rows[0].cells] if rows else []
91
+
92
+ for column in columns:
93
+ if not 0 <= column < len(table.columns):
94
+ findings.append({"kind": "bad_contract", "severity": "fail", "slide": slide_no, "message": f"column {column} is out of range"})
95
+ continue
96
+ header_text = header[column] if column < len(header) else ""
97
+ if NON_ADDITIVE.search(header_text):
98
+ checks.append({"slide": slide_no, "column": column, "header": header_text, "status": "skipped_non_additive"})
99
+ continue
100
+ displayed_text = rows[total_row].cells[column].text
101
+ displayed = _to_decimal(displayed_text)
102
+ if displayed is None:
103
+ findings.append(
104
+ {
105
+ "kind": "unparseable_total",
106
+ "severity": "fail",
107
+ "slide": slide_no,
108
+ "column": column,
109
+ "header": header_text,
110
+ "displayed": displayed_text,
111
+ "message": "the declared total cell is not a number this gate can read",
112
+ }
113
+ )
114
+ continue
115
+ addends: list[Decimal] = []
116
+ for row in rows[1:total_row]:
117
+ value = _to_decimal(row.cells[column].text)
118
+ if value is not None:
119
+ addends.append(value)
120
+ computed = sum(addends, Decimal(0))
121
+ unit = Decimal(1).scaleb(-max([_decimals(displayed_text)] + [_decimals(rows[index].cells[column].text) for index in range(1, total_row)] or [0]))
122
+ delta = abs(computed - displayed)
123
+ rounding_slack = max(tolerance, unit / 2)
124
+ passed = delta <= rounding_slack
125
+ checks.append(
126
+ {
127
+ "slide": slide_no,
128
+ "column": column,
129
+ "header": header_text,
130
+ "status": "pass" if passed else "mismatch",
131
+ "displayed": str(displayed),
132
+ "computed": str(computed),
133
+ "delta": str(delta),
134
+ "rounding_slack": str(rounding_slack),
135
+ "addends": [str(item) for item in addends],
136
+ }
137
+ )
138
+ if not passed:
139
+ findings.append(
140
+ {
141
+ "kind": "total_mismatch",
142
+ "severity": "fail",
143
+ "slide": slide_no,
144
+ "column": column,
145
+ "header": header_text,
146
+ "displayed": str(displayed),
147
+ "computed": str(computed),
148
+ "message": f"column '{header_text or column}' totals {computed} but displays {displayed}",
149
+ }
150
+ )
151
+
152
+ # diagnostics only: never fail on a row we were not told to enforce
153
+ for slide_no, slide in enumerate(slides, start=1):
154
+ for table_index, shape in enumerate(_tables(slide)):
155
+ if (slide_no, table_index) in declared:
156
+ continue
157
+ rows = list(shape.table.rows)
158
+ for row_index, row in enumerate(rows):
159
+ first = row.cells[0].text.strip()
160
+ if TOTAL_PATTERN.search(first) or SUBTOTAL_PATTERN.search(first):
161
+ findings.append(
162
+ {
163
+ "kind": "auto_detected_total",
164
+ "severity": "diagnostic",
165
+ "slide": slide_no,
166
+ "row": row_index,
167
+ "label": first,
168
+ "message": "total-looking row is not declared in a contract, so it was not enforced",
169
+ }
170
+ )
171
+
172
+ return {
173
+ "gate": "table-arithmetic",
174
+ "presentation": str(path.resolve()),
175
+ "contracts": len(contracts),
176
+ "checks": checks,
177
+ "findings": findings,
178
+ "passed": not [item for item in findings if item["severity"] == "fail"],
179
+ }
180
+
181
+
182
+ def main(argv: list[str] | None = None) -> int:
183
+ import argparse
184
+
185
+ parser = argparse.ArgumentParser(description="Verify declared table totals against their columns.")
186
+ parser.add_argument("presentation", nargs="?", help="path to the .pptx")
187
+ parser.add_argument("--input", dest="input_path", help="path to the .pptx (same as the positional)")
188
+ parser.add_argument("--contracts", required=True, help="JSON file listing the totals to enforce")
189
+ parser.add_argument("--row-base", type=int, choices=(0, 1), default=0, help="0-based row indices (default) or 1-based")
190
+ parser.add_argument("--json-out", help="also write the report here")
191
+ args = parser.parse_args(argv)
192
+ path = args.input_path or args.presentation
193
+ if not path:
194
+ parser.error("a .pptx path is required (positional or --input)")
195
+
196
+ try:
197
+ payload = json.loads(Path(args.contracts).read_text(encoding="utf-8"))
198
+ except Exception as exc: # noqa: BLE001 - any parse problem is the caller's contract
199
+ json.dump({"gate": "table-arithmetic", "passed": False, "error": {"code": "bad_contracts", "message": str(exc)}}, sys.stdout)
200
+ sys.stdout.write("\n")
201
+ return 2
202
+ contracts = payload.get("contracts", payload if isinstance(payload, list) else [])
203
+ report = check(path, contracts, args.row_base)
204
+ if args.json_out:
205
+ Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
206
+ json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
207
+ sys.stdout.write("\n")
208
+ return 0 if report["passed"] else 1
209
+
210
+
211
+ if __name__ == "__main__":
212
+ raise SystemExit(main())