dsh-office-kit 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +92 -0
  24. package/assets/skills/office-documents/scripts/a11y_audit.py +194 -0
  25. package/assets/skills/office-documents/scripts/accept_tracked_changes.py +151 -0
  26. package/assets/skills/office-documents/scripts/add_tracked_replacements.py +148 -0
  27. package/assets/skills/office-documents/scripts/append_blocks.py +108 -0
  28. package/assets/skills/office-documents/scripts/apply_template_styles.py +104 -0
  29. package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +102 -0
  30. package/assets/skills/office-documents/scripts/comments_add.py +136 -0
  31. package/assets/skills/office-documents/scripts/comments_apply_patch.py +114 -0
  32. package/assets/skills/office-documents/scripts/comments_extract.py +85 -0
  33. package/assets/skills/office-documents/scripts/comments_strip.py +74 -0
  34. package/assets/skills/office-documents/scripts/content_controls.py +192 -0
  35. package/assets/skills/office-documents/scripts/core_properties.py +124 -0
  36. package/assets/skills/office-documents/scripts/docx_kit.py +587 -0
  37. package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +225 -0
  38. package/assets/skills/office-documents/scripts/docx_table_to_csv.py +99 -0
  39. package/assets/skills/office-documents/scripts/fields_materialize.py +66 -0
  40. package/assets/skills/office-documents/scripts/fields_report.py +59 -0
  41. package/assets/skills/office-documents/scripts/flatten_ref_fields.py +62 -0
  42. package/assets/skills/office-documents/scripts/footnotes_report.py +77 -0
  43. package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +96 -0
  44. package/assets/skills/office-documents/scripts/heading_audit.py +81 -0
  45. package/assets/skills/office-documents/scripts/images_audit.py +80 -0
  46. package/assets/skills/office-documents/scripts/insert_note.py +126 -0
  47. package/assets/skills/office-documents/scripts/insert_ref_fields.py +90 -0
  48. package/assets/skills/office-documents/scripts/insert_toc.py +84 -0
  49. package/assets/skills/office-documents/scripts/internal_nav.py +146 -0
  50. package/assets/skills/office-documents/scripts/make_fixtures.py +169 -0
  51. package/assets/skills/office-documents/scripts/merge_docx_append.py +239 -0
  52. package/assets/skills/office-documents/scripts/privacy_scrub.py +131 -0
  53. package/assets/skills/office-documents/scripts/redact_docx.py +113 -0
  54. package/assets/skills/office-documents/scripts/render_and_diff.py +101 -0
  55. package/assets/skills/office-documents/scripts/replace_text.py +154 -0
  56. package/assets/skills/office-documents/scripts/section_audit.py +91 -0
  57. package/assets/skills/office-documents/scripts/set_protection.py +92 -0
  58. package/assets/skills/office-documents/scripts/style_lint.py +111 -0
  59. package/assets/skills/office-documents/scripts/style_normalize.py +103 -0
  60. package/assets/skills/office-documents/scripts/table_geometry.py +167 -0
  61. package/assets/skills/office-documents/scripts/watermark_add.py +48 -0
  62. package/assets/skills/office-documents/scripts/watermark_audit_remove.py +67 -0
  63. package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +117 -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 +136 -0
  114. package/assets/skills/office-presentations/container_tools/core_properties.py +120 -0
  115. package/assets/skills/office-presentations/container_tools/create_montage.py +126 -0
  116. package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +285 -0
  117. package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +157 -0
  118. package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +144 -0
  119. package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +199 -0
  120. package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +212 -0
  121. package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +345 -0
  122. package/assets/skills/office-presentations/container_tools/preview_render.py +375 -0
  123. package/assets/skills/office-presentations/container_tools/render_slides.py +139 -0
  124. package/assets/skills/office-presentations/container_tools/replace_text.py +168 -0
  125. package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +229 -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 +176 -0
  152. package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +154 -0
  153. package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +111 -0
  154. package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +239 -0
  155. package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +185 -0
  156. package/assets/skills/office-spreadsheets/SKILL.md +105 -101
  157. package/assets/skills/office-spreadsheets/container_tools/add_chart.py +236 -0
  158. package/assets/skills/office-spreadsheets/container_tools/add_image.py +107 -0
  159. package/assets/skills/office-spreadsheets/container_tools/append_rows.py +122 -0
  160. package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +306 -0
  161. package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +398 -0
  162. package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +275 -0
  163. package/assets/skills/office-spreadsheets/container_tools/set_cells.py +135 -0
  164. package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +134 -0
  165. package/assets/skills/office-spreadsheets/container_tools/style_cells.py +133 -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 +253 -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 +239 -89
  195. package/package.json +1 -1
  196. package/postinstall.mjs +92 -82
  197. package/python/office_cli.py +171 -246
@@ -0,0 +1,120 @@
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
+ FIELDS = ("title", "author", "subject", "keywords", "comments", "category", "last_modified_by")
32
+
33
+
34
+ def _emit(payload: dict, code: int = 0) -> int:
35
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
36
+ return code
37
+
38
+
39
+ def _snapshot(properties) -> dict:
40
+ return {field: getattr(properties, field, None) for field in FIELDS}
41
+
42
+
43
+ def main() -> int:
44
+ parser = argparse.ArgumentParser(
45
+ description="Read or set .pptx core properties (title, author, subject, keywords, comments).",
46
+ epilog="Exit codes: 0 ok, 2 bad input, 3 write failure.",
47
+ )
48
+ parser.add_argument("--input", required=True, help="source deck")
49
+ parser.add_argument("--title")
50
+ parser.add_argument("--author")
51
+ parser.add_argument("--subject")
52
+ parser.add_argument("--keywords")
53
+ parser.add_argument("--comments")
54
+ parser.add_argument("--category")
55
+ parser.add_argument("--last-modified-by")
56
+ parser.add_argument("--out", help="output deck (default: <input>-edited.pptx when writing)")
57
+ args = parser.parse_args()
58
+
59
+ source = Path(args.input)
60
+ if not source.is_file():
61
+ return _emit({"ok": False, "error": f"no such file: {source}"}, 2)
62
+
63
+ from pptx import Presentation # noqa: PLC0415
64
+
65
+ try:
66
+ prs = Presentation(str(source))
67
+ except Exception as error: # noqa: BLE001
68
+ return _emit({"ok": False, "error": f"cannot open {source}: {error}"}, 2)
69
+
70
+ before = _snapshot(prs.core_properties)
71
+ updates = {
72
+ field: (getattr(args, field) if getattr(args, field) is not None else None)
73
+ for field in FIELDS
74
+ if field != "last_modified_by"
75
+ }
76
+ if args.last_modified_by is not None:
77
+ updates["last_modified_by"] = args.last_modified_by
78
+ requested = {key: value for key, value in updates.items() if value is not None}
79
+
80
+ if not requested:
81
+ return _emit(
82
+ {
83
+ "schema": "core_properties/1",
84
+ "ok": True,
85
+ "input": str(source),
86
+ "output": None,
87
+ "before": before,
88
+ "after": before,
89
+ "changed": [],
90
+ "note": "read-only: no property flag was passed",
91
+ }
92
+ )
93
+
94
+ for field, value in requested.items():
95
+ setattr(prs.core_properties, field, value)
96
+ prs.core_properties.modified = datetime.now(timezone.utc).replace(tzinfo=None)
97
+ after = _snapshot(prs.core_properties)
98
+
99
+ output = Path(args.out) if args.out else source.with_name(f"{source.stem}-edited{source.suffix}")
100
+ try:
101
+ output.parent.mkdir(parents=True, exist_ok=True)
102
+ prs.save(str(output))
103
+ except Exception as error: # noqa: BLE001
104
+ return _emit({"ok": False, "error": f"cannot write {output}: {error}"}, 3)
105
+
106
+ return _emit(
107
+ {
108
+ "schema": "core_properties/1",
109
+ "ok": True,
110
+ "input": str(source),
111
+ "output": str(output),
112
+ "before": before,
113
+ "after": after,
114
+ "changed": [key for key, value in requested.items() if before.get(key) != value],
115
+ }
116
+ )
117
+
118
+
119
+ if __name__ == "__main__":
120
+ sys.exit(main())
@@ -0,0 +1,126 @@
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
+ try:
17
+ from PIL import Image, ImageDraw, ImageFont
18
+ except ImportError: # pragma: no cover - whitelist dependency, but fail clearly
19
+ print(json.dumps({"ok": False, "error": {"code": "pillow_missing", "message": "Pillow is required"}}))
20
+ raise SystemExit(3)
21
+
22
+
23
+ def _collect(paths: list[str], directory: str | None) -> list[Path]:
24
+ found: list[Path] = [Path(item) for item in paths]
25
+ if directory:
26
+ found.extend(sorted(Path(directory).glob("*.png")))
27
+ ordered: list[Path] = []
28
+ seen: set[str] = set()
29
+ for path in found:
30
+ key = str(path.resolve())
31
+ if key in seen or not path.is_file() or path.name in {"contact-sheet.png", "montage.png"}:
32
+ continue
33
+ seen.add(key)
34
+ ordered.append(path)
35
+ return ordered
36
+
37
+
38
+ def _label(draw, box, text: str, size: int, colour) -> None:
39
+ try:
40
+ font = ImageFont.truetype("arial.ttf", size)
41
+ except OSError:
42
+ try:
43
+ font = ImageFont.load_default(size=size)
44
+ except TypeError: # very old Pillow
45
+ font = ImageFont.load_default()
46
+ draw.text((box[0], box[1]), text, fill=colour, font=font)
47
+
48
+
49
+ def make_montage(
50
+ paths: list[str],
51
+ output: str | Path,
52
+ directory: str | None = None,
53
+ columns: int = 3,
54
+ cell_width: int = 480,
55
+ padding: int = 16,
56
+ background: tuple[int, int, int] = (245, 246, 248),
57
+ ) -> dict:
58
+ images = _collect(paths, directory)
59
+ if not images:
60
+ return {"ok": False, "error": {"code": "no_images", "message": "no PNG inputs found"}}
61
+
62
+ loaded: list[tuple[Path, Image.Image]] = []
63
+ for path in images:
64
+ try:
65
+ with Image.open(path) as handle:
66
+ loaded.append((path, handle.convert("RGB")))
67
+ except Exception as exc: # noqa: BLE001 - one bad file must not kill the sheet
68
+ loaded.append((path, Image.new("RGB", (cell_width, int(cell_width * 0.5625)), (220, 60, 60))))
69
+ columns = max(1, min(columns, len(loaded)))
70
+ cell_height = max(1, int(round(cell_width * loaded[0][1].height / max(loaded[0][1].width, 1))))
71
+ rows = (len(loaded) + columns - 1) // columns
72
+ label_height = 22
73
+ sheet = Image.new(
74
+ "RGB",
75
+ (
76
+ columns * cell_width + (columns + 1) * padding,
77
+ rows * (cell_height + label_height) + (rows + 1) * padding,
78
+ ),
79
+ background,
80
+ )
81
+ draw = ImageDraw.Draw(sheet)
82
+ for index, (path, image) in enumerate(loaded):
83
+ column = index % columns
84
+ row = index // columns
85
+ x = padding + column * (cell_width + padding)
86
+ y = padding + row * (cell_height + label_height + padding)
87
+ draw.rectangle([x - 1, y - 1, x + cell_width, y + cell_height], outline=(205, 210, 218))
88
+ thumb = image.copy()
89
+ thumb.thumbnail((cell_width, cell_height), Image.LANCZOS)
90
+ sheet.paste(thumb, (x + (cell_width - thumb.width) // 2, y + (cell_height - thumb.height) // 2))
91
+ _label(draw, (x, y + cell_height + 4), path.stem, 13, (70, 76, 86))
92
+
93
+ target = Path(output)
94
+ target.parent.mkdir(parents=True, exist_ok=True)
95
+ sheet.save(target)
96
+ return {
97
+ "ok": True,
98
+ "output": str(target.resolve()),
99
+ "cells": len(loaded),
100
+ "columns": columns,
101
+ "size": list(sheet.size),
102
+ "cells_in_order": [path.name for path, _ in loaded],
103
+ }
104
+
105
+
106
+ def main(argv: list[str] | None = None) -> int:
107
+ import argparse
108
+
109
+ parser = argparse.ArgumentParser(description="Combine slide PNGs into one labelled grid image.")
110
+ parser.add_argument("images", nargs="*", help="PNG files (order is preserved)")
111
+ parser.add_argument("--dir", help="also include every .png in this directory, sorted")
112
+ parser.add_argument("--out", required=True, help="path of the contact sheet to write")
113
+ parser.add_argument("--columns", type=int, default=3, help="grid columns (default 3)")
114
+ parser.add_argument("--cell-width", type=int, default=480, help="width of one cell in pixels (default 480)")
115
+ args = parser.parse_args(argv)
116
+
117
+ report = make_montage(args.images, args.out, args.dir, args.columns, args.cell_width)
118
+ json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
119
+ sys.stdout.write("\n")
120
+ if report.get("ok"):
121
+ return 0
122
+ return 2
123
+
124
+
125
+ if __name__ == "__main__":
126
+ raise SystemExit(main())
@@ -0,0 +1,285 @@
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
+ TITLE_OUTSIDE_MARGIN_IN = 0.0
46
+ DEFAULT_TITLE_FLOOR_PT = 28.0
47
+ DEFAULT_BODY_FLOOR_PT = 14.0
48
+ OVERLAP_HINT_RATIO = 0.35
49
+ TEXT_OVERFLOW_SLACK_IN = 0.08
50
+
51
+
52
+ def inspect(pptx_path: str | Path, title_floor: float = DEFAULT_TITLE_FLOOR_PT, body_floor: float = DEFAULT_BODY_FLOOR_PT) -> dict:
53
+ presentation = open_presentation(pptx_path)
54
+ slide_w, slide_h = slide_size_in(presentation)
55
+ findings: list[dict] = []
56
+ slides_report: list[dict] = []
57
+ title_sizes: list[tuple[int, int]] = []
58
+
59
+ for slide_no, slide in enumerate(presentation.slides, start=1):
60
+ shapes = [shape for shape in iter_shapes(slide.shapes)]
61
+ slide_findings: list[dict] = []
62
+ text_boxes: list[tuple[str, tuple[float, float, float, float], str]] = []
63
+
64
+ for shape in shapes:
65
+ geometry = box(shape)
66
+ name = label(shape)
67
+ if geometry is None:
68
+ slide_findings.append(finding("shape_without_frame", "review", f"'{name}' has no position or size", shape=name))
69
+ continue
70
+ left, top, width, height = geometry
71
+
72
+ 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:
73
+ slide_findings.append(
74
+ finding(
75
+ "outside_slide",
76
+ "fail",
77
+ f"'{name}' extends past the slide edge",
78
+ shape=name,
79
+ bbox=[round(left, 2), round(top, 2), round(width, 2), round(height, 2)],
80
+ bleed={
81
+ "right": round(max(0.0, left + width - slide_w), 2),
82
+ "bottom": round(max(0.0, top + height - slide_h), 2),
83
+ "left": round(max(0.0, -left), 2),
84
+ "top": round(max(0.0, -top), 2),
85
+ },
86
+ )
87
+ )
88
+
89
+ if width <= 0 or height <= 0:
90
+ slide_findings.append(finding("degenerate_size", "fail", f"'{name}' has zero width or height", shape=name))
91
+
92
+ needed = estimated_text_height_in(shape)
93
+ if needed is not None and needed > height + TEXT_OVERFLOW_SLACK_IN:
94
+ slide_findings.append(
95
+ finding(
96
+ "text_overflow",
97
+ "fail",
98
+ f"'{name}' needs about {needed:.2f} in of text height but its frame is {height:.2f} in",
99
+ shape=name,
100
+ neededIn=round(needed, 2),
101
+ frameIn=round(height, 2),
102
+ )
103
+ )
104
+
105
+ kind = (placeholder_kind(shape) or "").upper()
106
+ if kind and not has_visible_text(shape) and not is_picture(shape):
107
+ slide_findings.append(finding("empty_placeholder", "review", f"empty {kind} placeholder is still on the slide", shape=name))
108
+
109
+ if has_visible_text(shape):
110
+ text_boxes.append((name, geometry, text_of(shape)))
111
+ size = run_size_pt(shape) or 0.0
112
+ if "TITLE" in kind or name.lower().startswith("title"):
113
+ if 0 < size < title_floor:
114
+ slide_findings.append(finding("small_title", "review", f"title '{name}' is {size:.0f} pt", shape=name, fontPt=size))
115
+ if text_of(shape).rstrip().endswith("."):
116
+ slide_findings.append(finding("heading_full_stop", "review", f"heading '{name}' ends with a full stop", shape=name))
117
+ elif 0 < size < body_floor:
118
+ slide_findings.append(finding("small_body_text", "review", f"'{name}' is {size:.0f} pt", shape=name, fontPt=size))
119
+
120
+ if is_table(shape):
121
+ table_bottom = top + height
122
+ if table_bottom > slide_h:
123
+ slide_findings.append(finding("table_outside_slide", "fail", f"table '{name}' runs {table_bottom - slide_h:.2f} in past the bottom edge", shape=name))
124
+
125
+ # text block collisions
126
+ for first in range(len(text_boxes)):
127
+ for second in range(first + 1, len(text_boxes)):
128
+ name_a, box_a, text_a = text_boxes[first]
129
+ name_b, box_b, text_b = text_boxes[second]
130
+ if not rects_intersect(box_a, box_b):
131
+ continue
132
+ ratio = overlap_ratio(box_a, box_b)
133
+ if ratio >= OVERLAP_HINT_RATIO:
134
+ slide_findings.append(
135
+ finding(
136
+ "text_overlap",
137
+ "review",
138
+ f"'{name_a}' and '{name_b}' overlap by {ratio * 100:.0f}% of the smaller box",
139
+ shapes=[name_a, name_b],
140
+ overlap=round(ratio, 2),
141
+ excerpt=[text_a[:60], text_b[:60]],
142
+ )
143
+ )
144
+
145
+ # a chart or table hidden under a picture/other block
146
+ heavy = [(label(shape), box(shape)) for shape in shapes if (is_chart(shape) or is_table(shape)) and box(shape)]
147
+ for name, geometry in heavy:
148
+ for other in shapes:
149
+ if label(other) == name:
150
+ continue
151
+ other_box = box(other)
152
+ if other_box is None or is_connector(other) or (is_chart(other) or is_table(other)):
153
+ continue
154
+ if not has_visible_text(other) and not is_picture(other):
155
+ continue
156
+ ratio = overlap_ratio(geometry, other_box)
157
+ if ratio >= 0.5:
158
+ slide_findings.append(
159
+ finding(
160
+ "evidence_covered",
161
+ "fail",
162
+ f"'{label(other)}' covers {ratio * 100:.0f}% of '{name}'",
163
+ shapes=[name, label(other)],
164
+ overlap=round(ratio, 2),
165
+ )
166
+ )
167
+
168
+ # connectors crossing text
169
+ for shape in shapes:
170
+ if not is_connector(shape) or box(shape) is None:
171
+ continue
172
+ for name, text_box, _ in text_boxes:
173
+ if rects_intersect(box(shape), text_box) and overlap_ratio(box(shape), text_box) > 0.15:
174
+ slide_findings.append(finding("connector_over_text", "review", f"connector '{label(shape)}' crosses '{name}'", shapes=[label(shape), name]))
175
+
176
+ # chart and table repeating the same numbers on one slide
177
+ if any(is_chart(shape) for shape in shapes) and any(is_table(shape) for shape in shapes):
178
+ chart_labels = {
179
+ str(category).strip()
180
+ for shape in shapes
181
+ if is_chart(shape)
182
+ for category in _chart_categories(shape)
183
+ }
184
+ table_labels = {
185
+ str(cell.text).strip()
186
+ for shape in shapes
187
+ if is_table(shape)
188
+ for row in shape.table.rows
189
+ for cell in row.cells
190
+ }
191
+ shared = {value for value in chart_labels & table_labels if value and not value.replace(".", "").isdigit()}
192
+ if len(shared) >= 2:
193
+ slide_findings.append(
194
+ finding(
195
+ "duplicate_evidence",
196
+ "review",
197
+ "the chart and the table restate the same categories",
198
+ shared=sorted(shared)[:6],
199
+ )
200
+ )
201
+
202
+ findings.extend({**item, "slide": slide_no} for item in slide_findings)
203
+ slides_report.append(
204
+ {
205
+ "slide": slide_no,
206
+ "shapes": len(shapes),
207
+ "charts": sum(1 for shape in shapes if is_chart(shape)),
208
+ "tables": sum(1 for shape in shapes if is_table(shape)),
209
+ "pictures": sum(1 for shape in shapes if is_picture(shape)),
210
+ "findings": len(slide_findings),
211
+ }
212
+ )
213
+
214
+ for shape in shapes:
215
+ kind = (placeholder_kind(shape) or "").upper()
216
+ # A cover is meant to run larger than the body slides, so it is excluded.
217
+ if "CENTER_TITLE" in kind or not has_visible_text(shape):
218
+ continue
219
+ if "TITLE" in kind or label(shape).lower().startswith("title"):
220
+ # Inherited sizes only resolve at render time; approximate them with the
221
+ # pack's placeholder defaults so drift between slides is still visible.
222
+ size = run_size_pt(shape) or default_size_pt(shape)
223
+ if size:
224
+ title_sizes.append((slide_no, round(size)))
225
+
226
+ if len({size for _, size in title_sizes}) > 1:
227
+ findings.append(
228
+ finding(
229
+ "inconsistent_title_size",
230
+ "review",
231
+ "slide titles are set at different sizes; pick one size for the deck",
232
+ sizes=sorted({size for _, size in title_sizes}),
233
+ slides=[slide_no for slide_no, _ in title_sizes],
234
+ )
235
+ )
236
+ blocking = [item for item in findings if item["severity"] == "fail"]
237
+ return {
238
+ "gate": "layout-geometry",
239
+ "presentation": str(Path(pptx_path).resolve()),
240
+ "slideSizeIn": [round(slide_w, 3), round(slide_h, 3)],
241
+ "slides": slides_report,
242
+ "findings": findings,
243
+ "summary": {
244
+ "slides": len(slides_report),
245
+ "fail": len(blocking),
246
+ "review": len(findings) - len(blocking),
247
+ },
248
+ "passed": not blocking,
249
+ }
250
+
251
+
252
+ def _chart_categories(shape) -> list[str]:
253
+ try:
254
+ return [str(category) for category in shape.chart.plots[0].categories]
255
+ except Exception: # noqa: BLE001 - charts without a cache simply contribute nothing
256
+ return []
257
+
258
+
259
+ def main(argv: list[str] | None = None) -> int:
260
+ import argparse
261
+
262
+ parser = argparse.ArgumentParser(description="Report slide-geometry problems (out-of-bounds shapes, text overflow, overlaps).")
263
+ parser.add_argument("presentation", nargs="?", help="path to the .pptx")
264
+ parser.add_argument("--input", dest="input_path", help="path to the .pptx (same as the positional)")
265
+ parser.add_argument("--title-floor", type=float, default=DEFAULT_TITLE_FLOOR_PT, help="minimum title size in pt (default 28)")
266
+ parser.add_argument("--body-floor", type=float, default=DEFAULT_BODY_FLOOR_PT, help="minimum body size in pt (default 14)")
267
+ parser.add_argument("--strict", action="store_true", help="treat review findings as blocking too")
268
+ parser.add_argument("--json-out", help="also write the report here")
269
+ args = parser.parse_args(argv)
270
+ path = args.input_path or args.presentation
271
+ if not path:
272
+ parser.error("a .pptx path is required (positional or --input)")
273
+
274
+ report = inspect(path, args.title_floor, args.body_floor)
275
+ if args.strict:
276
+ report["strict"] = True
277
+ report["passed"] = not report["findings"]
278
+ if args.json_out:
279
+ Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
280
+ emit(report)
281
+ return 0 if report["passed"] else 1
282
+
283
+
284
+ if __name__ == "__main__":
285
+ raise SystemExit(main())
@@ -0,0 +1,157 @@
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
+ REL_NS = "{http://schemas.openxmlformats.org/package/2006/relationships}"
20
+ CT_NS = "{http://schemas.openxmlformats.org/package/2006/content-types}"
21
+ P_NS = "{http://schemas.openxmlformats.org/presentationml/2006/main}"
22
+ R_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
23
+
24
+
25
+ def _rels_targets(archive: zipfile.ZipFile, part: str) -> list[str]:
26
+ folder, _, name = part.rpartition("/")
27
+ rels_name = f"{folder}/_rels/{name}.rels" if folder else f"_rels/{name}.rels"
28
+ if rels_name not in archive.namelist():
29
+ return []
30
+ try:
31
+ root = ElementTree.fromstring(archive.read(rels_name))
32
+ except ElementTree.ParseError:
33
+ return []
34
+ targets: list[str] = []
35
+ for relationship in root.findall(f"{REL_NS}Relationship"):
36
+ if relationship.get("TargetMode") == "External":
37
+ continue
38
+ target = relationship.get("Target", "")
39
+ if target.startswith("/"):
40
+ resolved = target.lstrip("/")
41
+ else:
42
+ resolved = f"{folder}/{target}" if folder else target
43
+ parts: list[str] = []
44
+ for segment in resolved.split("/"):
45
+ if segment in {"", "."}:
46
+ continue
47
+ if segment == "..":
48
+ if parts:
49
+ parts.pop()
50
+ continue
51
+ parts.append(segment)
52
+ targets.append("/".join(parts))
53
+ return targets
54
+
55
+
56
+ def inspect_package(pptx_path: str | Path) -> dict:
57
+ path = Path(pptx_path)
58
+ problems: list[dict] = []
59
+ if not path.is_file():
60
+ return {"gate": "package-integrity", "passed": False, "problems": [{"kind": "missing_file", "detail": str(path)}]}
61
+ if not zipfile.is_zipfile(path):
62
+ return {"gate": "package-integrity", "passed": False, "problems": [{"kind": "not_a_zip", "detail": "not a readable OOXML archive"}]}
63
+
64
+ with zipfile.ZipFile(path) as archive:
65
+ names = set(archive.namelist())
66
+ for required in ("[Content_Types].xml", "_rels/.rels", "ppt/presentation.xml"):
67
+ if required not in names:
68
+ problems.append({"kind": "missing_part", "part": required})
69
+
70
+ slides = sorted(name for name in names if name.startswith("ppt/slides/slide") and name.endswith(".xml"))
71
+ layouts = sorted(name for name in names if name.startswith("ppt/slideLayouts/slideLayout") and name.endswith(".xml"))
72
+ masters = sorted(name for name in names if name.startswith("ppt/slideMasters/slideMaster") and name.endswith(".xml"))
73
+ charts = sorted(name for name in names if name.startswith("ppt/charts/chart") and name.endswith(".xml"))
74
+ media = sorted(name for name in names if name.startswith("ppt/media/"))
75
+ embeddings = sorted(name for name in names if name.startswith("ppt/embeddings/"))
76
+
77
+ if not slides:
78
+ problems.append({"kind": "no_slides", "detail": "the package has no ppt/slides parts"})
79
+ if not masters:
80
+ problems.append({"kind": "no_master", "detail": "the package has no slide master"})
81
+
82
+ for part in slides + charts + layouts + masters:
83
+ for target in _rels_targets(archive, part):
84
+ if target and target not in names:
85
+ problems.append({"kind": "dangling_relationship", "part": part, "target": target})
86
+
87
+ for part in slides:
88
+ try:
89
+ root = ElementTree.fromstring(archive.read(part))
90
+ except ElementTree.ParseError as exc:
91
+ problems.append({"kind": "unparsable_xml", "part": part, "detail": str(exc)})
92
+ continue
93
+ if root.tag != f"{P_NS}sld":
94
+ problems.append({"kind": "wrong_root_element", "part": part, "detail": root.tag})
95
+
96
+ presentation_xml = archive.read("ppt/presentation.xml") if "ppt/presentation.xml" in names else b""
97
+ declared_slides = 0
98
+ if presentation_xml:
99
+ try:
100
+ root = ElementTree.fromstring(presentation_xml)
101
+ slide_ids = root.find(f"{P_NS}sldIdLst")
102
+ declared_slides = len(list(slide_ids)) if slide_ids is not None else 0
103
+ except ElementTree.ParseError:
104
+ problems.append({"kind": "unparsable_xml", "part": "ppt/presentation.xml"})
105
+ if declared_slides and declared_slides != len(slides):
106
+ problems.append(
107
+ {
108
+ "kind": "slide_count_mismatch",
109
+ "detail": f"presentation.xml lists {declared_slides} slides but the package carries {len(slides)} parts",
110
+ }
111
+ )
112
+
113
+ duplicates = len(slides) - len({name.lower() for name in slides})
114
+ if duplicates:
115
+ problems.append({"kind": "duplicate_parts", "count": duplicates})
116
+
117
+ notes = [name for name in names if name.startswith("ppt/notesSlides/notesSlide") and name.endswith(".xml")]
118
+
119
+ return {
120
+ "gate": "package-integrity",
121
+ "presentation": str(path.resolve()),
122
+ "parts": {
123
+ "slides": len(slides),
124
+ "layouts": len(layouts),
125
+ "masters": len(masters),
126
+ "charts": len(charts),
127
+ "notesSlides": len(notes),
128
+ "media": len(media),
129
+ "embeddings": len(embeddings),
130
+ },
131
+ "problems": problems,
132
+ "passed": not problems,
133
+ }
134
+
135
+
136
+ def main(argv: list[str] | None = None) -> int:
137
+ import argparse
138
+
139
+ parser = argparse.ArgumentParser(description="Verify OOXML part/relationship integrity of a .pptx.")
140
+ parser.add_argument("presentation", nargs="?", help="path to the .pptx")
141
+ parser.add_argument("--input", dest="input_path", help="path to the .pptx (same as the positional)")
142
+ parser.add_argument("--json-out", help="also write the report here")
143
+ args = parser.parse_args(argv)
144
+ path = args.input_path or args.presentation
145
+ if not path:
146
+ parser.error("a .pptx path is required (positional or --input)")
147
+
148
+ report = inspect_package(path)
149
+ if args.json_out:
150
+ Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
151
+ json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
152
+ sys.stdout.write("\n")
153
+ return 0 if report["passed"] else 1
154
+
155
+
156
+ if __name__ == "__main__":
157
+ raise SystemExit(main())