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,183 @@
1
+ """Catch the traces an authoring pass leaves in a template-based deck.
2
+
3
+ Purpose: a deck can keep the template's layouts and still be wrong — a
4
+ "Click to add title" prompt left in place, the template's instruction text
5
+ still sitting in a body, or an opaque panel dropped exactly over the artwork
6
+ the design relies on. None of those are visible in a plan; all of them are
7
+ cheap to detect afterwards.
8
+
9
+ Exit codes: 0 clean, 1 findings, 2 unreadable input.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "container_tools"))
20
+
21
+ from ooxml_helpers import box, has_visible_text, is_picture, overlap_ratio, text_of # noqa: E402
22
+
23
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
24
+ for _stream in (sys.stdout, sys.stderr):
25
+ try:
26
+ _stream.reconfigure(encoding="utf-8", errors="replace")
27
+ except (AttributeError, ValueError):
28
+ pass
29
+
30
+ PLACEHOLDER_PATTERNS = [
31
+ re.compile(pattern, re.IGNORECASE)
32
+ for pattern in (
33
+ r"^slide number$",
34
+ r"^date$",
35
+ r"^footer$",
36
+ r"^click to add (title|subtitle|text|body)",
37
+ r"^(title|subtitle|name|text|body) goes here$",
38
+ r"^lorem ipsum\b",
39
+ r"\btemplate instruction\b",
40
+ r"^\[.*\]$",
41
+ )
42
+ ]
43
+ ART_COVER_MIN_RATIO = 0.9
44
+
45
+
46
+ def _has_solid_fill(shape) -> bool:
47
+ try:
48
+ from pptx.enum.dml import MSO_FILL
49
+
50
+ return shape.fill.type == MSO_FILL.SOLID
51
+ except Exception: # noqa: BLE001 - shapes without a fill API are never opaque panels
52
+ return False
53
+
54
+
55
+ def _slide_size_in(slide) -> tuple[float, float]:
56
+ presentation = slide.part.package.presentation_part.presentation
57
+ return presentation.slide_width / 914400, presentation.slide_height / 914400
58
+
59
+
60
+ def check(template_path: str | Path, final_path: str | Path, plan_path: str | Path | None = None) -> dict:
61
+ from pptx import Presentation
62
+
63
+ template_file = Path(template_path)
64
+ final_file = Path(final_path)
65
+ for candidate in (template_file, final_file):
66
+ if not candidate.is_file():
67
+ return {"gate": "template-authoring-fidelity", "passed": False, "findings": [{"kind": "missing_file", "detail": str(candidate)}]}
68
+
69
+ template = Presentation(str(template_file))
70
+ final = Presentation(str(final_file))
71
+ template_slides = list(template.slides)
72
+ final_slides = list(final.slides)
73
+ plan = {}
74
+ if plan_path and Path(plan_path).is_file():
75
+ plan = json.loads(Path(plan_path).read_text(encoding="utf-8"))
76
+
77
+ findings: list[dict] = []
78
+ mapping: list[tuple[int, int]] = []
79
+ for entry in plan.get("outputSlides") or []:
80
+ mapping.append((int(entry.get("slide", len(mapping) + 1)), int(entry.get("sourceSlide", 0))))
81
+ if not mapping:
82
+ mapping = [(index, index) for index in range(1, min(len(template_slides), len(final_slides)) + 1)]
83
+
84
+ for output_index, source_index in mapping:
85
+ if not (1 <= output_index <= len(final_slides) and 1 <= source_index <= len(template_slides)):
86
+ findings.append({"kind": "mapping_out_of_range", "severity": "fail", "message": f"mapping {output_index}->{source_index} is outside the deck"})
87
+ continue
88
+ output_slide = final_slides[output_index - 1]
89
+ source_slide = template_slides[source_index - 1]
90
+
91
+ if output_slide.slide_layout.name != source_slide.slide_layout.name:
92
+ findings.append(
93
+ {
94
+ "kind": "layout_drift",
95
+ "severity": "review",
96
+ "slide": output_index,
97
+ "message": f"layout is '{output_slide.slide_layout.name}', template slide used '{source_slide.slide_layout.name}'",
98
+ }
99
+ )
100
+
101
+ for shape in output_slide.shapes:
102
+ if not has_visible_text(shape):
103
+ continue
104
+ text = text_of(shape).strip()
105
+ for pattern in PLACEHOLDER_PATTERNS:
106
+ if pattern.search(text):
107
+ findings.append(
108
+ {
109
+ "kind": "leftover_placeholder_text",
110
+ "severity": "fail",
111
+ "slide": output_index,
112
+ "shape": shape.name,
113
+ "text": text[:80],
114
+ "message": "template prompt text is still on the slide",
115
+ }
116
+ )
117
+ break
118
+ if not text and shape.is_placeholder:
119
+ findings.append({"kind": "empty_placeholder", "severity": "review", "slide": output_index, "shape": shape.name, "message": "placeholder was left empty"})
120
+
121
+ inherited_art = [box(shape) for shape in source_slide.shapes if is_picture(shape) and box(shape)]
122
+ slide_w, slide_h = _slide_size_in(source_slide)
123
+ full_bleed = {
124
+ index
125
+ for index, art in enumerate(inherited_art)
126
+ if art[0] <= 0.02 and art[1] <= 0.02 and art[2] >= slide_w * 0.98 and art[3] >= slide_h * 0.98
127
+ }
128
+ for shape in output_slide.shapes:
129
+ # only an opaque panel is a problem: text over a background image is the template working
130
+ if is_picture(shape) or not _has_solid_fill(shape):
131
+ continue
132
+ geometry = box(shape)
133
+ if geometry is None:
134
+ continue
135
+ for art_index, art in enumerate(inherited_art):
136
+ if art_index in full_bleed:
137
+ continue
138
+ ratio = overlap_ratio(geometry, art)
139
+ if ratio >= ART_COVER_MIN_RATIO:
140
+ findings.append(
141
+ {
142
+ "kind": "template_art_covered",
143
+ "severity": "fail",
144
+ "slide": output_index,
145
+ "shape": shape.name,
146
+ "overlap": round(ratio, 2),
147
+ "message": "a text or table block sits on top of the template artwork",
148
+ }
149
+ )
150
+ break
151
+
152
+ blocking = [item for item in findings if item["severity"] == "fail"]
153
+ return {
154
+ "gate": "template-authoring-fidelity",
155
+ "template": str(template_file.resolve()),
156
+ "final": str(final_file.resolve()),
157
+ "plan": str(plan_path) if plan_path else None,
158
+ "mappedSlides": len(mapping),
159
+ "findings": findings,
160
+ "passed": not blocking,
161
+ }
162
+
163
+
164
+ def main(argv: list[str] | None = None) -> int:
165
+ import argparse
166
+
167
+ parser = argparse.ArgumentParser(description="Find leftover template prompts, layout drift and covered artwork.")
168
+ parser.add_argument("--template", required=True, help="the template .pptx")
169
+ parser.add_argument("--final", required=True, help="the finished .pptx")
170
+ parser.add_argument("--plan", help="optional template-frame-map.json for the slide mapping")
171
+ parser.add_argument("--json-out", help="also write the report here")
172
+ args = parser.parse_args(argv)
173
+
174
+ report = check(args.template, args.final, args.plan)
175
+ if args.json_out:
176
+ Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
177
+ json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
178
+ sys.stdout.write("\n")
179
+ return 0 if report["passed"] else 1
180
+
181
+
182
+ if __name__ == "__main__":
183
+ raise SystemExit(main())
@@ -0,0 +1,161 @@
1
+ """Read a template deck into records an author can plan against.
2
+
3
+ Purpose: matching a supplied deck starts with knowing what it actually is —
4
+ slide size, which layouts exist, what each slide inherits, where the title and
5
+ body sit, which elements repeat on every slide, what type and colours the theme
6
+ carries. Guessing from memory of "a normal 16:9 deck" is how a template gets
7
+ flattened.
8
+
9
+ Writes:
10
+ <out-dir>/template-inspect.ndjson one JSON record per slide
11
+ <out-dir>/template-manifest.json deck level summary
12
+
13
+ Exit codes: 0 written, 2 unreadable input.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "container_tools"))
23
+
24
+ from ooxml_helpers import box, has_visible_text, is_chart, is_picture, is_table, placeholder_kind, slide_size_in, text_of # noqa: E402
25
+
26
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
27
+ for _stream in (sys.stdout, sys.stderr):
28
+ try:
29
+ _stream.reconfigure(encoding="utf-8", errors="replace")
30
+ except (AttributeError, ValueError):
31
+ pass
32
+
33
+
34
+ def _theme(presentation) -> dict:
35
+ fonts: set[str] = set()
36
+ fills: set[str] = set()
37
+ for master in presentation.slide_masters:
38
+ for layout in master.slide_layouts:
39
+ for shape in layout.shapes:
40
+ if has_visible_text(shape):
41
+ for paragraph in shape.text_frame.paragraphs:
42
+ for run in paragraph.runs:
43
+ if run.font.name:
44
+ fonts.add(run.font.name)
45
+ for slide in presentation.slides:
46
+ for shape in slide.shapes:
47
+ if has_visible_text(shape):
48
+ for paragraph in shape.text_frame.paragraphs:
49
+ for run in paragraph.runs:
50
+ if run.font.name:
51
+ fonts.add(run.font.name)
52
+ return {"fontsSeen": sorted(fonts), "colourSourcesNote": "check layouts and masters in PowerPoint for exact theme colours"}
53
+
54
+
55
+ def inspect_template(template_path: str | Path, out_dir: str | Path) -> dict:
56
+ from pptx import Presentation
57
+
58
+ source = Path(template_path)
59
+ if not source.is_file():
60
+ return {"ok": False, "error": {"code": "input_missing", "message": f"no such file: {source}"}}
61
+ presentation = Presentation(str(source))
62
+ slide_w, slide_h = slide_size_in(presentation)
63
+ destination = Path(out_dir)
64
+ destination.mkdir(parents=True, exist_ok=True)
65
+
66
+ records: list[dict] = []
67
+ for index, slide in enumerate(presentation.slides, start=1):
68
+ layout = slide.slide_layout
69
+ placeholders = []
70
+ for shape in slide.shapes:
71
+ if not shape.is_placeholder:
72
+ continue
73
+ placeholders.append(
74
+ {
75
+ "idx": shape.placeholder_format.idx,
76
+ "kind": placeholder_kind(shape) or "",
77
+ "type": int(shape.placeholder_format.type) if shape.placeholder_format.type is not None else None,
78
+ "bboxIn": [round(value, 3) for value in (box(shape) or (0, 0, 0, 0))],
79
+ "text": text_of(shape)[:120],
80
+ }
81
+ )
82
+ layout_placeholders = [
83
+ {
84
+ "idx": shape.placeholder_format.idx,
85
+ "kind": placeholder_kind(shape) or "",
86
+ "bboxIn": [round(value, 3) for value in (box(shape) or (0, 0, 0, 0))],
87
+ }
88
+ for shape in layout.shapes
89
+ if shape.is_placeholder
90
+ ]
91
+ records.append(
92
+ {
93
+ "slide": index,
94
+ "layout": layout.name,
95
+ "layoutIndex": list(presentation.slide_layouts).index(layout) if layout in list(presentation.slide_layouts) else None,
96
+ "layoutPlaceholders": layout_placeholders,
97
+ "placeholders": placeholders,
98
+ "shapes": len(slide.shapes),
99
+ "pictures": sum(1 for shape in slide.shapes if is_picture(shape)),
100
+ "charts": sum(1 for shape in slide.shapes if is_chart(shape)),
101
+ "tables": sum(1 for shape in slide.shapes if is_table(shape)),
102
+ "textExcerpt": " | ".join(text_of(shape)[:80] for shape in slide.shapes if has_visible_text(shape))[:240],
103
+ "backgroundPicture": any(
104
+ is_picture(shape) and box(shape) and box(shape)[2] >= slide_w * 0.95 and box(shape)[3] >= slide_h * 0.95
105
+ for shape in slide.shapes
106
+ ),
107
+ }
108
+ )
109
+
110
+ manifest = {
111
+ "template": str(source.resolve()),
112
+ "slideSizeIn": [round(slide_w, 3), round(slide_h, 3)],
113
+ "aspect": "16:9" if abs(slide_w / slide_h - 16 / 9) < 0.02 else "4:3" if abs(slide_w / slide_h - 4 / 3) < 0.02 else "custom",
114
+ "slideCount": len(presentation.slides),
115
+ "layouts": [layout.name for layout in presentation.slide_layouts],
116
+ "masters": len(presentation.slide_masters),
117
+ "slides": [{key: record[key] for key in ("slide", "layout", "shapes", "backgroundPicture")} for record in records],
118
+ "theme": _theme(presentation),
119
+ }
120
+
121
+ ndjson = destination / "template-inspect.ndjson"
122
+ ndjson.write_text("\n".join(json.dumps(record, ensure_ascii=False) for record in records) + "\n", encoding="utf-8")
123
+ manifest_path = destination / "template-manifest.json"
124
+ manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
125
+
126
+ return {
127
+ "ok": True,
128
+ "template": str(source.resolve()),
129
+ "records": len(records),
130
+ "ndjson": str(ndjson.resolve()),
131
+ "manifest": str(manifest_path.resolve()),
132
+ "summary": {
133
+ "slideCount": manifest["slideCount"],
134
+ "slideSizeIn": manifest["slideSizeIn"],
135
+ "aspect": manifest["aspect"],
136
+ "layouts": manifest["layouts"],
137
+ "backgroundArtSlides": [record["slide"] for record in records if record["backgroundPicture"]],
138
+ },
139
+ }
140
+
141
+
142
+ def main(argv: list[str] | None = None) -> int:
143
+ import argparse
144
+
145
+ parser = argparse.ArgumentParser(description="Summarise a template deck: layouts, placeholders, geometry, theme.")
146
+ parser.add_argument("template", nargs="?", help="path to the template .pptx")
147
+ parser.add_argument("--template", dest="template_opt", help="path to the template .pptx (same as the positional)")
148
+ parser.add_argument("--out-dir", required=True, help="directory for template-inspect.ndjson and template-manifest.json")
149
+ args = parser.parse_args(argv)
150
+ template = args.template_opt or args.template
151
+ if not template:
152
+ parser.error("a template .pptx path is required (positional or --template)")
153
+
154
+ report = inspect_template(template, args.out_dir)
155
+ json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
156
+ sys.stdout.write("\n")
157
+ return 0 if report.get("ok") else 2
158
+
159
+
160
+ if __name__ == "__main__":
161
+ raise SystemExit(main())
@@ -0,0 +1,118 @@
1
+ """Tile slide previews into one image so the whole deck can be judged at once.
2
+
3
+ Purpose: reading ten previews one by one costs ten looks and hides pace,
4
+ repetition and colour drift between slides. One contact sheet shows whether the
5
+ deck has a rhythm: which slides are dense, which are empty, whether the cover
6
+ carries the same accent as the close.
7
+
8
+ Exit codes: 0 sheet written, 2 nothing readable.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import math
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
19
+ for _stream in (sys.stdout, sys.stderr):
20
+ try:
21
+ _stream.reconfigure(encoding="utf-8", errors="replace")
22
+ except (AttributeError, ValueError):
23
+ pass
24
+
25
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "container_tools"))
26
+
27
+
28
+ def _natural_key(path: Path) -> list:
29
+ import re
30
+
31
+ return [int(part) if part.isdigit() else part.lower() for part in re.split(r"(\d+)", path.stem)]
32
+
33
+
34
+ def build_sheet(images: list[Path], out_path: Path, columns: int = 3, label_height: int = 48, padding: int = 18) -> dict:
35
+ from PIL import Image, ImageDraw
36
+
37
+ columns = max(1, columns)
38
+ rows = max(1, math.ceil(len(images) / columns))
39
+ thumbs = []
40
+ cell_w = 0
41
+ cell_h = 0
42
+ for path in images:
43
+ try:
44
+ with Image.open(path) as opened:
45
+ thumb = opened.convert("RGB")
46
+ thumbs.append((path, thumb.copy(), None))
47
+ cell_w = max(cell_w, thumb.width)
48
+ cell_h = max(cell_h, thumb.height)
49
+ except Exception as exc: # noqa: BLE001 - unreadable previews become placeholder cells
50
+ thumbs.append((path, None, str(exc)))
51
+ if not cell_w or not cell_h:
52
+ return {"ok": False, "error": {"code": "no_readable_images", "message": "none of the inputs could be opened"}}
53
+
54
+ width = columns * cell_w + (columns + 1) * padding
55
+ height = rows * (cell_h + label_height) + (rows + 1) * padding
56
+ sheet = Image.new("RGB", (width, height), (246, 246, 246))
57
+ draw = ImageDraw.Draw(sheet)
58
+
59
+ for index, (path, thumb, error) in enumerate(thumbs):
60
+ row, column = divmod(index, columns)
61
+ x = padding + column * (cell_w + padding)
62
+ y = padding + row * (cell_h + label_height + padding)
63
+ if thumb is None:
64
+ draw.rectangle([x, y, x + cell_w, y + cell_h], fill=(232, 232, 232), outline=(180, 60, 60), width=3)
65
+ draw.line([x, y, x + cell_w, y + cell_h], fill=(180, 60, 60), width=3)
66
+ draw.line([x + cell_w, y, x, y + cell_h], fill=(180, 60, 60), width=3)
67
+ else:
68
+ sheet.paste(thumb, (x, y))
69
+ draw.rectangle([x, y, x + cell_w, y + cell_h], outline=(190, 190, 190), width=1)
70
+ draw.text((x + 4, y + cell_h + 8), path.stem, fill=(40, 40, 40))
71
+
72
+ out_path.parent.mkdir(parents=True, exist_ok=True)
73
+ sheet.save(out_path)
74
+ return {
75
+ "ok": True,
76
+ "output": str(out_path.resolve()),
77
+ "cells": len(thumbs),
78
+ "grid": {"columns": columns, "rows": rows},
79
+ "size": [width, height],
80
+ "unreadable": [path.name for path, thumb, _ in thumbs if thumb is None],
81
+ }
82
+
83
+
84
+ def _collect(args) -> list[Path]:
85
+ found: list[Path] = []
86
+ for value in [*args.images, *([args.dir] if args.dir else [])]:
87
+ path = Path(value)
88
+ if path.is_dir():
89
+ found.extend(sorted((item for item in path.iterdir() if item.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"}), key=_natural_key))
90
+ else:
91
+ found.append(path)
92
+ return found
93
+
94
+
95
+ def main(argv: list[str] | None = None) -> int:
96
+ import argparse
97
+
98
+ parser = argparse.ArgumentParser(description="Build one contact sheet from slide previews.")
99
+ parser.add_argument("images", nargs="*", help="preview images and/or directories of them")
100
+ parser.add_argument("--dir", help="a directory of preview images to include (same as passing it positionally)")
101
+ parser.add_argument("--out", required=True, help="path of the contact sheet to write")
102
+ parser.add_argument("--columns", type=int, default=3, help="grid width (default 3)")
103
+ parser.add_argument("--json-out", help="also write the report here")
104
+ args = parser.parse_args(argv)
105
+ if not args.images and not args.dir:
106
+ parser.error("give at least one image, one directory, or --dir")
107
+
108
+ images = _collect(args)
109
+ report = build_sheet(images, Path(args.out), args.columns) if images else {"ok": False, "error": {"code": "no_images", "message": "no preview images found"}}
110
+ if args.json_out:
111
+ Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
112
+ json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
113
+ sys.stdout.write("\n")
114
+ return 0 if report.get("ok") else 2
115
+
116
+
117
+ if __name__ == "__main__":
118
+ raise SystemExit(main())