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,175 @@
1
+ """Replace text inside runs of an existing deck.
2
+
3
+ Run-level by design: a match only counts when the whole `find` string lives in a
4
+ single run. Text split across runs by earlier formatting is *not* merged, which
5
+ keeps formatting intact and makes the outcome predictable — the tool reports
6
+ what it matched, and reports what it could not match.
7
+
8
+ Usage
9
+ -----
10
+ <python> replace_text.py --input deck.pptx --find "Q4 2025" --replace "Q4 2026"
11
+ <python> replace_text.py --input deck.pptx --spec replacements.json --out fixed.pptx
12
+
13
+ Exit codes
14
+ ----------
15
+ 0 every requested pattern was applied at least once
16
+ 1 a pattern matched nothing (the file is still written; check the report)
17
+ 2 bad arguments, unreadable input or invalid spec
18
+ 3 the output file could not be written
19
+
20
+ The JSON report on stdout is `{ok, input, output, total, slides[], unmatched[]}`.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
30
+ for _stream in (sys.stdout, sys.stderr):
31
+ try:
32
+ _stream.reconfigure(encoding="utf-8", errors="replace")
33
+ except (AttributeError, ValueError):
34
+ pass
35
+
36
+
37
+ def _emit(payload: dict, code: int = 0) -> int:
38
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
39
+ return code
40
+
41
+
42
+ def _load_spec(path: str | None) -> list[dict]:
43
+ if not path:
44
+ return []
45
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
46
+ if isinstance(data, dict):
47
+ entries = data.get("replacements") or []
48
+ elif isinstance(data, list):
49
+ entries = data
50
+ else:
51
+ raise ValueError("spec must be an object with 'replacements' or a list")
52
+ resolved = []
53
+ for entry in entries:
54
+ if not isinstance(entry, dict) or "find" not in entry:
55
+ raise ValueError(f"replacement needs a 'find' key: {entry!r}")
56
+ resolved.append(
57
+ {
58
+ "find": str(entry["find"]),
59
+ "replace": str(entry.get("replace", "")),
60
+ "slide": entry.get("slide"),
61
+ "match_case": bool(entry.get("match_case", True)),
62
+ }
63
+ )
64
+ return resolved
65
+
66
+
67
+ def _frames(slide):
68
+ for shape in slide.shapes:
69
+ if shape.has_text_frame:
70
+ yield shape.text_frame
71
+ if getattr(shape, "has_table", False) and shape.has_table:
72
+ for row in shape.table.rows:
73
+ for cell in row.cells:
74
+ yield cell.text_frame
75
+
76
+
77
+ def replace_in_slide(slide, rules: list[dict]) -> list[dict]:
78
+ hits = []
79
+ for frame in _frames(slide):
80
+ for paragraph in frame.paragraphs:
81
+ for run in paragraph.runs:
82
+ original = run.text
83
+ updated = original
84
+ for rule in rules:
85
+ needle = rule["find"] if rule["match_case"] else rule["find"].lower()
86
+ haystack = updated if rule["match_case"] else updated.lower()
87
+ if needle and needle in haystack:
88
+ updated = updated.replace(rule["find"], rule["replace"])
89
+ hits.append({"find": rule["find"], "before": original, "after": updated})
90
+ if updated != original:
91
+ run.text = updated
92
+ return hits
93
+
94
+
95
+ def main() -> int:
96
+ parser = argparse.ArgumentParser(
97
+ description="Replace text inside runs of a .pptx (never across runs).",
98
+ epilog="Exit codes: 0 all patterns applied, 1 a pattern matched nothing, 2 bad input, 3 write failure.",
99
+ )
100
+ parser.add_argument("--input", required=True, help="source deck")
101
+ parser.add_argument("--find", action="append", default=[], help="text to find (repeatable)")
102
+ parser.add_argument("--replace", action="append", default=[], help="replacement (repeatable, pairs with --find)")
103
+ parser.add_argument("--spec", help="JSON file: {replacements:[{find, replace, slide?, match_case?}]}")
104
+ parser.add_argument("--out", help="output deck (default: <input>-edited.pptx)")
105
+ args = parser.parse_args()
106
+
107
+ rules: list[dict] = []
108
+ try:
109
+ for index, needle in enumerate(args.find):
110
+ rules.append(
111
+ {
112
+ "find": needle,
113
+ "replace": args.replace[index] if index < len(args.replace) else "",
114
+ "slide": None,
115
+ "match_case": True,
116
+ }
117
+ )
118
+ rules.extend(_load_spec(args.spec))
119
+ except (OSError, ValueError, json.JSONDecodeError) as error:
120
+ return _emit({"ok": False, "error": f"bad spec: {error}"}, 2)
121
+
122
+ if not rules:
123
+ return _emit({"ok": False, "error": "nothing to replace: pass --find/--replace or --spec"}, 2)
124
+
125
+ source = Path(args.input)
126
+ if not source.is_file():
127
+ return _emit({"ok": False, "error": f"no such file: {source}"}, 2)
128
+ output = Path(args.out) if args.out else source.with_name(f"{source.stem}-edited{source.suffix}")
129
+
130
+ try:
131
+ from pptx import Presentation
132
+ except ImportError as error:
133
+ return _emit({"ok": False, "error": f"python-pptx is required: {error}"}, 2)
134
+
135
+ try:
136
+ prs = Presentation(str(source))
137
+ except Exception as error: # noqa: BLE001 - report, never traceback at the user
138
+ return _emit({"ok": False, "error": f"cannot open {source}: {error}"}, 2)
139
+
140
+ slides_report = []
141
+ total = 0
142
+ matched_patterns: set[str] = set()
143
+ for index, slide in enumerate(prs.slides, start=1):
144
+ applicable = [rule for rule in rules if rule["slide"] in (None, index)]
145
+ hits = replace_in_slide(slide, applicable)
146
+ for hit in hits:
147
+ matched_patterns.add(hit["find"])
148
+ total += len(hits)
149
+ if hits:
150
+ slides_report.append({"slide": index, "replacements": len(hits), "details": hits[:10]})
151
+
152
+ unmatched = sorted({rule["find"] for rule in rules} - matched_patterns)
153
+
154
+ try:
155
+ output.parent.mkdir(parents=True, exist_ok=True)
156
+ prs.save(str(output))
157
+ except Exception as error: # noqa: BLE001
158
+ return _emit({"ok": False, "error": f"cannot write {output}: {error}"}, 3)
159
+
160
+ report = {
161
+ "schema": "replace_text/1",
162
+ "ok": not unmatched,
163
+ "input": str(source),
164
+ "output": str(output),
165
+ "rules": len(rules),
166
+ "total": total,
167
+ "slides": slides_report,
168
+ "unmatched": unmatched,
169
+ "note": "matches only inside a single run; text split by formatting is left alone",
170
+ }
171
+ return _emit(report, 0 if not unmatched else 1)
172
+
173
+
174
+ if __name__ == "__main__":
175
+ sys.exit(main())
@@ -0,0 +1,236 @@
1
+ """Compare a finished deck against the template it claims to follow.
2
+
3
+ Purpose: "it looks similar" is not fidelity. This gate takes explicit
4
+ requirements — which source slides must be represented, how close the slide
5
+ size and placeholder geometry must be, whether a photographic background must
6
+ survive — and answers yes or no. No requirements supplied means nothing to
7
+ enforce, so the gate passes neutrally instead of inventing a standard.
8
+
9
+ Requirements file:
10
+ {
11
+ "reference_slide_indices": [1, 3, 4],
12
+ "mapping": {"1": 1, "2": 3, "3": 4},
13
+ "minimum_coverage_ratio": 1.0,
14
+ "require_exact_dimensions": true,
15
+ "require_placeholder_geometry": true,
16
+ "require_photographic_background": false,
17
+ "geometry_tolerance_emu": 9144
18
+ }
19
+
20
+ Exit codes: 0 pass, 1 fail, 2 unreadable input.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
30
+
31
+ from ooxml_helpers import box, is_picture, slide_size_in # noqa: E402
32
+
33
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
34
+ for _stream in (sys.stdout, sys.stderr):
35
+ try:
36
+ _stream.reconfigure(encoding="utf-8", errors="replace")
37
+ except (AttributeError, ValueError):
38
+ pass
39
+
40
+ EMU_PER_INCH = 914400
41
+ DEFAULT_TOLERANCE_EMU = 9144 # 0.01 in
42
+
43
+
44
+ def _placeholder_map(slide) -> dict[tuple[int, str], tuple[float, float, float, float]]:
45
+ entries: dict[tuple[int, str], tuple[float, float, float, float]] = {}
46
+ for shape in slide.shapes:
47
+ if not shape.is_placeholder:
48
+ continue
49
+ geometry = box(shape)
50
+ if geometry is None:
51
+ continue
52
+ entries[(shape.placeholder_format.idx, shape.name)] = geometry
53
+ return entries
54
+
55
+
56
+ def _background_picture(slide, slide_w: float, slide_h: float) -> bool:
57
+ for shape in slide.shapes:
58
+ if not is_picture(shape):
59
+ continue
60
+ geometry = box(shape)
61
+ if geometry is None:
62
+ continue
63
+ left, top, width, height = geometry
64
+ if left <= 0.02 and top <= 0.02 and width >= slide_w * 0.98 and height >= slide_h * 0.98:
65
+ return True
66
+ return False
67
+
68
+
69
+ def check_fidelity(template_path: str | Path, final_path: str | Path, requirements: dict | None) -> dict:
70
+ from pptx import Presentation
71
+
72
+ requirements = requirements or {}
73
+ template_file = Path(template_path)
74
+ final_file = Path(final_path)
75
+ for candidate in (template_file, final_file):
76
+ if not candidate.is_file():
77
+ return {"gate": "template-source-fidelity", "passed": False, "findings": [{"kind": "missing_file", "detail": str(candidate)}]}
78
+
79
+ if not requirements:
80
+ return {
81
+ "gate": "template-source-fidelity",
82
+ "passed": True,
83
+ "neutral": True,
84
+ "note": "no fidelity requirements were supplied, so nothing was enforced",
85
+ "findings": [],
86
+ }
87
+
88
+ template = Presentation(str(template_file))
89
+ final = Presentation(str(final_file))
90
+ template_w, template_h = slide_size_in(template)
91
+ final_w, final_h = slide_size_in(final)
92
+ tolerance_in = float(requirements.get("geometry_tolerance_emu", DEFAULT_TOLERANCE_EMU)) / EMU_PER_INCH
93
+ findings: list[dict] = []
94
+ checks: list[dict] = []
95
+
96
+ if requirements.get("require_exact_dimensions", True):
97
+ same = abs(template_w - final_w) <= tolerance_in and abs(template_h - final_h) <= tolerance_in
98
+ checks.append({"check": "dimensions", "passed": same, "template": [template_w, template_h], "final": [final_w, final_h]})
99
+ if not same:
100
+ findings.append(
101
+ {
102
+ "kind": "dimension_mismatch",
103
+ "severity": "fail",
104
+ "message": f"final deck is {final_w:.2f}x{final_h:.2f} in, template is {template_w:.2f}x{template_h:.2f} in",
105
+ }
106
+ )
107
+
108
+ template_slides = list(template.slides)
109
+ final_slides = list(final.slides)
110
+ mapping = requirements.get("mapping") or {}
111
+ pairs: list[tuple[int, int]] = []
112
+ for output_index, source_index in mapping.items():
113
+ pairs.append((int(output_index), int(source_index)))
114
+ if not pairs:
115
+ pairs = [(index, index) for index in range(1, min(len(template_slides), len(final_slides)) + 1)]
116
+
117
+ matched = 0
118
+ for output_index, source_index in pairs:
119
+ if not (1 <= output_index <= len(final_slides) and 1 <= source_index <= len(template_slides)):
120
+ findings.append(
121
+ {
122
+ "kind": "mapping_out_of_range",
123
+ "severity": "fail",
124
+ "message": f"mapping {output_index}->{source_index} is outside the deck",
125
+ }
126
+ )
127
+ continue
128
+ source_slide = template_slides[source_index - 1]
129
+ output_slide = final_slides[output_index - 1]
130
+ slide_pass = True
131
+
132
+ if source_slide.slide_layout.name != output_slide.slide_layout.name:
133
+ slide_pass = False
134
+ findings.append(
135
+ {
136
+ "kind": "layout_changed",
137
+ "severity": "fail",
138
+ "slide": output_index,
139
+ "sourceSlide": source_index,
140
+ "message": f"layout went from '{source_slide.slide_layout.name}' to '{output_slide.slide_layout.name}'",
141
+ }
142
+ )
143
+
144
+ if requirements.get("require_placeholder_geometry", True):
145
+ source_placeholders = _placeholder_map(source_slide)
146
+ output_placeholders = _placeholder_map(output_slide)
147
+ for key, source_box in source_placeholders.items():
148
+ output_box = output_placeholders.get(key)
149
+ if output_box is None:
150
+ continue
151
+ drift = max(abs(a - b) for a, b in zip(source_box, output_box))
152
+ if drift > tolerance_in:
153
+ slide_pass = False
154
+ findings.append(
155
+ {
156
+ "kind": "placeholder_geometry_drift",
157
+ "severity": "fail",
158
+ "slide": output_index,
159
+ "placeholder": key[1],
160
+ "driftIn": round(drift, 3),
161
+ "message": f"placeholder '{key[1]}' moved {drift:.2f} in from the template position",
162
+ }
163
+ )
164
+
165
+ if requirements.get("require_photographic_background"):
166
+ if _background_picture(source_slide, template_w, template_h) and not _background_picture(output_slide, final_w, final_h):
167
+ slide_pass = False
168
+ findings.append(
169
+ {
170
+ "kind": "background_lost",
171
+ "severity": "fail",
172
+ "slide": output_index,
173
+ "sourceSlide": source_index,
174
+ "message": "the template slide carries a full-bleed background image; the output does not",
175
+ }
176
+ )
177
+
178
+ checks.append({"check": "slide", "outputSlide": output_index, "sourceSlide": source_index, "passed": slide_pass})
179
+ matched += 1 if slide_pass else 0
180
+
181
+ coverage = matched / len(pairs) if pairs else 0.0
182
+ minimum = float(requirements.get("minimum_coverage_ratio", 1.0))
183
+ checks.append({"check": "coverage", "ratio": round(coverage, 4), "minimum": minimum, "passed": coverage + 1e-9 >= minimum})
184
+ if coverage + 1e-9 < minimum:
185
+ findings.append(
186
+ {
187
+ "kind": "coverage_below_minimum",
188
+ "severity": "fail",
189
+ "message": f"{coverage:.0%} of mapped slides preserved the template, minimum is {minimum:.0%}",
190
+ }
191
+ )
192
+
193
+ reference = requirements.get("reference_slide_indices") or []
194
+ if reference and not pairs:
195
+ findings.append({"kind": "no_usable_mapping", "severity": "fail", "message": f"reference slides {reference} could not be mapped to output slides"})
196
+
197
+ return {
198
+ "gate": "template-source-fidelity",
199
+ "template": str(template_file.resolve()),
200
+ "final": str(final_file.resolve()),
201
+ "requirements": requirements,
202
+ "checks": checks,
203
+ "findings": findings,
204
+ "coverage": round(coverage, 4),
205
+ "passed": not [item for item in findings if item["severity"] == "fail"],
206
+ }
207
+
208
+
209
+ def main(argv: list[str] | None = None) -> int:
210
+ import argparse
211
+
212
+ parser = argparse.ArgumentParser(description="Verify that a deck preserved the template it was built from.")
213
+ parser.add_argument("--template", required=True, help="the source template .pptx")
214
+ parser.add_argument("--final", required=True, help="the finished .pptx")
215
+ parser.add_argument("--requirements", help="JSON file with the fidelity requirements; omit for a neutral pass")
216
+ parser.add_argument("--json-out", help="also write the report here")
217
+ args = parser.parse_args(argv)
218
+
219
+ requirements = None
220
+ if args.requirements:
221
+ try:
222
+ requirements = json.loads(Path(args.requirements).read_text(encoding="utf-8"))
223
+ except Exception as exc: # noqa: BLE001
224
+ json.dump({"gate": "template-source-fidelity", "passed": False, "error": {"code": "bad_requirements", "message": str(exc)}}, sys.stdout)
225
+ sys.stdout.write("\n")
226
+ return 2
227
+ report = check_fidelity(args.template, args.final, requirements)
228
+ if args.json_out:
229
+ Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
230
+ json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
231
+ sys.stdout.write("\n")
232
+ return 0 if report["passed"] else 1
233
+
234
+
235
+ if __name__ == "__main__":
236
+ raise SystemExit(main())
@@ -0,0 +1,66 @@
1
+ # Finance decks
2
+
3
+ The mechanics are the same as any deck; the failure modes are specific.
4
+
5
+ ## What a finance audience checks first
6
+
7
+ 1. Does the total add up? A slide whose numbers do not sum loses the argument
8
+ before the argument starts. Run the arithmetic gate on every declared total.
9
+ 2. Is the period stated? "Revenue up 12%" without a base period is not a claim.
10
+ 3. Is the unit stated? Thousands, millions, currency, and whether it is gross or
11
+ net. Put it in the header, once.
12
+ 4. Is the comparison like-for-like? Full quarter against full quarter; do not
13
+ put a 13-week quarter beside a 12-week one without saying so.
14
+ 5. Does the chart axis start at zero? For bars, yes, always — a truncated bar
15
+ chart in a finance deck reads as manipulation even when it is not.
16
+
17
+ ## The financial slide kit
18
+
19
+ | Slide | Content | Note |
20
+ |---|---|---|
21
+ | Headline | One number, one claim, one source | The number the meeting is about |
22
+ | Bridge | Waterfall from last period to this one | Native chart; label the middle bars, not the ends |
23
+ | Results table | Period rows, metric columns, total row | Totals computed, not typed |
24
+ | Variance | Actual vs plan vs prior, and why | The "why" is a sentence, not a column |
25
+ | Outlook | Ranges, not points | A single forecast point implies precision you do not have |
26
+ | Ask | The decision, the amount, the date | Last slide, one line |
27
+
28
+ ## Numbers on slides
29
+
30
+ - Two significant figures is usually enough for a headline (`£2.4m`, not
31
+ `£2,384,117`). Keep full precision in the appendix table.
32
+ - Percentages: one decimal at most, and say what they are a percentage *of*.
33
+ - Round consistently: if the headline says 2.4m, the table should not say
34
+ 2.38m in one row and 2.4m in the next.
35
+ - Negative numbers: use a minus sign consistently (`−12%`) and keep
36
+ parentheses for tables where accounting convention is expected.
37
+ - A variance is meaningless without its sign and its direction; state whether
38
+ favourable is up or down (revenue vs cost).
39
+
40
+ ## Charts that survive a CFO
41
+
42
+ - Waterfall for a bridge, column for periods, line for a trend, never pie for
43
+ more than three slices.
44
+ - Label the axis unit once, in the caption: "£m, FY2026 actuals".
45
+ - One series per chart unless the comparison *is* the point.
46
+ - Do not chart a table of six numbers; the table is more honest and faster to
47
+ read.
48
+ - Forecast periods are visually distinct (lighter fill, dashed line) and named
49
+ in the caption.
50
+
51
+ ## Provenance
52
+
53
+ Every non-obvious number gets a note with: source file or system, extraction
54
+ date, period covered, and any adjustment made. The speaker notes are the right
55
+ place; a footnote-heavy slide is a slide nobody reads.
56
+
57
+ ## Check before delivery
58
+
59
+ ```bash
60
+ "<python>" "<skill>/container_tools/native_table_arithmetic_gate.py" --input deck.pptx --contracts totals.json
61
+ "<python>" "<skill>/container_tools/native_quantitative_chart_gate.py" --input deck.pptx --all-slides
62
+ "<python>" "<skill>/container_tools/inspect_presentation_layout_geometry.py" --input deck.pptx
63
+ ```
64
+
65
+ Then read the slides as a sceptic: which number would you challenge first, and
66
+ does the deck already answer it?
@@ -0,0 +1,71 @@
1
+ # Slide mechanics and paste-runnable recipes
2
+
3
+ The helper CLI covers the common path: a small deck with a title, bullet slides,
4
+ a table, a chart and a picture. Everything else — precise coordinates, master
5
+ edits, notes, theme tokens, connectors — is ordinary `python-pptx` work, and
6
+ these recipes are the honest route.
7
+
8
+ Read the mechanics first, then paste the recipe. Every snippet runs as-is from a
9
+ shell, from any working directory:
10
+
11
+ ```bash
12
+ "<python>" - <<'PY'
13
+ from pptx import Presentation
14
+ ...
15
+ PY
16
+ ```
17
+
18
+ ## The four numbers that decide every layout
19
+
20
+ | Question | Answer |
21
+ |---|---|
22
+ | Where is this shape? | `left`, `top`, `width`, `height` in **EMU** (English Metric Units). |
23
+ | What is an inch? | `914400` EMU. `pptx.util.Inches(1)` is the polite wrapper. |
24
+ | What is a point? | `12700` EMU. Font sizes use points, geometry uses EMU, and mixing them silently breaks layout. |
25
+ | What is a slide? | `prs.slide_width` / `prs.slide_height`; check them, never assume 16:9. |
26
+
27
+ Helper conversions worth memorising: `Emu(914400) == Inches(1)`,
28
+ `Pt(28) == Emu(355600)`, and a fraction of the slide is
29
+ `int(0.1 * prs.slide_width)`.
30
+
31
+ ## Z-order
32
+
33
+ Shapes paint in the order they live in `slide.shapes`. A shape added later
34
+ covers one added earlier. To reorder without deleting:
35
+
36
+ ```python
37
+ tree = slide.shapes._spTree
38
+ shape = slide.shapes[3]
39
+ tree.remove(shape._element)
40
+ tree.insert(2, shape._element) # after nvGrpSpPr and grpSpPr
41
+ ```
42
+
43
+ Index `2` is the first painted shape; that is where a full-bleed background
44
+ image belongs.
45
+
46
+ ## What this pack ships
47
+
48
+ | Recipe | Use it when |
49
+ |---|---|
50
+ | [textbox-at-coordinate](textbox-at-coordinate.md) | Text must land on an exact rect, not "somewhere under the title". |
51
+ | [autolayout](autolayout.md) | The same build has to fit 3 bullets or 9 without hand-tuning. |
52
+ | [tables](tables.md) | Numbers need a grid with column widths and per-cell formatting. |
53
+ | [native-charts](native-charts.md) | Data must stay editable, not become a picture of a chart. |
54
+ | [master-and-layouts](master-and-layouts.md) | The whole deck changes at once: title bar, accent line, footer. |
55
+ | [speaker-notes](speaker-notes.md) | The presenter needs a script attached to each slide. |
56
+ | [theme-tokens](theme-tokens.md) | Colours and fonts must come from one place. |
57
+ | [rich-text-runs](rich-text-runs.md) | One paragraph mixes bold, accent colour and a footnote. |
58
+ | [images](images.md) | A picture needs a crop, a cover fit or a real alt text. |
59
+ | [connectors](connectors.md) | Two boxes are joined by a line that must survive a move. |
60
+
61
+ ## Order of work that keeps decks out of trouble
62
+
63
+ 1. Read the target geometry off the template or the user's reference
64
+ (`container_tools/inspect_presentation_layout_geometry.py` prints it as JSON).
65
+ 2. Place shapes in EMU, top-down: background, chrome, title, body, evidence.
66
+ 3. Set type sizes by the *smallest* screen the deck will be shown on; body 18 pt
67
+ and up, footnotes 12 pt and up.
68
+ 4. Fill every placeholder you keep and delete the ones you do not — an empty
69
+ placeholder prints "Click to add text" in edit view and looks unfinished.
70
+ 5. Render, look, then fix. `container_tools/render_slides.py` plus `read_image`
71
+ is the loop; a deck nobody looked at is not verified.
@@ -0,0 +1,88 @@
1
+ # Auto-layout rules
2
+
3
+ ## Mechanics
4
+
5
+ PowerPoint has two different "autofit" ideas and mixing them up wastes a round
6
+ trip:
7
+
8
+ - `word_wrap` decides whether text wraps inside the width. Turn it on for every
9
+ authored box.
10
+ - `auto_size` decides whether the *frame* follows the text (`MSO_AUTO_SIZE.
11
+ SHAPE_TO_FIT_TEXT`) or the text shrinks to fit the frame
12
+ (`TEXT_TO_FIT_SHAPE`). `TEXT_TO_FIT_SHAPE` is a **render-time** behaviour:
13
+ PowerPoint recalculates it when the file is opened, so a deck that looks fine
14
+ in a draft preview can still shrink to 8 pt on a real screen.
15
+
16
+ The reliable pattern is: fixed frame, explicit sizes, content measured against
17
+ the frame, no text-to-fit. Estimate, then verify by rendering.
18
+
19
+ ## Recipe: fit a variable number of bullets
20
+
21
+ ```python
22
+ "<python>" - <<'PY'
23
+ from pptx import Presentation
24
+ from pptx.util import Inches, Pt
25
+ from pptx.enum.text import MSO_ANCHOR
26
+
27
+ BULLETS = [
28
+ "Picking is 41% of floor labour hours",
29
+ "Travel between aisles adds 12 minutes per order",
30
+ "Manual cycle counts stop the line twice a week",
31
+ "Two cells idle below 60% utilisation",
32
+ "Overtime covers the Friday peak",
33
+ ]
34
+
35
+ def fits(text: str, width_in: float, height_in: float, size_pt: float) -> bool:
36
+ per_line = max(1, int(width_in * 96 / (0.55 * size_pt)))
37
+ lines = max(1, -(-len(text) // per_line))
38
+ return lines * 1.2 * size_pt / 72 <= height_in
39
+
40
+ prs = Presentation()
41
+ prs.slide_width, prs.slide_height = Inches(13.333), Inches(7.5)
42
+ slide = prs.slides.add_slide(prs.slide_layouts[6])
43
+
44
+ width, height = 8.4, 4.6 # the body rect you reserved
45
+ box = slide.shapes.add_textbox(Inches(0.9), Inches(1.6), Inches(width), Inches(height))
46
+ frame = box.text_frame
47
+ frame.word_wrap = True
48
+ frame.vertical_anchor = MSO_ANCHOR.TOP
49
+
50
+ size = next(pt for pt in (24, 22, 20, 18) if fits(" ".join(BULLETS), width, height, pt))
51
+ needed = sum(
52
+ max(1, -(-len(item) // max(1, int(width * 96 / (0.55 * size))))) * 1.2 * size / 72
53
+ for item in BULLETS
54
+ )
55
+ for index, item in enumerate(BULLETS):
56
+ paragraph = frame.paragraphs[0] if index == 0 else frame.add_paragraph()
57
+ paragraph.text = item
58
+ paragraph.font.size = Pt(size)
59
+ paragraph.space_after = Pt(10)
60
+
61
+ prs.save("autofit.pptx")
62
+ print(f"size={size}pt lines_estimate_in={needed:.2f} frame_in={height}")
63
+ PY
64
+ ```
65
+
66
+ ## Rules that hold regardless of content
67
+
68
+ 1. Body text is 18 pt or larger; below that the back row cannot read it.
69
+ 2. Six bullets or three lines of body is the point where a slide should split.
70
+ 3. Longer text shrinks the number of *words*, not the type size. If the fit loop
71
+ lands below the body floor, cut copy or split the slide.
72
+ 4. Keep 0.5 in of bottom margin for the footer and for projectors that crop.
73
+ 5. Two columns beat one narrow column: a 4.2 in column of 18 pt text is a wall.
74
+
75
+ ## Pitfalls this avoids
76
+
77
+ - `frame.text` then `paragraph.font.size` on paragraph 0 only: a multi-paragraph
78
+ assignment leaves paragraphs 1..n at the inherited 18 pt.
79
+ - Text-to-fit hiding the overflow: the file opens fine and the audience reads
80
+ 9 pt. Measure, do not delegate the fit to the renderer.
81
+ - A frame height that exactly equals the text height: one extra word on edit and
82
+ the block runs into the evidence below. Reserve 10% slack.
83
+
84
+ ## CLI fast path
85
+
86
+ `pptx create` with `layout: "bullets"` fills the layout's body placeholder at
87
+ its own size. Use the loop above when the number of bullets is not known before
88
+ the content is written.