dsh-office-kit 0.1.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 (198) hide show
  1. package/CAPABILITY.md +147 -68
  2. package/LICENSE.md +21 -21
  3. package/README.md +108 -106
  4. package/assets/skills/excel-live-control/SKILL.md +234 -0
  5. package/assets/skills/excel-live-control/container_tools/live_excel.py +1589 -0
  6. package/assets/skills/excel-live-control/domain_guidance/financial_models.md +86 -0
  7. package/assets/skills/excel-live-control/domain_guidance/healthcare.md +58 -0
  8. package/assets/skills/excel-live-control/domain_guidance/marketing_advertising.md +53 -0
  9. package/assets/skills/excel-live-control/domain_guidance/scientific_research.md +53 -0
  10. package/assets/skills/excel-live-control/references/office-automation.md +207 -0
  11. package/assets/skills/excel-live-control/references/read_only_qna.md +59 -0
  12. package/assets/skills/excel-live-control/style_guidelines.md +87 -0
  13. package/assets/skills/excel-live-control/workflows/create_workflows.md +78 -0
  14. package/assets/skills/excel-live-control/workflows/edit_workflows.md +72 -0
  15. package/assets/skills/office-documents/SKILL.md +90 -77
  16. package/assets/skills/office-documents/examples/end_to_end_smoke_test.md +86 -0
  17. package/assets/skills/office-documents/ooxml/comments.md +40 -0
  18. package/assets/skills/office-documents/ooxml/hyperlinks_and_fields.md +66 -0
  19. package/assets/skills/office-documents/ooxml/rels_and_content_types.md +62 -0
  20. package/assets/skills/office-documents/ooxml/tracked_changes.md +53 -0
  21. package/assets/skills/office-documents/references/docx-structure.md +83 -0
  22. package/assets/skills/office-documents/references/template-elicitation.md +49 -0
  23. package/assets/skills/office-documents/references/writing-quality.md +49 -0
  24. package/assets/skills/office-documents/render_docx.py +92 -0
  25. package/assets/skills/office-documents/scripts/a11y_audit.py +194 -0
  26. package/assets/skills/office-documents/scripts/accept_tracked_changes.py +151 -0
  27. package/assets/skills/office-documents/scripts/add_tracked_replacements.py +148 -0
  28. package/assets/skills/office-documents/scripts/append_blocks.py +108 -0
  29. package/assets/skills/office-documents/scripts/apply_template_styles.py +104 -0
  30. package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +102 -0
  31. package/assets/skills/office-documents/scripts/comments_add.py +136 -0
  32. package/assets/skills/office-documents/scripts/comments_apply_patch.py +114 -0
  33. package/assets/skills/office-documents/scripts/comments_extract.py +85 -0
  34. package/assets/skills/office-documents/scripts/comments_strip.py +74 -0
  35. package/assets/skills/office-documents/scripts/content_controls.py +192 -0
  36. package/assets/skills/office-documents/scripts/core_properties.py +124 -0
  37. package/assets/skills/office-documents/scripts/docx_kit.py +587 -0
  38. package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +225 -0
  39. package/assets/skills/office-documents/scripts/docx_table_to_csv.py +99 -0
  40. package/assets/skills/office-documents/scripts/fields_materialize.py +66 -0
  41. package/assets/skills/office-documents/scripts/fields_report.py +59 -0
  42. package/assets/skills/office-documents/scripts/flatten_ref_fields.py +62 -0
  43. package/assets/skills/office-documents/scripts/footnotes_report.py +77 -0
  44. package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +96 -0
  45. package/assets/skills/office-documents/scripts/heading_audit.py +81 -0
  46. package/assets/skills/office-documents/scripts/images_audit.py +80 -0
  47. package/assets/skills/office-documents/scripts/insert_note.py +126 -0
  48. package/assets/skills/office-documents/scripts/insert_ref_fields.py +90 -0
  49. package/assets/skills/office-documents/scripts/insert_toc.py +84 -0
  50. package/assets/skills/office-documents/scripts/internal_nav.py +146 -0
  51. package/assets/skills/office-documents/scripts/make_fixtures.py +169 -0
  52. package/assets/skills/office-documents/scripts/merge_docx_append.py +239 -0
  53. package/assets/skills/office-documents/scripts/privacy_scrub.py +131 -0
  54. package/assets/skills/office-documents/scripts/redact_docx.py +113 -0
  55. package/assets/skills/office-documents/scripts/render_and_diff.py +101 -0
  56. package/assets/skills/office-documents/scripts/replace_text.py +154 -0
  57. package/assets/skills/office-documents/scripts/section_audit.py +91 -0
  58. package/assets/skills/office-documents/scripts/set_protection.py +92 -0
  59. package/assets/skills/office-documents/scripts/style_lint.py +111 -0
  60. package/assets/skills/office-documents/scripts/style_normalize.py +103 -0
  61. package/assets/skills/office-documents/scripts/table_geometry.py +167 -0
  62. package/assets/skills/office-documents/scripts/watermark_add.py +48 -0
  63. package/assets/skills/office-documents/scripts/watermark_audit_remove.py +67 -0
  64. package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +117 -0
  65. package/assets/skills/office-documents/tasks/accessibility_a11y.md +45 -0
  66. package/assets/skills/office-documents/tasks/captions_crossrefs.md +49 -0
  67. package/assets/skills/office-documents/tasks/clean_tracked_changes.md +49 -0
  68. package/assets/skills/office-documents/tasks/comments_manage.md +59 -0
  69. package/assets/skills/office-documents/tasks/compare_diff.md +40 -0
  70. package/assets/skills/office-documents/tasks/create_edit.md +107 -0
  71. package/assets/skills/office-documents/tasks/equations.md +59 -0
  72. package/assets/skills/office-documents/tasks/fields_update.md +44 -0
  73. package/assets/skills/office-documents/tasks/fixtures_edge_cases.md +50 -0
  74. package/assets/skills/office-documents/tasks/footnotes_endnotes.md +38 -0
  75. package/assets/skills/office-documents/tasks/forms_content_controls.md +48 -0
  76. package/assets/skills/office-documents/tasks/headings_numbering.md +55 -0
  77. package/assets/skills/office-documents/tasks/images_figures.md +63 -0
  78. package/assets/skills/office-documents/tasks/multi_doc_merge.md +43 -0
  79. package/assets/skills/office-documents/tasks/navigation_internal_links.md +38 -0
  80. package/assets/skills/office-documents/tasks/privacy_scrub_metadata.md +45 -0
  81. package/assets/skills/office-documents/tasks/protection_restrict_editing.md +41 -0
  82. package/assets/skills/office-documents/tasks/read_review.md +50 -0
  83. package/assets/skills/office-documents/tasks/redaction_anonymization.md +42 -0
  84. package/assets/skills/office-documents/tasks/sections_layout.md +54 -0
  85. package/assets/skills/office-documents/tasks/style_lint_normalize.md +44 -0
  86. package/assets/skills/office-documents/tasks/tables_spreadsheets.md +64 -0
  87. package/assets/skills/office-documents/tasks/templates_style_packs.md +53 -0
  88. package/assets/skills/office-documents/tasks/toc_workflow.md +39 -0
  89. package/assets/skills/office-documents/tasks/verify_render.md +50 -0
  90. package/assets/skills/office-documents/tasks/watermarks_background.md +35 -0
  91. package/assets/skills/office-documents/troubleshooting/libreoffice_headless.md +47 -0
  92. package/assets/skills/office-documents/troubleshooting/run_splitting.md +36 -0
  93. package/assets/skills/office-pdf/SKILL.md +73 -56
  94. package/assets/skills/office-pdf/references/form-fields.md +56 -0
  95. package/assets/skills/office-pdf/references/geometry.md +44 -0
  96. package/assets/skills/office-pdf/references/layout-quality.md +41 -0
  97. package/assets/skills/office-pdf/references/limits.md +58 -0
  98. package/assets/skills/office-pdf/scripts/extract_text.py +172 -0
  99. package/assets/skills/office-pdf/scripts/forms.py +556 -0
  100. package/assets/skills/office-pdf/scripts/metadata.py +161 -0
  101. package/assets/skills/office-pdf/scripts/page_ops.py +198 -0
  102. package/assets/skills/office-pdf/scripts/redact_region.py +347 -0
  103. package/assets/skills/office-pdf/scripts/render_pages.py +207 -0
  104. package/assets/skills/office-pdf/scripts/split_pages.py +155 -0
  105. package/assets/skills/office-pdf/scripts/stamp_overlay.py +199 -0
  106. package/assets/skills/office-pdf/tasks/annotations.md +98 -0
  107. package/assets/skills/office-pdf/tasks/create.md +75 -0
  108. package/assets/skills/office-pdf/tasks/edit-pages.md +71 -0
  109. package/assets/skills/office-pdf/tasks/forms.md +67 -0
  110. package/assets/skills/office-pdf/tasks/read.md +62 -0
  111. package/assets/skills/office-pdf/tasks/redact.md +72 -0
  112. package/assets/skills/office-pdf/tasks/stamp.md +60 -0
  113. package/assets/skills/office-presentations/SKILL.md +141 -87
  114. package/assets/skills/office-presentations/container_tools/add_slides.py +136 -0
  115. package/assets/skills/office-presentations/container_tools/core_properties.py +120 -0
  116. package/assets/skills/office-presentations/container_tools/create_montage.py +126 -0
  117. package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +285 -0
  118. package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +157 -0
  119. package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +144 -0
  120. package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +199 -0
  121. package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +212 -0
  122. package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +345 -0
  123. package/assets/skills/office-presentations/container_tools/preview_render.py +375 -0
  124. package/assets/skills/office-presentations/container_tools/render_slides.py +139 -0
  125. package/assets/skills/office-presentations/container_tools/replace_text.py +168 -0
  126. package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +229 -0
  127. package/assets/skills/office-presentations/domain_guidance/finance.md +66 -0
  128. package/assets/skills/office-presentations/recipes/README.md +71 -0
  129. package/assets/skills/office-presentations/recipes/autolayout.md +88 -0
  130. package/assets/skills/office-presentations/recipes/connectors.md +83 -0
  131. package/assets/skills/office-presentations/recipes/images.md +80 -0
  132. package/assets/skills/office-presentations/recipes/master-and-layouts.md +127 -0
  133. package/assets/skills/office-presentations/recipes/native-charts.md +87 -0
  134. package/assets/skills/office-presentations/recipes/rich-text-runs.md +86 -0
  135. package/assets/skills/office-presentations/recipes/speaker-notes.md +65 -0
  136. package/assets/skills/office-presentations/recipes/tables.md +88 -0
  137. package/assets/skills/office-presentations/recipes/textbox-at-coordinate.md +71 -0
  138. package/assets/skills/office-presentations/recipes/theme-tokens.md +87 -0
  139. package/assets/skills/office-presentations/references/authoring-workflow.md +65 -0
  140. package/assets/skills/office-presentations/references/finalization.md +63 -0
  141. package/assets/skills/office-presentations/references/native-bullets.md +78 -0
  142. package/assets/skills/office-presentations/references/native-evidence.md +67 -0
  143. package/assets/skills/office-presentations/references/style-guidelines.md +66 -0
  144. package/assets/skills/office-presentations/references/templates.md +76 -0
  145. package/assets/skills/office-presentations/routing/google_slides.md +45 -0
  146. package/assets/skills/office-presentations/tasks/apply-annotations.md +83 -0
  147. package/assets/skills/office-presentations/tasks/charts-tables-notes.md +79 -0
  148. package/assets/skills/office-presentations/tasks/create-deck.md +106 -0
  149. package/assets/skills/office-presentations/tasks/edit-deck.md +93 -0
  150. package/assets/skills/office-presentations/tasks/inspect-and-verify.md +107 -0
  151. package/assets/skills/office-presentations/tasks/template-following.md +132 -0
  152. package/assets/skills/office-presentations/template_following_scripts/check_template_fidelity.py +176 -0
  153. package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +154 -0
  154. package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +111 -0
  155. package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +239 -0
  156. package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +185 -0
  157. package/assets/skills/office-spreadsheets/SKILL.md +105 -101
  158. package/assets/skills/office-spreadsheets/container_tools/add_chart.py +236 -0
  159. package/assets/skills/office-spreadsheets/container_tools/add_image.py +107 -0
  160. package/assets/skills/office-spreadsheets/container_tools/append_rows.py +122 -0
  161. package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +306 -0
  162. package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +398 -0
  163. package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +275 -0
  164. package/assets/skills/office-spreadsheets/container_tools/set_cells.py +135 -0
  165. package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +134 -0
  166. package/assets/skills/office-spreadsheets/container_tools/style_cells.py +133 -0
  167. package/assets/skills/office-spreadsheets/recipes/charts.md +66 -0
  168. package/assets/skills/office-spreadsheets/recipes/conditional-formats.md +69 -0
  169. package/assets/skills/office-spreadsheets/recipes/data-tables.md +58 -0
  170. package/assets/skills/office-spreadsheets/recipes/named-ranges.md +41 -0
  171. package/assets/skills/office-spreadsheets/recipes/number-formats.md +72 -0
  172. package/assets/skills/office-spreadsheets/recipes/sparklines.md +43 -0
  173. package/assets/skills/office-spreadsheets/references/charts.md +63 -0
  174. package/assets/skills/office-spreadsheets/references/domain/financial-models.md +40 -0
  175. package/assets/skills/office-spreadsheets/references/domain/healthcare.md +22 -0
  176. package/assets/skills/office-spreadsheets/references/domain/marketing.md +28 -0
  177. package/assets/skills/office-spreadsheets/references/domain/research.md +22 -0
  178. package/assets/skills/office-spreadsheets/references/formula-patterns.md +78 -0
  179. package/assets/skills/office-spreadsheets/references/style-guidelines.md +69 -0
  180. package/assets/skills/office-spreadsheets/references/workbook-structure.md +60 -0
  181. package/assets/skills/office-spreadsheets/routing/google-sheets.md +36 -0
  182. package/assets/skills/office-spreadsheets/tasks/analyze-workbook.md +37 -0
  183. package/assets/skills/office-spreadsheets/tasks/annotations.md +51 -0
  184. package/assets/skills/office-spreadsheets/tasks/create-workbook.md +66 -0
  185. package/assets/skills/office-spreadsheets/tasks/diff-workbooks.md +38 -0
  186. package/assets/skills/office-spreadsheets/tasks/edit-workbook.md +69 -0
  187. package/assets/skills/office-spreadsheets/tasks/inspect-workbook.md +38 -0
  188. package/assets/skills/office-spreadsheets/tasks/recalc-and-check.md +57 -0
  189. package/assets/skills/shared/annotation-recipes.md +252 -0
  190. package/assets/skills/shared/check_runtime.py +253 -0
  191. package/assets/skills/shared/common-workflow.md +139 -0
  192. package/assets/skills/shared/scripts/ensure_raster_image.py +187 -0
  193. package/dist/client.js +4 -4
  194. package/dist/client.js.map +1 -1
  195. package/dist/index.js +239 -89
  196. package/package.json +146 -149
  197. package/postinstall.mjs +92 -82
  198. package/python/office_cli.py +171 -246
@@ -0,0 +1,111 @@
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
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "container_tools"))
19
+
20
+
21
+ def _natural_key(path: Path) -> list:
22
+ import re
23
+
24
+ return [int(part) if part.isdigit() else part.lower() for part in re.split(r"(\d+)", path.stem)]
25
+
26
+
27
+ def build_sheet(images: list[Path], out_path: Path, columns: int = 3, label_height: int = 48, padding: int = 18) -> dict:
28
+ from PIL import Image, ImageDraw
29
+
30
+ columns = max(1, columns)
31
+ rows = max(1, math.ceil(len(images) / columns))
32
+ thumbs = []
33
+ cell_w = 0
34
+ cell_h = 0
35
+ for path in images:
36
+ try:
37
+ with Image.open(path) as opened:
38
+ thumb = opened.convert("RGB")
39
+ thumbs.append((path, thumb.copy(), None))
40
+ cell_w = max(cell_w, thumb.width)
41
+ cell_h = max(cell_h, thumb.height)
42
+ except Exception as exc: # noqa: BLE001 - unreadable previews become placeholder cells
43
+ thumbs.append((path, None, str(exc)))
44
+ if not cell_w or not cell_h:
45
+ return {"ok": False, "error": {"code": "no_readable_images", "message": "none of the inputs could be opened"}}
46
+
47
+ width = columns * cell_w + (columns + 1) * padding
48
+ height = rows * (cell_h + label_height) + (rows + 1) * padding
49
+ sheet = Image.new("RGB", (width, height), (246, 246, 246))
50
+ draw = ImageDraw.Draw(sheet)
51
+
52
+ for index, (path, thumb, error) in enumerate(thumbs):
53
+ row, column = divmod(index, columns)
54
+ x = padding + column * (cell_w + padding)
55
+ y = padding + row * (cell_h + label_height + padding)
56
+ if thumb is None:
57
+ draw.rectangle([x, y, x + cell_w, y + cell_h], fill=(232, 232, 232), outline=(180, 60, 60), width=3)
58
+ draw.line([x, y, x + cell_w, y + cell_h], fill=(180, 60, 60), width=3)
59
+ draw.line([x + cell_w, y, x, y + cell_h], fill=(180, 60, 60), width=3)
60
+ else:
61
+ sheet.paste(thumb, (x, y))
62
+ draw.rectangle([x, y, x + cell_w, y + cell_h], outline=(190, 190, 190), width=1)
63
+ draw.text((x + 4, y + cell_h + 8), path.stem, fill=(40, 40, 40))
64
+
65
+ out_path.parent.mkdir(parents=True, exist_ok=True)
66
+ sheet.save(out_path)
67
+ return {
68
+ "ok": True,
69
+ "output": str(out_path.resolve()),
70
+ "cells": len(thumbs),
71
+ "grid": {"columns": columns, "rows": rows},
72
+ "size": [width, height],
73
+ "unreadable": [path.name for path, thumb, _ in thumbs if thumb is None],
74
+ }
75
+
76
+
77
+ def _collect(args) -> list[Path]:
78
+ found: list[Path] = []
79
+ for value in [*args.images, *([args.dir] if args.dir else [])]:
80
+ path = Path(value)
81
+ if path.is_dir():
82
+ found.extend(sorted((item for item in path.iterdir() if item.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"}), key=_natural_key))
83
+ else:
84
+ found.append(path)
85
+ return found
86
+
87
+
88
+ def main(argv: list[str] | None = None) -> int:
89
+ import argparse
90
+
91
+ parser = argparse.ArgumentParser(description="Build one contact sheet from slide previews.")
92
+ parser.add_argument("images", nargs="*", help="preview images and/or directories of them")
93
+ parser.add_argument("--dir", help="a directory of preview images to include (same as passing it positionally)")
94
+ parser.add_argument("--out", required=True, help="path of the contact sheet to write")
95
+ parser.add_argument("--columns", type=int, default=3, help="grid width (default 3)")
96
+ parser.add_argument("--json-out", help="also write the report here")
97
+ args = parser.parse_args(argv)
98
+ if not args.images and not args.dir:
99
+ parser.error("give at least one image, one directory, or --dir")
100
+
101
+ images = _collect(args)
102
+ report = build_sheet(images, Path(args.out), args.columns) if images else {"ok": False, "error": {"code": "no_images", "message": "no preview images found"}}
103
+ if args.json_out:
104
+ Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
105
+ json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
106
+ sys.stdout.write("\n")
107
+ return 0 if report.get("ok") else 2
108
+
109
+
110
+ if __name__ == "__main__":
111
+ raise SystemExit(main())
@@ -0,0 +1,239 @@
1
+ """Build a starter deck out of the slides the plan keeps.
2
+
3
+ Purpose: the safe way to follow a supplied design is to keep its actual slides,
4
+ not to redraw a lookalike. This copies the template file, keeps the mapped
5
+ source slides in plan order, drops the rest, then applies the element actions
6
+ that only move text: keep, delete, fill-placeholder, rewrite and
7
+ rewrite-and-reposition.
8
+
9
+ Ceiling: slides are reused, not cloned. Two output slides may not map to the
10
+ same source slide, and 'replace' is left to the author (build the new element
11
+ with a recipe, then run check_template_fidelity). Charts are never rebuilt here.
12
+
13
+ Exit codes: 0 starter written, 1 plan rejected, 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, placeholder_kind, slide_size_in # noqa: E402
25
+
26
+ PLACEHOLDER_ALIASES = {
27
+ "title": ("TITLE", "CENTER_TITLE"),
28
+ "subtitle": ("SUBTITLE",),
29
+ "body": ("BODY", "OBJECT"),
30
+ }
31
+
32
+
33
+ def _slides(prs) -> list:
34
+ return list(prs.slides)
35
+
36
+
37
+ def _drop_slide(prs, slide) -> None:
38
+ slide_id = None
39
+ for element in prs.slides._sldIdLst:
40
+ if element.rId == slide.part.relate_to(slide.part, "") if False else False:
41
+ break
42
+ # find the sldId whose relationship points at this slide part
43
+ rels = prs.part.rels
44
+ rId = None
45
+ for key, relationship in rels.items():
46
+ if relationship.reltype.endswith("/slide") and relationship.target_part is slide.part:
47
+ rId = key
48
+ break
49
+ if rId is None:
50
+ return
51
+ for element in list(prs.slides._sldIdLst):
52
+ if element.rId == rId:
53
+ slide_id = element
54
+ break
55
+ if slide_id is not None:
56
+ prs.slides._sldIdLst.remove(slide_id)
57
+ prs.part.drop_rel(rId)
58
+
59
+
60
+ def _keep_only(prs, wanted: list[int]) -> None:
61
+ ordered = [element for element in prs.slides._sldIdLst]
62
+ keep_rids = []
63
+ for position in wanted:
64
+ keep_rids.append(ordered[position - 1].rId)
65
+ for slide in list(prs.slides):
66
+ current = None
67
+ for key, relationship in prs.part.rels.items():
68
+ if relationship.reltype.endswith("/slide") and relationship.target_part is slide.part:
69
+ current = key
70
+ break
71
+ if current not in keep_rids:
72
+ _drop_slide(prs, slide)
73
+ # reorder what survived to match the plan
74
+ by_rid = {element.rId: element for element in prs.slides._sldIdLst}
75
+ for element in list(prs.slides._sldIdLst):
76
+ prs.slides._sldIdLst.remove(element)
77
+ for rId in keep_rids:
78
+ prs.slides._sldIdLst.append(by_rid[rId])
79
+
80
+
81
+ def _find_shape(slide, element_id: str):
82
+ shapes = list(slide.shapes)
83
+ if element_id.isdigit():
84
+ index = int(element_id) - 1
85
+ return shapes[index] if 0 <= index < len(shapes) else None
86
+ for shape in shapes:
87
+ if shape.name == element_id:
88
+ return shape
89
+ if element_id.lower().startswith("shape-") and element_id[6:].isdigit():
90
+ index = int(element_id[6:]) - 1
91
+ return shapes[index] if 0 <= index < len(shapes) else None
92
+ return None
93
+
94
+
95
+ def _find_placeholder(slide, alias: str):
96
+ wanted = PLACEHOLDER_ALIASES.get(alias.lower())
97
+ for shape in slide.shapes:
98
+ if not shape.is_placeholder:
99
+ continue
100
+ kind = (placeholder_kind(shape) or "").upper()
101
+ if alias.isdigit() and shape.placeholder_format.idx == int(alias):
102
+ return shape
103
+ if wanted and kind in wanted:
104
+ return shape
105
+ if alias.lower() == (placeholder_kind(shape) or "").lower():
106
+ return shape
107
+ return None
108
+
109
+
110
+ def _set_text(shape, text: str) -> None:
111
+ frame = shape.text_frame
112
+ paragraphs = list(frame.paragraphs)
113
+ first = paragraphs[0]
114
+ runs = list(first.runs)
115
+ if runs:
116
+ runs[0].text = text
117
+ for run in runs[1:]:
118
+ run._r.getparent().remove(run._r)
119
+ for paragraph in paragraphs[1:]:
120
+ paragraph._p.getparent().remove(paragraph._p)
121
+ else:
122
+ frame.text = text
123
+
124
+
125
+ def build_starter(template_path: str | Path, plan_path: str | Path, out_path: str | Path) -> dict:
126
+ from pptx import Presentation
127
+
128
+ template = Path(template_path)
129
+ plan_file = Path(plan_path)
130
+ if not template.is_file():
131
+ return {"ok": False, "error": {"code": "template_missing", "message": str(template)}}
132
+ if not plan_file.is_file():
133
+ return {"ok": False, "error": {"code": "plan_missing", "message": str(plan_file)}}
134
+
135
+ plan = json.loads(plan_file.read_text(encoding="utf-8"))
136
+ entries = plan.get("outputSlides") or []
137
+ if not entries:
138
+ return {"ok": False, "error": {"code": "plan_empty", "message": "outputSlides is empty"}}
139
+
140
+ sources = [int(entry.get("sourceSlide", 0)) for entry in entries]
141
+ duplicates = sorted({value for value in sources if sources.count(value) > 1})
142
+ if duplicates:
143
+ return {
144
+ "ok": False,
145
+ "error": {
146
+ "code": "duplicate_source_slide",
147
+ "message": f"source slides {duplicates} are mapped more than once; this builder reuses slides instead of cloning them",
148
+ },
149
+ }
150
+
151
+ prs = Presentation(str(template))
152
+ total = len(prs.slides)
153
+ out_of_range = [value for value in sources if not 1 <= value <= total]
154
+ if out_of_range:
155
+ return {"ok": False, "error": {"code": "source_out_of_range", "message": f"template has {total} slides, plan asks for {out_of_range}"}}
156
+
157
+ slide_w, slide_h = slide_size_in(prs)
158
+ _keep_only(prs, sources)
159
+ kept = _slides(prs)
160
+
161
+ applied: list[dict] = []
162
+ for position, entry in enumerate(entries):
163
+ slide = kept[position]
164
+ for element in entry.get("elements", []):
165
+ element_id = str(element.get("elementId", ""))
166
+ action = str(element.get("action", "keep"))
167
+ target = element.get("target") or {}
168
+ if action == "keep":
169
+ continue
170
+ if action == "fill-placeholder":
171
+ alias = str(target.get("placeholder") or "")
172
+ shape = _find_placeholder(slide, alias) if alias else _find_shape(slide, element_id)
173
+ if shape is None:
174
+ applied.append({"slide": position + 1, "element": element_id, "action": action, "status": "not_found"})
175
+ continue
176
+ if target.get("text") is not None:
177
+ _set_text(shape, str(target["text"]))
178
+ applied.append({"slide": position + 1, "element": shape.name, "action": action, "status": "applied"})
179
+ continue
180
+ shape = _find_shape(slide, element_id)
181
+ if shape is None:
182
+ applied.append({"slide": position + 1, "element": element_id, "action": action, "status": "not_found"})
183
+ continue
184
+ if action == "delete":
185
+ shape._element.getparent().remove(shape._element)
186
+ applied.append({"slide": position + 1, "element": element_id, "action": action, "status": "applied"})
187
+ continue
188
+ if action in {"rewrite", "rewrite-and-reposition"}:
189
+ _set_text(shape, str(target.get("text", "")))
190
+ if action == "rewrite-and-reposition" and isinstance(target.get("zone"), list):
191
+ x, y, width, height = (float(value) for value in target["zone"])
192
+ shape.left = int(x * slide_w * 914400)
193
+ shape.top = int(y * slide_h * 914400)
194
+ shape.width = int(width * slide_w * 914400)
195
+ shape.height = int(height * slide_h * 914400)
196
+ applied.append({"slide": position + 1, "element": element_id, "action": action, "status": "applied"})
197
+ continue
198
+ applied.append({"slide": position + 1, "element": element_id, "action": action, "status": "left_for_author"})
199
+
200
+ destination = Path(out_path)
201
+ destination.parent.mkdir(parents=True, exist_ok=True)
202
+ prs.save(str(destination))
203
+
204
+ leftovers = []
205
+ for index, slide in enumerate(kept, start=1):
206
+ for shape in slide.shapes:
207
+ if shape.is_placeholder and not (shape.text_frame.text or "").strip():
208
+ leftovers.append({"slide": index, "placeholder": shape.name, "bboxIn": [round(value, 2) for value in (box(shape) or (0, 0, 0, 0))]})
209
+
210
+ return {
211
+ "ok": True,
212
+ "starter": str(destination.resolve()),
213
+ "slides": len(kept),
214
+ "sourceSlides": sources,
215
+ "applied": applied,
216
+ "emptyPlaceholders": leftovers,
217
+ "note": "empty placeholders still show their prompt in edit view; fill or delete them before delivery",
218
+ }
219
+
220
+
221
+ def main(argv: list[str] | None = None) -> int:
222
+ import argparse
223
+
224
+ parser = argparse.ArgumentParser(description="Build a starter deck from the template slides a plan keeps.")
225
+ parser.add_argument("--template", required=True, help="path to the template .pptx")
226
+ parser.add_argument("--plan", required=True, help="path to template-frame-map.json")
227
+ parser.add_argument("--out", required=True, help="path of the starter .pptx to write")
228
+ args = parser.parse_args(argv)
229
+
230
+ report = build_starter(args.template, args.plan, args.out)
231
+ json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
232
+ sys.stdout.write("\n")
233
+ if report.get("ok"):
234
+ return 0
235
+ return 2 if report["error"]["code"] in {"template_missing", "plan_missing"} else 1
236
+
237
+
238
+ if __name__ == "__main__":
239
+ raise SystemExit(main())
@@ -0,0 +1,185 @@
1
+ """Check a template plan before any slide is touched.
2
+
3
+ Purpose: a template plan is a contract between "what the design gives you" and
4
+ "what you intend to write". Validating it first turns a silent redesign into a
5
+ list of violations, because the expensive mistakes (deleting a logo, inventing
6
+ new blocks the brand never had, covering inherited art) are all visible on
7
+ paper before anything is built.
8
+
9
+ Plan shape (template-frame-map.json):
10
+ {
11
+ "template": "path/to/template.pptx",
12
+ "newPrimitives": {"allowed": false, "justification": ""},
13
+ "outputSlides": [
14
+ {"slide": 1, "mode": "duplicate-slide", "sourceSlide": 3,
15
+ "elements": [{"elementId": "shape-7", "action": "fill-placeholder",
16
+ "target": {"placeholder": "title", "text": "Q3 results"}}]}
17
+ ]
18
+ }
19
+
20
+ Exit codes: 0 valid, 1 violations, 2 unreadable plan.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "container_tools"))
30
+
31
+ ALLOWED_ACTIONS = {"rewrite", "rewrite-and-reposition", "replace", "delete", "keep", "fill-placeholder"}
32
+ PRESERVE_ONLY_ROLES = {"brand", "logo", "bumper", "divider", "section", "chrome", "footer"}
33
+ MODES = {"duplicate-slide"}
34
+
35
+
36
+ def _violation(code: str, message: str, severity: str = "fail", **extra) -> dict:
37
+ return {"code": code, "severity": severity, "message": message, **extra}
38
+
39
+
40
+ def validate_plan(plan: dict) -> dict:
41
+ violations: list[dict] = []
42
+ warnings: list[dict] = []
43
+
44
+ output_slides = plan.get("outputSlides")
45
+ if not isinstance(output_slides, list) or not output_slides:
46
+ return {"gate": "template-plan", "passed": False, "violations": [_violation("missing_output_slides", "the plan declares no output slides")], "checks": 0}
47
+
48
+ indices = []
49
+ sources: dict[int, int] = {}
50
+ element_actions = 0
51
+ for position, entry in enumerate(output_slides, start=1):
52
+ if not isinstance(entry, dict):
53
+ violations.append(_violation("malformed_slide", f"output slide #{position} is not an object"))
54
+ continue
55
+ slide_no = entry.get("slide", position)
56
+ indices.append(slide_no)
57
+ mode = entry.get("mode", "duplicate-slide")
58
+ if mode not in MODES:
59
+ violations.append(_violation("unsupported_mode", f"slide {slide_no}: mode '{mode}' is not supported", slide=slide_no))
60
+ source = entry.get("sourceSlide")
61
+ if not isinstance(source, int) or source < 1:
62
+ violations.append(_violation("missing_source_slide", f"slide {slide_no}: sourceSlide must be a positive integer", slide=slide_no))
63
+ else:
64
+ if source in sources:
65
+ warnings.append(
66
+ _violation(
67
+ "duplicated_source_slide",
68
+ f"slides {sources[source]} and {slide_no} both duplicate source slide {source}",
69
+ severity="review",
70
+ slide=slide_no,
71
+ )
72
+ )
73
+ sources[source] = slide_no
74
+ elements = entry.get("elements", [])
75
+ if not isinstance(elements, list):
76
+ violations.append(_violation("malformed_elements", f"slide {slide_no}: elements must be a list", slide=slide_no))
77
+ continue
78
+ for element in elements:
79
+ if not isinstance(element, dict) or not element.get("elementId"):
80
+ violations.append(_violation("missing_element_id", f"slide {slide_no}: an element has no elementId", slide=slide_no))
81
+ continue
82
+ element_actions += 1
83
+ element_id = element["elementId"]
84
+ action = element.get("action")
85
+ target = element.get("target") or {}
86
+ if action not in ALLOWED_ACTIONS:
87
+ violations.append(
88
+ _violation(
89
+ "unknown_action",
90
+ f"slide {slide_no} element {element_id}: '{action}' is not one of {sorted(ALLOWED_ACTIONS)}",
91
+ slide=slide_no,
92
+ elementId=element_id,
93
+ )
94
+ )
95
+ continue
96
+ role = str(target.get("role") or element.get("role") or "").lower()
97
+ if role in PRESERVE_ONLY_ROLES and action != "keep":
98
+ violations.append(
99
+ _violation(
100
+ "preserve_only_element",
101
+ f"slide {slide_no} element {element_id}: role '{role}' must stay untouched, found '{action}'",
102
+ slide=slide_no,
103
+ elementId=element_id,
104
+ )
105
+ )
106
+ if action == "fill-placeholder" and not (target.get("placeholder") or target.get("text")):
107
+ violations.append(
108
+ _violation("placeholder_target_missing", f"slide {slide_no} element {element_id}: fill-placeholder needs a placeholder or text target", slide=slide_no, elementId=element_id)
109
+ )
110
+ if action in {"rewrite", "rewrite-and-reposition"} and not target.get("text"):
111
+ violations.append(
112
+ _violation("rewrite_text_missing", f"slide {slide_no} element {element_id}: {action} needs target.text", slide=slide_no, elementId=element_id)
113
+ )
114
+ if action == "rewrite-and-reposition":
115
+ zone = target.get("zone")
116
+ if not (isinstance(zone, list) and len(zone) == 4 and all(isinstance(value, (int, float)) for value in zone)):
117
+ violations.append(
118
+ _violation("zone_missing", f"slide {slide_no} element {element_id}: rewrite-and-reposition needs a zone [x, y, w, h]", slide=slide_no, elementId=element_id)
119
+ )
120
+ else:
121
+ x, y, width, height = zone
122
+ if not all(0.0 <= value <= 1.0 for value in zone) or x + width > 1.0001 or y + height > 1.0001:
123
+ violations.append(
124
+ _violation("zone_out_of_bounds", f"slide {slide_no} element {element_id}: zone {zone} does not fit inside 0-1", slide=slide_no, elementId=element_id)
125
+ )
126
+ if action == "replace":
127
+ warnings.append(
128
+ _violation(
129
+ "replace_needs_authoring",
130
+ f"slide {slide_no} element {element_id}: 'replace' is applied by the author with a recipe, not by the starter-deck builder",
131
+ severity="review",
132
+ slide=slide_no,
133
+ elementId=element_id,
134
+ )
135
+ )
136
+
137
+ if indices != list(range(1, len(output_slides) + 1)):
138
+ violations.append(_violation("non_sequential_slides", f"output slide numbers must start at 1 and be sequential, found {indices}"))
139
+
140
+ primitives = plan.get("newPrimitives") or {}
141
+ if primitives.get("allowed") is True and not str(primitives.get("justification") or "").strip():
142
+ violations.append(_violation("new_primitives_unjustified", "newPrimitives.allowed is true but no justification was given"))
143
+
144
+ blocking = [item for item in violations if item["severity"] == "fail"]
145
+ return {
146
+ "gate": "template-plan",
147
+ "passed": not blocking,
148
+ "checks": {"outputSlides": len(output_slides), "elementActions": element_actions},
149
+ "violations": violations,
150
+ "warnings": warnings,
151
+ }
152
+
153
+
154
+ def main(argv: list[str] | None = None) -> int:
155
+ import argparse
156
+
157
+ parser = argparse.ArgumentParser(description="Validate a template-frame-map before building the deck.")
158
+ parser.add_argument("--plan", required=True, help="path to template-frame-map.json")
159
+ parser.add_argument("--template", help="optionally confirm the plan names an existing template file")
160
+ parser.add_argument("--json-out", help="also write the report here")
161
+ args = parser.parse_args(argv)
162
+
163
+ try:
164
+ plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
165
+ except Exception as exc: # noqa: BLE001 - a bad plan is the finding
166
+ json.dump({"gate": "template-plan", "passed": False, "error": {"code": "unparsable_plan", "message": str(exc)}}, sys.stdout)
167
+ sys.stdout.write("\n")
168
+ return 2
169
+
170
+ report = validate_plan(plan)
171
+ if args.template:
172
+ declared = plan.get("template") or args.template
173
+ report["template"] = str(declared)
174
+ if not Path(declared).is_file():
175
+ report["violations"].append({"code": "template_missing", "severity": "fail", "message": f"template not found: {declared}"})
176
+ report["passed"] = False
177
+ if args.json_out:
178
+ Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
179
+ json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
180
+ sys.stdout.write("\n")
181
+ return 0 if report["passed"] else 1
182
+
183
+
184
+ if __name__ == "__main__":
185
+ raise SystemExit(main())