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,164 @@
1
+ """Check that a .pptx is a self-consistent package, not just a readable file.
2
+
3
+ Purpose: python-pptx opens many broken decks happily. A half-written edit can
4
+ leave a relationship pointing at a part that is not in the archive, a chart
5
+ whose embedded workbook vanished, or a slide whose layout was never shipped.
6
+ This walks the archive and reports what is actually there.
7
+
8
+ Exit codes: 0 healthy, 1 problems found, 2 unreadable input.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+ import zipfile
16
+ from pathlib import Path
17
+ from xml.etree import ElementTree
18
+
19
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
20
+ for _stream in (sys.stdout, sys.stderr):
21
+ try:
22
+ _stream.reconfigure(encoding="utf-8", errors="replace")
23
+ except (AttributeError, ValueError):
24
+ pass
25
+
26
+ REL_NS = "{http://schemas.openxmlformats.org/package/2006/relationships}"
27
+ CT_NS = "{http://schemas.openxmlformats.org/package/2006/content-types}"
28
+ P_NS = "{http://schemas.openxmlformats.org/presentationml/2006/main}"
29
+ R_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
30
+
31
+
32
+ def _rels_targets(archive: zipfile.ZipFile, part: str) -> list[str]:
33
+ folder, _, name = part.rpartition("/")
34
+ rels_name = f"{folder}/_rels/{name}.rels" if folder else f"_rels/{name}.rels"
35
+ if rels_name not in archive.namelist():
36
+ return []
37
+ try:
38
+ root = ElementTree.fromstring(archive.read(rels_name))
39
+ except ElementTree.ParseError:
40
+ return []
41
+ targets: list[str] = []
42
+ for relationship in root.findall(f"{REL_NS}Relationship"):
43
+ if relationship.get("TargetMode") == "External":
44
+ continue
45
+ target = relationship.get("Target", "")
46
+ if target.startswith("/"):
47
+ resolved = target.lstrip("/")
48
+ else:
49
+ resolved = f"{folder}/{target}" if folder else target
50
+ parts: list[str] = []
51
+ for segment in resolved.split("/"):
52
+ if segment in {"", "."}:
53
+ continue
54
+ if segment == "..":
55
+ if parts:
56
+ parts.pop()
57
+ continue
58
+ parts.append(segment)
59
+ targets.append("/".join(parts))
60
+ return targets
61
+
62
+
63
+ def inspect_package(pptx_path: str | Path) -> dict:
64
+ path = Path(pptx_path)
65
+ problems: list[dict] = []
66
+ if not path.is_file():
67
+ return {"gate": "package-integrity", "passed": False, "problems": [{"kind": "missing_file", "detail": str(path)}]}
68
+ if not zipfile.is_zipfile(path):
69
+ return {"gate": "package-integrity", "passed": False, "problems": [{"kind": "not_a_zip", "detail": "not a readable OOXML archive"}]}
70
+
71
+ with zipfile.ZipFile(path) as archive:
72
+ names = set(archive.namelist())
73
+ for required in ("[Content_Types].xml", "_rels/.rels", "ppt/presentation.xml"):
74
+ if required not in names:
75
+ problems.append({"kind": "missing_part", "part": required})
76
+
77
+ slides = sorted(name for name in names if name.startswith("ppt/slides/slide") and name.endswith(".xml"))
78
+ layouts = sorted(name for name in names if name.startswith("ppt/slideLayouts/slideLayout") and name.endswith(".xml"))
79
+ masters = sorted(name for name in names if name.startswith("ppt/slideMasters/slideMaster") and name.endswith(".xml"))
80
+ charts = sorted(name for name in names if name.startswith("ppt/charts/chart") and name.endswith(".xml"))
81
+ media = sorted(name for name in names if name.startswith("ppt/media/"))
82
+ embeddings = sorted(name for name in names if name.startswith("ppt/embeddings/"))
83
+
84
+ if not slides:
85
+ problems.append({"kind": "no_slides", "detail": "the package has no ppt/slides parts"})
86
+ if not masters:
87
+ problems.append({"kind": "no_master", "detail": "the package has no slide master"})
88
+
89
+ for part in slides + charts + layouts + masters:
90
+ for target in _rels_targets(archive, part):
91
+ if target and target not in names:
92
+ problems.append({"kind": "dangling_relationship", "part": part, "target": target})
93
+
94
+ for part in slides:
95
+ try:
96
+ root = ElementTree.fromstring(archive.read(part))
97
+ except ElementTree.ParseError as exc:
98
+ problems.append({"kind": "unparsable_xml", "part": part, "detail": str(exc)})
99
+ continue
100
+ if root.tag != f"{P_NS}sld":
101
+ problems.append({"kind": "wrong_root_element", "part": part, "detail": root.tag})
102
+
103
+ presentation_xml = archive.read("ppt/presentation.xml") if "ppt/presentation.xml" in names else b""
104
+ declared_slides = 0
105
+ if presentation_xml:
106
+ try:
107
+ root = ElementTree.fromstring(presentation_xml)
108
+ slide_ids = root.find(f"{P_NS}sldIdLst")
109
+ declared_slides = len(list(slide_ids)) if slide_ids is not None else 0
110
+ except ElementTree.ParseError:
111
+ problems.append({"kind": "unparsable_xml", "part": "ppt/presentation.xml"})
112
+ if declared_slides and declared_slides != len(slides):
113
+ problems.append(
114
+ {
115
+ "kind": "slide_count_mismatch",
116
+ "detail": f"presentation.xml lists {declared_slides} slides but the package carries {len(slides)} parts",
117
+ }
118
+ )
119
+
120
+ duplicates = len(slides) - len({name.lower() for name in slides})
121
+ if duplicates:
122
+ problems.append({"kind": "duplicate_parts", "count": duplicates})
123
+
124
+ notes = [name for name in names if name.startswith("ppt/notesSlides/notesSlide") and name.endswith(".xml")]
125
+
126
+ return {
127
+ "gate": "package-integrity",
128
+ "presentation": str(path.resolve()),
129
+ "parts": {
130
+ "slides": len(slides),
131
+ "layouts": len(layouts),
132
+ "masters": len(masters),
133
+ "charts": len(charts),
134
+ "notesSlides": len(notes),
135
+ "media": len(media),
136
+ "embeddings": len(embeddings),
137
+ },
138
+ "problems": problems,
139
+ "passed": not problems,
140
+ }
141
+
142
+
143
+ def main(argv: list[str] | None = None) -> int:
144
+ import argparse
145
+
146
+ parser = argparse.ArgumentParser(description="Verify OOXML part/relationship integrity of a .pptx.")
147
+ parser.add_argument("presentation", nargs="?", help="path to the .pptx")
148
+ parser.add_argument("--input", dest="input_path", help="path to the .pptx (same as the positional)")
149
+ parser.add_argument("--json-out", help="also write the report here")
150
+ args = parser.parse_args(argv)
151
+ path = args.input_path or args.presentation
152
+ if not path:
153
+ parser.error("a .pptx path is required (positional or --input)")
154
+
155
+ report = inspect_package(path)
156
+ if args.json_out:
157
+ Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
158
+ json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
159
+ sys.stdout.write("\n")
160
+ return 0 if report["passed"] else 1
161
+
162
+
163
+ if __name__ == "__main__":
164
+ raise SystemExit(main())
@@ -0,0 +1,151 @@
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
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
24
+ for _stream in (sys.stdout, sys.stderr):
25
+ try:
26
+ _stream.reconfigure(encoding="utf-8", errors="replace")
27
+ except (AttributeError, ValueError):
28
+ pass
29
+
30
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
31
+
32
+ C_NS = "{http://schemas.openxmlformats.org/drawingml/2006/chart}"
33
+ A_NS = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
34
+ R_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
35
+
36
+ DEFAULT_TITLE_STRINGS = ("chart title", "chart 1 title")
37
+ BLOCKING = {"default_placeholder", "empty_title"}
38
+
39
+
40
+ def _chart_title_status(chart_xml: bytes) -> tuple[str, str]:
41
+ root = ElementTree.fromstring(chart_xml)
42
+ chart = root.find(f"{C_NS}chart")
43
+ if chart is None:
44
+ return "absent", ""
45
+ deleted = chart.find(f"{C_NS}autoTitleDeleted")
46
+ suppressed = deleted is not None and deleted.get("val", "1") in {"1", "true"}
47
+ title = chart.find(f"{C_NS}title")
48
+ if title is None:
49
+ return ("explicitly_suppressed", "") if suppressed else ("absent", "")
50
+
51
+ if title.find(f"{C_NS}tx/{C_NS}strRef") is not None:
52
+ return "linked_title_unresolved", ""
53
+ rich = title.find(f"{C_NS}tx/{C_NS}rich")
54
+ if rich is None:
55
+ return "empty_title", ""
56
+ pieces = [node.text or "" for node in rich.iter(f"{A_NS}t")]
57
+ text = "".join(pieces).strip()
58
+ if not text:
59
+ return "empty_title", ""
60
+ if " ".join(text.split()).lower() in DEFAULT_TITLE_STRINGS:
61
+ return "default_placeholder", text
62
+ return "present", text
63
+
64
+
65
+ def inspect_chart_titles(pptx_path: str | Path) -> dict:
66
+ path = Path(pptx_path)
67
+ if not path.is_file():
68
+ return {"gate": "native-chart-title", "passed": False, "findings": [{"kind": "missing_file", "detail": str(path)}]}
69
+
70
+ from pptx import Presentation
71
+
72
+ presentation = Presentation(str(path))
73
+ slides: list[dict] = []
74
+ checks: list[dict] = []
75
+ chart_parts: dict[str, bytes] = {}
76
+ with zipfile.ZipFile(path) as archive:
77
+ for name in archive.namelist():
78
+ if name.startswith("ppt/charts/") and name.endswith(".xml"):
79
+ chart_parts[name] = archive.read(name)
80
+
81
+ for slide_no, slide in enumerate(presentation.slides, start=1):
82
+ chart_count = 0
83
+ for shape in slide.shapes:
84
+ if not getattr(shape, "has_chart", False):
85
+ continue
86
+ chart_count += 1
87
+ part = shape.chart.part
88
+ part_name = str(part.partname).lstrip("/")
89
+ payload = chart_parts.get(part_name)
90
+ if payload is None:
91
+ checks.append({"slide": slide_no, "chart": shape.name, "part": part_name, "status": "part_missing", "title": ""})
92
+ continue
93
+ status, text = _chart_title_status(payload)
94
+ checks.append({"slide": slide_no, "chart": shape.name, "part": part_name, "status": status, "title": text})
95
+ slides.append({"slide": slide_no, "charts": chart_count})
96
+
97
+ unused = sorted(set(chart_parts) - {check["part"] for check in checks})
98
+ findings = [
99
+ {
100
+ "kind": "chart_title",
101
+ "severity": "fail",
102
+ "slide": check["slide"],
103
+ "chart": check["chart"],
104
+ "status": check["status"],
105
+ "message": (
106
+ "chart still shows the placeholder title"
107
+ if check["status"] == "default_placeholder"
108
+ else "chart has an empty title element"
109
+ ),
110
+ }
111
+ for check in checks
112
+ if check["status"] in BLOCKING
113
+ ]
114
+ for part in unused:
115
+ findings.append({"kind": "orphan_chart_part", "severity": "review", "part": part, "message": "chart part is not placed on any slide"})
116
+
117
+ return {
118
+ "gate": "native-chart-title",
119
+ "presentation": str(path.resolve()),
120
+ "slide_count": len(slides),
121
+ "native_chart_count": sum(item["charts"] for item in slides),
122
+ "checks": checks,
123
+ "findings": findings,
124
+ "native_powerpoint_verified": False,
125
+ "source_text_emitted": False,
126
+ "passed": not [item for item in findings if item["severity"] == "fail"],
127
+ }
128
+
129
+
130
+ def main(argv: list[str] | None = None) -> int:
131
+ import argparse
132
+
133
+ parser = argparse.ArgumentParser(description="Fail when a native chart keeps a placeholder or empty title.")
134
+ parser.add_argument("presentation", nargs="?", help="path to the .pptx")
135
+ parser.add_argument("--input", dest="input_path", help="path to the .pptx (same as the positional)")
136
+ parser.add_argument("--json-out", help="also write the report here")
137
+ args = parser.parse_args(argv)
138
+ path = args.input_path or args.presentation
139
+ if not path:
140
+ parser.error("a .pptx path is required (positional or --input)")
141
+
142
+ report = inspect_chart_titles(path)
143
+ if args.json_out:
144
+ Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
145
+ json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
146
+ sys.stdout.write("\n")
147
+ return 0 if report["passed"] else 1
148
+
149
+
150
+ if __name__ == "__main__":
151
+ raise SystemExit(main())
@@ -0,0 +1,206 @@
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
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
26
+ for _stream in (sys.stdout, sys.stderr):
27
+ try:
28
+ _stream.reconfigure(encoding="utf-8", errors="replace")
29
+ except (AttributeError, ValueError):
30
+ pass
31
+
32
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
33
+
34
+ C_NS = "{http://schemas.openxmlformats.org/drawingml/2006/chart}"
35
+ R_NS = "{http://schemas.openxmlformats.org/package/2006/relationships}"
36
+
37
+
38
+ def _embedded_workbooks(archive: zipfile.ZipFile, chart_part: str) -> list[str]:
39
+ folder, _, name = chart_part.rpartition("/")
40
+ rels = f"{folder}/_rels/{name}.rels"
41
+ if rels not in archive.namelist():
42
+ return []
43
+ try:
44
+ root = ElementTree.fromstring(archive.read(rels))
45
+ except ElementTree.ParseError:
46
+ return []
47
+ found: list[str] = []
48
+ for relationship in root.findall(f"{R_NS}Relationship"):
49
+ target = relationship.get("Target", "")
50
+ if target.endswith(".xlsx") or target.endswith(".xlsb"):
51
+ resolved = target.lstrip("/") if target.startswith("/") else f"{folder}/{target}"
52
+ parts: list[str] = []
53
+ for segment in resolved.split("/"):
54
+ if segment in {"", "."}:
55
+ continue
56
+ if segment == "..":
57
+ if parts:
58
+ parts.pop()
59
+ continue
60
+ parts.append(segment)
61
+ found.append("/".join(parts))
62
+ return found
63
+
64
+
65
+ def _series_report(chart_xml: bytes) -> list[dict]:
66
+ root = ElementTree.fromstring(chart_xml)
67
+ report: list[dict] = []
68
+ for series in root.iter(f"{C_NS}ser"):
69
+ name_node = series.find(f"{C_NS}tx/{C_NS}strRef/{C_NS}strCache/{C_NS}pt/{C_NS}v")
70
+ if name_node is None:
71
+ name_node = series.find(f"{C_NS}tx/{C_NS}v")
72
+ categories = [node.text for node in series.findall(f"{C_NS}cat//{C_NS}pt/{C_NS}v")]
73
+ points = [node.text for node in series.findall(f"{C_NS}val//{C_NS}pt/{C_NS}v")]
74
+ if not points:
75
+ # some producers store values as c:v children of c:numCache without pt wrappers
76
+ points = [node.text for node in series.findall(f"{C_NS}val//{C_NS}v")]
77
+ numeric = 0
78
+ for value in points:
79
+ if value is None:
80
+ continue
81
+ try:
82
+ Decimal(str(value))
83
+ numeric += 1
84
+ except InvalidOperation:
85
+ continue
86
+ report.append(
87
+ {
88
+ "name": (name_node.text if name_node is not None else "") or "",
89
+ "categories": len(categories),
90
+ "points": len(points),
91
+ "numeric_points": numeric,
92
+ }
93
+ )
94
+ return report
95
+
96
+
97
+ def inspect_charts(pptx_path: str | Path, declared_slides: list[int], mode: str = "portable") -> dict:
98
+ path = Path(pptx_path)
99
+ if not path.is_file():
100
+ return {"gate": "native-quantitative-chart", "passed": False, "findings": [{"kind": "missing_file", "detail": str(path)}]}
101
+
102
+ from pptx import Presentation
103
+
104
+ presentation = Presentation(str(path))
105
+ checks: list[dict] = []
106
+ findings: list[dict] = []
107
+ with zipfile.ZipFile(path) as archive:
108
+ names = set(archive.namelist())
109
+ for slide_no, slide in enumerate(presentation.slides, start=1):
110
+ if slide_no not in declared_slides:
111
+ continue
112
+ for shape in slide.shapes:
113
+ if not getattr(shape, "has_chart", False):
114
+ continue
115
+ chart_part = str(shape.chart.part.partname).lstrip("/")
116
+ if chart_part not in names:
117
+ findings.append({"kind": "chart_part_missing", "severity": "fail", "slide": slide_no, "part": chart_part})
118
+ continue
119
+ payload = archive.read(chart_part)
120
+ workbooks = [item for item in _embedded_workbooks(archive, chart_part) if item in names]
121
+ series = _series_report(payload)
122
+ problems: list[str] = []
123
+ if not series:
124
+ problems.append("the chart declares no series")
125
+ for entry in series:
126
+ if entry["categories"] == 0:
127
+ problems.append(f"series '{entry['name'] or '(unnamed)'}' has no categories")
128
+ if entry["points"] == 0:
129
+ problems.append(f"series '{entry['name'] or '(unnamed)'}' has no values")
130
+ elif entry["numeric_points"] != entry["points"]:
131
+ problems.append(
132
+ f"series '{entry['name'] or '(unnamed)'}' has {entry['points'] - entry['numeric_points']} non-numeric value(s)"
133
+ )
134
+ elif entry["categories"] and entry["points"] and entry["categories"] != entry["points"]:
135
+ problems.append(
136
+ f"series '{entry['name'] or '(unnamed)'}' has {entry['categories']} categories but {entry['points']} values"
137
+ )
138
+ has_cache = bool(series) and all(entry["points"] > 0 for entry in series)
139
+ if mode == "portable" and not workbooks:
140
+ problems.append("no embedded workbook: the chart would not be editable after export")
141
+ if mode == "powerpoint" and not has_cache and not workbooks:
142
+ problems.append("neither an embedded workbook nor a complete literal cache")
143
+ checks.append(
144
+ {
145
+ "slide": slide_no,
146
+ "chart": shape.name,
147
+ "part": chart_part,
148
+ "mode": mode,
149
+ "embedded_workbooks": workbooks,
150
+ "series": series,
151
+ "problems": problems,
152
+ }
153
+ )
154
+ for problem in problems:
155
+ findings.append(
156
+ {
157
+ "kind": "quantitative_chart",
158
+ "severity": "fail",
159
+ "slide": slide_no,
160
+ "chart": shape.name,
161
+ "message": problem,
162
+ }
163
+ )
164
+
165
+ return {
166
+ "gate": "native-quantitative-chart",
167
+ "presentation": str(path.resolve()),
168
+ "mode": mode,
169
+ "declared_slides": declared_slides,
170
+ "checks": checks,
171
+ "findings": findings,
172
+ "passed": not [item for item in findings if item["severity"] == "fail"],
173
+ }
174
+
175
+
176
+ def main(argv: list[str] | None = None) -> int:
177
+ import argparse
178
+
179
+ parser = argparse.ArgumentParser(description="Require real, editable data behind charts on declared slides.")
180
+ parser.add_argument("presentation", nargs="?", help="path to the .pptx")
181
+ parser.add_argument("--input", dest="input_path", help="path to the .pptx (same as the positional)")
182
+ parser.add_argument("--slide", type=int, action="append", default=[], help="1-based slide to govern (repeatable)")
183
+ parser.add_argument("--all-slides", action="store_true", help="govern every slide that holds a chart")
184
+ parser.add_argument("--mode", choices=("portable", "powerpoint"), default="portable", help="acceptance mode (default portable)")
185
+ parser.add_argument("--json-out", help="also write the report here")
186
+ args = parser.parse_args(argv)
187
+ path = args.input_path or args.presentation
188
+ if not path:
189
+ parser.error("a .pptx path is required (positional or --input)")
190
+
191
+ declared = list(args.slide)
192
+ if args.all_slides or not declared:
193
+ from pptx import Presentation
194
+
195
+ presentation = Presentation(path)
196
+ declared = [index for index, slide in enumerate(presentation.slides, start=1) if any(getattr(shape, "has_chart", False) for shape in slide.shapes)]
197
+ report = inspect_charts(path, declared, args.mode)
198
+ if args.json_out:
199
+ Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
200
+ json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
201
+ sys.stdout.write("\n")
202
+ return 0 if report["passed"] else 1
203
+
204
+
205
+ if __name__ == "__main__":
206
+ raise SystemExit(main())