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,127 @@
1
+ """Read or set the document properties of a deck.
2
+
3
+ Title, author, subject, keywords and comments live in `docProps/core.xml`. They
4
+ show up in file listings, in the presenter's title bar and in search, which is
5
+ why a deck titled "PowerPoint Presentation" is a small embarrassment.
6
+
7
+ Passing no property flag prints the current values and changes nothing.
8
+
9
+ Usage
10
+ -----
11
+ <python> core_properties.py --input deck.pptx
12
+ <python> core_properties.py --input deck.pptx --title "Automation Review" --author "Ops"
13
+ <python> core_properties.py --input deck.pptx --keywords "warehouse, automation" --out tagged.pptx
14
+
15
+ Exit codes
16
+ ----------
17
+ 0 properties read or written
18
+ 2 bad arguments or unreadable input
19
+ 3 the output file could not be written
20
+
21
+ The JSON report on stdout is `{ok, input, output, before{}, after{}, changed[]}`.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import sys
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+
31
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
32
+ for _stream in (sys.stdout, sys.stderr):
33
+ try:
34
+ _stream.reconfigure(encoding="utf-8", errors="replace")
35
+ except (AttributeError, ValueError):
36
+ pass
37
+
38
+ FIELDS = ("title", "author", "subject", "keywords", "comments", "category", "last_modified_by")
39
+
40
+
41
+ def _emit(payload: dict, code: int = 0) -> int:
42
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
43
+ return code
44
+
45
+
46
+ def _snapshot(properties) -> dict:
47
+ return {field: getattr(properties, field, None) for field in FIELDS}
48
+
49
+
50
+ def main() -> int:
51
+ parser = argparse.ArgumentParser(
52
+ description="Read or set .pptx core properties (title, author, subject, keywords, comments).",
53
+ epilog="Exit codes: 0 ok, 2 bad input, 3 write failure.",
54
+ )
55
+ parser.add_argument("--input", required=True, help="source deck")
56
+ parser.add_argument("--title")
57
+ parser.add_argument("--author")
58
+ parser.add_argument("--subject")
59
+ parser.add_argument("--keywords")
60
+ parser.add_argument("--comments")
61
+ parser.add_argument("--category")
62
+ parser.add_argument("--last-modified-by")
63
+ parser.add_argument("--out", help="output deck (default: <input>-edited.pptx when writing)")
64
+ args = parser.parse_args()
65
+
66
+ source = Path(args.input)
67
+ if not source.is_file():
68
+ return _emit({"ok": False, "error": f"no such file: {source}"}, 2)
69
+
70
+ from pptx import Presentation # noqa: PLC0415
71
+
72
+ try:
73
+ prs = Presentation(str(source))
74
+ except Exception as error: # noqa: BLE001
75
+ return _emit({"ok": False, "error": f"cannot open {source}: {error}"}, 2)
76
+
77
+ before = _snapshot(prs.core_properties)
78
+ updates = {
79
+ field: (getattr(args, field) if getattr(args, field) is not None else None)
80
+ for field in FIELDS
81
+ if field != "last_modified_by"
82
+ }
83
+ if args.last_modified_by is not None:
84
+ updates["last_modified_by"] = args.last_modified_by
85
+ requested = {key: value for key, value in updates.items() if value is not None}
86
+
87
+ if not requested:
88
+ return _emit(
89
+ {
90
+ "schema": "core_properties/1",
91
+ "ok": True,
92
+ "input": str(source),
93
+ "output": None,
94
+ "before": before,
95
+ "after": before,
96
+ "changed": [],
97
+ "note": "read-only: no property flag was passed",
98
+ }
99
+ )
100
+
101
+ for field, value in requested.items():
102
+ setattr(prs.core_properties, field, value)
103
+ prs.core_properties.modified = datetime.now(timezone.utc).replace(tzinfo=None)
104
+ after = _snapshot(prs.core_properties)
105
+
106
+ output = Path(args.out) if args.out else source.with_name(f"{source.stem}-edited{source.suffix}")
107
+ try:
108
+ output.parent.mkdir(parents=True, exist_ok=True)
109
+ prs.save(str(output))
110
+ except Exception as error: # noqa: BLE001
111
+ return _emit({"ok": False, "error": f"cannot write {output}: {error}"}, 3)
112
+
113
+ return _emit(
114
+ {
115
+ "schema": "core_properties/1",
116
+ "ok": True,
117
+ "input": str(source),
118
+ "output": str(output),
119
+ "before": before,
120
+ "after": after,
121
+ "changed": [key for key, value in requested.items() if before.get(key) != value],
122
+ }
123
+ )
124
+
125
+
126
+ if __name__ == "__main__":
127
+ sys.exit(main())
@@ -0,0 +1,133 @@
1
+ """Assemble slide PNGs into one labelled contact sheet.
2
+
3
+ Purpose: eyeballing twenty renders one file at a time wastes context; a single
4
+ grid image lets you spot collisions, empty slides and stray template text at a
5
+ glance, then zoom into the slide that looks wrong.
6
+
7
+ Exit codes: 0 sheet written, 2 no readable input, 3 Pillow unavailable.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
17
+ for _stream in (sys.stdout, sys.stderr):
18
+ try:
19
+ _stream.reconfigure(encoding="utf-8", errors="replace")
20
+ except (AttributeError, ValueError):
21
+ pass
22
+
23
+ try:
24
+ from PIL import Image, ImageDraw, ImageFont
25
+ except ImportError: # pragma: no cover - whitelist dependency, but fail clearly
26
+ print(json.dumps({"ok": False, "error": {"code": "pillow_missing", "message": "Pillow is required"}}))
27
+ raise SystemExit(3)
28
+
29
+
30
+ def _collect(paths: list[str], directory: str | None) -> list[Path]:
31
+ found: list[Path] = [Path(item) for item in paths]
32
+ if directory:
33
+ found.extend(sorted(Path(directory).glob("*.png")))
34
+ ordered: list[Path] = []
35
+ seen: set[str] = set()
36
+ for path in found:
37
+ key = str(path.resolve())
38
+ if key in seen or not path.is_file() or path.name in {"contact-sheet.png", "montage.png"}:
39
+ continue
40
+ seen.add(key)
41
+ ordered.append(path)
42
+ return ordered
43
+
44
+
45
+ def _label(draw, box, text: str, size: int, colour) -> None:
46
+ try:
47
+ font = ImageFont.truetype("arial.ttf", size)
48
+ except OSError:
49
+ try:
50
+ font = ImageFont.load_default(size=size)
51
+ except TypeError: # very old Pillow
52
+ font = ImageFont.load_default()
53
+ draw.text((box[0], box[1]), text, fill=colour, font=font)
54
+
55
+
56
+ def make_montage(
57
+ paths: list[str],
58
+ output: str | Path,
59
+ directory: str | None = None,
60
+ columns: int = 3,
61
+ cell_width: int = 480,
62
+ padding: int = 16,
63
+ background: tuple[int, int, int] = (245, 246, 248),
64
+ ) -> dict:
65
+ images = _collect(paths, directory)
66
+ if not images:
67
+ return {"ok": False, "error": {"code": "no_images", "message": "no PNG inputs found"}}
68
+
69
+ loaded: list[tuple[Path, Image.Image]] = []
70
+ for path in images:
71
+ try:
72
+ with Image.open(path) as handle:
73
+ loaded.append((path, handle.convert("RGB")))
74
+ except Exception as exc: # noqa: BLE001 - one bad file must not kill the sheet
75
+ loaded.append((path, Image.new("RGB", (cell_width, int(cell_width * 0.5625)), (220, 60, 60))))
76
+ columns = max(1, min(columns, len(loaded)))
77
+ cell_height = max(1, int(round(cell_width * loaded[0][1].height / max(loaded[0][1].width, 1))))
78
+ rows = (len(loaded) + columns - 1) // columns
79
+ label_height = 22
80
+ sheet = Image.new(
81
+ "RGB",
82
+ (
83
+ columns * cell_width + (columns + 1) * padding,
84
+ rows * (cell_height + label_height) + (rows + 1) * padding,
85
+ ),
86
+ background,
87
+ )
88
+ draw = ImageDraw.Draw(sheet)
89
+ for index, (path, image) in enumerate(loaded):
90
+ column = index % columns
91
+ row = index // columns
92
+ x = padding + column * (cell_width + padding)
93
+ y = padding + row * (cell_height + label_height + padding)
94
+ draw.rectangle([x - 1, y - 1, x + cell_width, y + cell_height], outline=(205, 210, 218))
95
+ thumb = image.copy()
96
+ thumb.thumbnail((cell_width, cell_height), Image.LANCZOS)
97
+ sheet.paste(thumb, (x + (cell_width - thumb.width) // 2, y + (cell_height - thumb.height) // 2))
98
+ _label(draw, (x, y + cell_height + 4), path.stem, 13, (70, 76, 86))
99
+
100
+ target = Path(output)
101
+ target.parent.mkdir(parents=True, exist_ok=True)
102
+ sheet.save(target)
103
+ return {
104
+ "ok": True,
105
+ "output": str(target.resolve()),
106
+ "cells": len(loaded),
107
+ "columns": columns,
108
+ "size": list(sheet.size),
109
+ "cells_in_order": [path.name for path, _ in loaded],
110
+ }
111
+
112
+
113
+ def main(argv: list[str] | None = None) -> int:
114
+ import argparse
115
+
116
+ parser = argparse.ArgumentParser(description="Combine slide PNGs into one labelled grid image.")
117
+ parser.add_argument("images", nargs="*", help="PNG files (order is preserved)")
118
+ parser.add_argument("--dir", help="also include every .png in this directory, sorted")
119
+ parser.add_argument("--out", required=True, help="path of the contact sheet to write")
120
+ parser.add_argument("--columns", type=int, default=3, help="grid columns (default 3)")
121
+ parser.add_argument("--cell-width", type=int, default=480, help="width of one cell in pixels (default 480)")
122
+ args = parser.parse_args(argv)
123
+
124
+ report = make_montage(args.images, args.out, args.dir, args.columns, args.cell_width)
125
+ json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
126
+ sys.stdout.write("\n")
127
+ if report.get("ok"):
128
+ return 0
129
+ return 2
130
+
131
+
132
+ if __name__ == "__main__":
133
+ raise SystemExit(main())
@@ -0,0 +1,292 @@
1
+ """Inspect slide geometry for the mistakes a render catches late.
2
+
3
+ Purpose: before spending a round trip on a render, read the shapes and answer
4
+ the questions that actually break a deck — does anything leave the slide, is
5
+ text dropped out of its own box, do two blocks of text sit on top of each
6
+ other, is a chart buried under a table, is the smallest type too small, is an
7
+ opens-in-place placeholder still empty, does a heading end in a full stop.
8
+
9
+ Severity split (ported behaviour): `fail` findings are structural and block
10
+ delivery; `review` findings are judgement calls the author should look at.
11
+
12
+ Exit codes: 0 no blocking finding, 1 blocking finding, 2 unreadable input.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
22
+
23
+ from ooxml_helpers import ( # noqa: E402
24
+ box,
25
+ emit,
26
+ default_size_pt,
27
+ estimated_text_height_in,
28
+ finding,
29
+ has_visible_text,
30
+ is_chart,
31
+ is_connector,
32
+ is_picture,
33
+ is_table,
34
+ iter_shapes,
35
+ label,
36
+ open_presentation,
37
+ overlap_ratio,
38
+ placeholder_kind,
39
+ rects_intersect,
40
+ run_size_pt,
41
+ slide_size_in,
42
+ text_of,
43
+ )
44
+
45
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
46
+ for _stream in (sys.stdout, sys.stderr):
47
+ try:
48
+ _stream.reconfigure(encoding="utf-8", errors="replace")
49
+ except (AttributeError, ValueError):
50
+ pass
51
+
52
+ TITLE_OUTSIDE_MARGIN_IN = 0.0
53
+ DEFAULT_TITLE_FLOOR_PT = 28.0
54
+ DEFAULT_BODY_FLOOR_PT = 14.0
55
+ OVERLAP_HINT_RATIO = 0.35
56
+ TEXT_OVERFLOW_SLACK_IN = 0.08
57
+
58
+
59
+ def inspect(pptx_path: str | Path, title_floor: float = DEFAULT_TITLE_FLOOR_PT, body_floor: float = DEFAULT_BODY_FLOOR_PT) -> dict:
60
+ presentation = open_presentation(pptx_path)
61
+ slide_w, slide_h = slide_size_in(presentation)
62
+ findings: list[dict] = []
63
+ slides_report: list[dict] = []
64
+ title_sizes: list[tuple[int, int]] = []
65
+
66
+ for slide_no, slide in enumerate(presentation.slides, start=1):
67
+ shapes = [shape for shape in iter_shapes(slide.shapes)]
68
+ slide_findings: list[dict] = []
69
+ text_boxes: list[tuple[str, tuple[float, float, float, float], str]] = []
70
+
71
+ for shape in shapes:
72
+ geometry = box(shape)
73
+ name = label(shape)
74
+ if geometry is None:
75
+ slide_findings.append(finding("shape_without_frame", "review", f"'{name}' has no position or size", shape=name))
76
+ continue
77
+ left, top, width, height = geometry
78
+
79
+ if left < -TITLE_OUTSIDE_MARGIN_IN or top < -TITLE_OUTSIDE_MARGIN_IN or left + width > slide_w + 0.01 or top + height > slide_h + 0.01:
80
+ slide_findings.append(
81
+ finding(
82
+ "outside_slide",
83
+ "fail",
84
+ f"'{name}' extends past the slide edge",
85
+ shape=name,
86
+ bbox=[round(left, 2), round(top, 2), round(width, 2), round(height, 2)],
87
+ bleed={
88
+ "right": round(max(0.0, left + width - slide_w), 2),
89
+ "bottom": round(max(0.0, top + height - slide_h), 2),
90
+ "left": round(max(0.0, -left), 2),
91
+ "top": round(max(0.0, -top), 2),
92
+ },
93
+ )
94
+ )
95
+
96
+ if width <= 0 or height <= 0:
97
+ slide_findings.append(finding("degenerate_size", "fail", f"'{name}' has zero width or height", shape=name))
98
+
99
+ needed = estimated_text_height_in(shape)
100
+ if needed is not None and needed > height + TEXT_OVERFLOW_SLACK_IN:
101
+ slide_findings.append(
102
+ finding(
103
+ "text_overflow",
104
+ "fail",
105
+ f"'{name}' needs about {needed:.2f} in of text height but its frame is {height:.2f} in",
106
+ shape=name,
107
+ neededIn=round(needed, 2),
108
+ frameIn=round(height, 2),
109
+ )
110
+ )
111
+
112
+ kind = (placeholder_kind(shape) or "").upper()
113
+ if kind and not has_visible_text(shape) and not is_picture(shape):
114
+ slide_findings.append(finding("empty_placeholder", "review", f"empty {kind} placeholder is still on the slide", shape=name))
115
+
116
+ if has_visible_text(shape):
117
+ text_boxes.append((name, geometry, text_of(shape)))
118
+ size = run_size_pt(shape) or 0.0
119
+ if "TITLE" in kind or name.lower().startswith("title"):
120
+ if 0 < size < title_floor:
121
+ slide_findings.append(finding("small_title", "review", f"title '{name}' is {size:.0f} pt", shape=name, fontPt=size))
122
+ if text_of(shape).rstrip().endswith("."):
123
+ slide_findings.append(finding("heading_full_stop", "review", f"heading '{name}' ends with a full stop", shape=name))
124
+ elif 0 < size < body_floor:
125
+ slide_findings.append(finding("small_body_text", "review", f"'{name}' is {size:.0f} pt", shape=name, fontPt=size))
126
+
127
+ if is_table(shape):
128
+ table_bottom = top + height
129
+ if table_bottom > slide_h:
130
+ slide_findings.append(finding("table_outside_slide", "fail", f"table '{name}' runs {table_bottom - slide_h:.2f} in past the bottom edge", shape=name))
131
+
132
+ # text block collisions
133
+ for first in range(len(text_boxes)):
134
+ for second in range(first + 1, len(text_boxes)):
135
+ name_a, box_a, text_a = text_boxes[first]
136
+ name_b, box_b, text_b = text_boxes[second]
137
+ if not rects_intersect(box_a, box_b):
138
+ continue
139
+ ratio = overlap_ratio(box_a, box_b)
140
+ if ratio >= OVERLAP_HINT_RATIO:
141
+ slide_findings.append(
142
+ finding(
143
+ "text_overlap",
144
+ "review",
145
+ f"'{name_a}' and '{name_b}' overlap by {ratio * 100:.0f}% of the smaller box",
146
+ shapes=[name_a, name_b],
147
+ overlap=round(ratio, 2),
148
+ excerpt=[text_a[:60], text_b[:60]],
149
+ )
150
+ )
151
+
152
+ # a chart or table hidden under a picture/other block
153
+ heavy = [(label(shape), box(shape)) for shape in shapes if (is_chart(shape) or is_table(shape)) and box(shape)]
154
+ for name, geometry in heavy:
155
+ for other in shapes:
156
+ if label(other) == name:
157
+ continue
158
+ other_box = box(other)
159
+ if other_box is None or is_connector(other) or (is_chart(other) or is_table(other)):
160
+ continue
161
+ if not has_visible_text(other) and not is_picture(other):
162
+ continue
163
+ ratio = overlap_ratio(geometry, other_box)
164
+ if ratio >= 0.5:
165
+ slide_findings.append(
166
+ finding(
167
+ "evidence_covered",
168
+ "fail",
169
+ f"'{label(other)}' covers {ratio * 100:.0f}% of '{name}'",
170
+ shapes=[name, label(other)],
171
+ overlap=round(ratio, 2),
172
+ )
173
+ )
174
+
175
+ # connectors crossing text
176
+ for shape in shapes:
177
+ if not is_connector(shape) or box(shape) is None:
178
+ continue
179
+ for name, text_box, _ in text_boxes:
180
+ if rects_intersect(box(shape), text_box) and overlap_ratio(box(shape), text_box) > 0.15:
181
+ slide_findings.append(finding("connector_over_text", "review", f"connector '{label(shape)}' crosses '{name}'", shapes=[label(shape), name]))
182
+
183
+ # chart and table repeating the same numbers on one slide
184
+ if any(is_chart(shape) for shape in shapes) and any(is_table(shape) for shape in shapes):
185
+ chart_labels = {
186
+ str(category).strip()
187
+ for shape in shapes
188
+ if is_chart(shape)
189
+ for category in _chart_categories(shape)
190
+ }
191
+ table_labels = {
192
+ str(cell.text).strip()
193
+ for shape in shapes
194
+ if is_table(shape)
195
+ for row in shape.table.rows
196
+ for cell in row.cells
197
+ }
198
+ shared = {value for value in chart_labels & table_labels if value and not value.replace(".", "").isdigit()}
199
+ if len(shared) >= 2:
200
+ slide_findings.append(
201
+ finding(
202
+ "duplicate_evidence",
203
+ "review",
204
+ "the chart and the table restate the same categories",
205
+ shared=sorted(shared)[:6],
206
+ )
207
+ )
208
+
209
+ findings.extend({**item, "slide": slide_no} for item in slide_findings)
210
+ slides_report.append(
211
+ {
212
+ "slide": slide_no,
213
+ "shapes": len(shapes),
214
+ "charts": sum(1 for shape in shapes if is_chart(shape)),
215
+ "tables": sum(1 for shape in shapes if is_table(shape)),
216
+ "pictures": sum(1 for shape in shapes if is_picture(shape)),
217
+ "findings": len(slide_findings),
218
+ }
219
+ )
220
+
221
+ for shape in shapes:
222
+ kind = (placeholder_kind(shape) or "").upper()
223
+ # A cover is meant to run larger than the body slides, so it is excluded.
224
+ if "CENTER_TITLE" in kind or not has_visible_text(shape):
225
+ continue
226
+ if "TITLE" in kind or label(shape).lower().startswith("title"):
227
+ # Inherited sizes only resolve at render time; approximate them with the
228
+ # pack's placeholder defaults so drift between slides is still visible.
229
+ size = run_size_pt(shape) or default_size_pt(shape)
230
+ if size:
231
+ title_sizes.append((slide_no, round(size)))
232
+
233
+ if len({size for _, size in title_sizes}) > 1:
234
+ findings.append(
235
+ finding(
236
+ "inconsistent_title_size",
237
+ "review",
238
+ "slide titles are set at different sizes; pick one size for the deck",
239
+ sizes=sorted({size for _, size in title_sizes}),
240
+ slides=[slide_no for slide_no, _ in title_sizes],
241
+ )
242
+ )
243
+ blocking = [item for item in findings if item["severity"] == "fail"]
244
+ return {
245
+ "gate": "layout-geometry",
246
+ "presentation": str(Path(pptx_path).resolve()),
247
+ "slideSizeIn": [round(slide_w, 3), round(slide_h, 3)],
248
+ "slides": slides_report,
249
+ "findings": findings,
250
+ "summary": {
251
+ "slides": len(slides_report),
252
+ "fail": len(blocking),
253
+ "review": len(findings) - len(blocking),
254
+ },
255
+ "passed": not blocking,
256
+ }
257
+
258
+
259
+ def _chart_categories(shape) -> list[str]:
260
+ try:
261
+ return [str(category) for category in shape.chart.plots[0].categories]
262
+ except Exception: # noqa: BLE001 - charts without a cache simply contribute nothing
263
+ return []
264
+
265
+
266
+ def main(argv: list[str] | None = None) -> int:
267
+ import argparse
268
+
269
+ parser = argparse.ArgumentParser(description="Report slide-geometry problems (out-of-bounds shapes, text overflow, overlaps).")
270
+ parser.add_argument("presentation", nargs="?", help="path to the .pptx")
271
+ parser.add_argument("--input", dest="input_path", help="path to the .pptx (same as the positional)")
272
+ parser.add_argument("--title-floor", type=float, default=DEFAULT_TITLE_FLOOR_PT, help="minimum title size in pt (default 28)")
273
+ parser.add_argument("--body-floor", type=float, default=DEFAULT_BODY_FLOOR_PT, help="minimum body size in pt (default 14)")
274
+ parser.add_argument("--strict", action="store_true", help="treat review findings as blocking too")
275
+ parser.add_argument("--json-out", help="also write the report here")
276
+ args = parser.parse_args(argv)
277
+ path = args.input_path or args.presentation
278
+ if not path:
279
+ parser.error("a .pptx path is required (positional or --input)")
280
+
281
+ report = inspect(path, args.title_floor, args.body_floor)
282
+ if args.strict:
283
+ report["strict"] = True
284
+ report["passed"] = not report["findings"]
285
+ if args.json_out:
286
+ Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
287
+ emit(report)
288
+ return 0 if report["passed"] else 1
289
+
290
+
291
+ if __name__ == "__main__":
292
+ raise SystemExit(main())