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,375 @@
1
+ """Draft slide preview: rasterise a .pptx into one PNG per slide without Office.
2
+
3
+ Purpose: `render_slides.py` needs pixels to look at, and a machine may have no
4
+ office renderer at all. This module reads the package with
5
+ python-pptx and redraws every shape onto a PyMuPDF page: rectangles, text at
6
+ its declared size, table grids, images, and a bar/line/pie sketch of charts.
7
+ It then rasterises each page to PNG.
8
+
9
+ What it proves and what it does not:
10
+ * proves geometry — what sits where, what collides, what leaves the slide,
11
+ and which text boxes ran out of room (PyMuPDF tells us when a textbox
12
+ truncates);
13
+ * does NOT prove typography — fonts fall back to a built-in face and theme
14
+ colours/fills that python-pptx cannot resolve are approximated.
15
+
16
+ Exit codes: 0 ok, 2 unreadable input, 3 nothing rasterised.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import sys
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
27
+
28
+ try: # PyMuPDF ships both names; prefer the current one
29
+ import pymupdf as fitz # noqa: E402
30
+ except ImportError: # pragma: no cover - older wheels only expose fitz
31
+ import fitz # noqa: E402
32
+
33
+ from ooxml_helpers import ( # noqa: E402
34
+ EMU_PER_INCH,
35
+ box,
36
+ default_size_pt,
37
+ estimated_text_height_in,
38
+ has_text_frame,
39
+ is_chart,
40
+ is_picture,
41
+ is_table,
42
+ iter_shapes,
43
+ open_presentation,
44
+ placeholder_kind,
45
+ run_size_pt,
46
+ slide_size_in,
47
+ text_of,
48
+ WIDE_CHAR,
49
+ )
50
+
51
+ INK = (0.13, 0.15, 0.18)
52
+ MUTED = (0.42, 0.45, 0.50)
53
+ RULE = (0.80, 0.82, 0.85)
54
+ ACCENT = (0.16, 0.36, 0.62)
55
+ PANEL = (0.96, 0.97, 0.98)
56
+ CJK_FONT = "china-s"
57
+
58
+
59
+ def _has_cjk(text: str) -> bool:
60
+ return any(WIDE_CHAR.match(char) for char in text)
61
+
62
+
63
+ def _font_for(text: str, size: float) -> tuple[str, str | None]:
64
+ if _has_cjk(text):
65
+ return CJK_FONT, None
66
+ return ("helv" if size < 20 else "hebo"), None
67
+
68
+
69
+ def _solid_rgb(shape) -> tuple[float, float, float] | None:
70
+ try:
71
+ from pptx.enum.dml import MSO_FILL
72
+
73
+ if shape.fill.type != MSO_FILL.SOLID:
74
+ return None
75
+ rgb = shape.fill.fore_color.rgb
76
+ return (rgb[0] / 255, rgb[1] / 255, rgb[2] / 255)
77
+ except Exception: # noqa: BLE001 - theme colours raise; preview just skips them
78
+ return None
79
+
80
+
81
+ def _text_rgb(shape) -> tuple[float, float, float]:
82
+ for paragraph in getattr(shape, "text_frame", None).paragraphs if has_text_frame(shape) else []:
83
+ for run in paragraph.runs:
84
+ try:
85
+ rgb = run.font.color.rgb
86
+ except Exception: # noqa: BLE001
87
+ continue
88
+ if rgb is not None:
89
+ return (rgb[0] / 255, rgb[1] / 255, rgb[2] / 255)
90
+ return INK
91
+
92
+
93
+ def _draw_text(page, rect: fitz.Rect, text: str, size: float, color, align: int = 0) -> float:
94
+ if not text.strip():
95
+ return 1.0
96
+ fontname, _ = _font_for(text, size)
97
+ try:
98
+ return page.insert_textbox(rect, text, fontsize=size, fontname=fontname, color=color, align=align, lineheight=1.22)
99
+ except Exception: # noqa: BLE001 - a bad font name must not kill the render
100
+ return page.insert_textbox(rect, text, fontsize=size, color=color, align=align)
101
+
102
+
103
+ def _draw_shape(page, shape, slide_no: int, notes: list[dict[str, Any]]) -> None:
104
+ geometry = box(shape)
105
+ if geometry is None:
106
+ return
107
+ left, top, width, height = geometry
108
+ rect = fitz.Rect(left * 72, top * 72, (left + width) * 72, (top + height) * 72)
109
+ if rect.is_empty:
110
+ return
111
+
112
+ if is_picture(shape):
113
+ try:
114
+ page.insert_image(rect, stream=shape.image.blob, keep_proportion=False)
115
+ except Exception as exc: # noqa: BLE001
116
+ notes.append({"slide": slide_no, "shape": shape.name, "issue": f"picture not drawn: {exc}"})
117
+ return
118
+
119
+ if is_table(shape):
120
+ _draw_table(page, shape, rect, slide_no, notes)
121
+ return
122
+
123
+ if is_chart(shape):
124
+ _draw_chart(page, shape, rect, slide_no, notes)
125
+ return
126
+
127
+ fill = _solid_rgb(shape)
128
+ if fill is not None:
129
+ page.draw_rect(rect, color=RULE, fill=fill, width=0.6)
130
+ elif getattr(shape, "shape_type", None) is not None and shape.shape_type is not None:
131
+ page.draw_rect(rect, color=RULE, width=0.4, dashes="[2 2] 0")
132
+
133
+ if not has_text_frame(shape):
134
+ return
135
+
136
+ frame = shape.text_frame
137
+ inset = fitz.Rect(
138
+ rect.x0 + (frame.margin_left or 0) / EMU_PER_INCH * 72,
139
+ rect.y0 + (frame.margin_top or 0) / EMU_PER_INCH * 72,
140
+ rect.x1 - (frame.margin_right or 0) / EMU_PER_INCH * 72,
141
+ rect.y1 - (frame.margin_bottom or 0) / EMU_PER_INCH * 72,
142
+ )
143
+ text = text_of(shape)
144
+ if not text.strip():
145
+ return
146
+ size = run_size_pt(shape) or default_size_pt(shape)
147
+ color = _text_rgb(shape)
148
+ kind = (placeholder_kind(shape) or "").upper()
149
+ align = 1 if kind in {"CENTER_TITLE", "TITLE"} else 0
150
+ # Title-like placeholders inherit middle anchoring from the master; the
151
+ # preview would otherwise pin every title to the top of its box.
152
+ if kind in {"CENTER_TITLE", "TITLE", "SUBTITLE"}:
153
+ needed = estimated_text_height_in(shape) or 0.0
154
+ slack = inset.height - needed * 72
155
+ if slack > 4:
156
+ inset = fitz.Rect(inset.x0, inset.y0 + slack / 2, inset.x1, inset.y1)
157
+ spare = _draw_text(page, inset, text, size, color, align)
158
+ if spare < 0:
159
+ # PyMuPDF draws nothing at all when the text cannot fit. Grow the frame
160
+ # downward and retry so the preview shows the overflow instead of an
161
+ # empty box, and still report it.
162
+ grown = fitz.Rect(inset.x0, inset.y0, inset.x1, inset.y1 + abs(spare) + 24)
163
+ _draw_text(page, grown, text, size, color, align)
164
+ notes.append(
165
+ {
166
+ "slide": slide_no,
167
+ "shape": shape.name,
168
+ "issue": "text overflows its frame",
169
+ "fontPt": round(size, 1),
170
+ "overflowPt": round(-spare, 1),
171
+ }
172
+ )
173
+
174
+
175
+ def _draw_table(page, shape, rect: fitz.Rect, slide_no: int, notes: list[dict[str, Any]]) -> None:
176
+ table = shape.table
177
+ columns = len(table.columns)
178
+ rows = len(table.rows)
179
+ if not columns or not rows:
180
+ return
181
+ widths = [column.width or 0 for column in table.columns]
182
+ total_w = sum(widths) or 1
183
+ heights = [row.height or int(0.4 * EMU_PER_INCH) for row in table.rows]
184
+ total_h = sum(heights) or 1
185
+ x = rect.x0
186
+ y = rect.y0
187
+ for row_index in range(rows):
188
+ row_h = rect.height * (heights[row_index] / total_h)
189
+ x = rect.x0
190
+ for column_index in range(columns):
191
+ column_w = rect.width * (widths[column_index] / total_w)
192
+ cell_rect = fitz.Rect(x, y, x + column_w, y + row_h)
193
+ page.draw_rect(cell_rect, color=RULE, width=0.5, fill=PANEL if row_index == 0 else None)
194
+ cell = table.cell(row_index, column_index)
195
+ cell_text = (cell.text or "").strip()
196
+ if cell_text:
197
+ size = 10.0 if row_index else 10.5
198
+ inner = fitz.Rect(cell_rect.x0 + 4, cell_rect.y0 + 2, cell_rect.x1 - 4, cell_rect.y1 - 2)
199
+ spare = _draw_text(page, inner, cell_text, size, INK)
200
+ if spare < 0:
201
+ notes.append({"slide": slide_no, "shape": f"{shape.name}[{row_index},{column_index}]", "issue": "table cell clips"})
202
+ x += column_w
203
+ y += row_h
204
+
205
+
206
+ def _draw_chart(page, shape, rect: fitz.Rect, slide_no: int, notes: list[dict[str, Any]]) -> None:
207
+ chart = shape.chart
208
+ page.draw_rect(rect, color=RULE, fill=(1, 1, 1), width=0.8)
209
+ title = ""
210
+ try:
211
+ if chart.has_title and chart.chart_title.text_frame.text.strip():
212
+ title = chart.chart_title.text_frame.text.strip()
213
+ except Exception: # noqa: BLE001
214
+ title = ""
215
+ plot = fitz.Rect(rect.x0 + 18, rect.y0 + 12, rect.x1 - 14, rect.y1 - 26)
216
+ if title:
217
+ _draw_text(page, fitz.Rect(plot.x0, rect.y0 + 4, plot.x1, rect.y0 + 26), title, 11, INK)
218
+ plot = fitz.Rect(plot.x0, rect.y0 + 30, plot.x1, rect.y1 - 26)
219
+
220
+ try:
221
+ categories = [str(category) for category in chart.plots[0].categories]
222
+ except Exception: # noqa: BLE001
223
+ categories = []
224
+ series: list[tuple[str, list[float]]] = []
225
+ try:
226
+ for plot_series in chart.series:
227
+ values = [float(value) if value is not None else 0.0 for value in plot_series.values]
228
+ series.append((str(plot_series.name), values))
229
+ except Exception: # noqa: BLE001 - a broken chart cache must not kill the render
230
+ notes.append({"slide": slide_no, "shape": shape.name, "issue": "chart values unreadable"})
231
+
232
+ if not series or not categories:
233
+ _draw_text(page, plot, "chart data unavailable", 11, MUTED)
234
+ return
235
+
236
+ kind = str(getattr(chart, "chart_type", ""))
237
+ max_value = max([abs(value) for _, values in series for value in values] + [1.0])
238
+ baseline = plot.y1 - 16
239
+
240
+ if "PIE" in kind.upper() or "DOUGHNUT" in kind.upper():
241
+ values = series[0][1]
242
+ total = sum(abs(value) for value in values) or 1.0
243
+ center = fitz.Point((plot.x0 + plot.x1) / 2, (plot.y0 + baseline) / 2)
244
+ radius = min(plot.width, baseline - plot.y0) * 0.34
245
+ start = 0.0
246
+ shades = [0.25, 0.40, 0.55, 0.70, 0.85, 0.95]
247
+ for index, value in enumerate(values):
248
+ sweep = 360.0 * abs(value) / total
249
+ shade = shades[index % len(shades)]
250
+ page.draw_pie(center, radius, fitz.Point(radius * 1.0, 0), start, start + sweep, color=RULE, fill=(shade, shade, shade * 0.95 + 0.05), width=0.6)
251
+ start += sweep
252
+ return
253
+
254
+ if "LINE" in kind.upper():
255
+ step = plot.width / max(len(categories) - 1, 1)
256
+ for series_index, (_, values) in enumerate(series):
257
+ points = []
258
+ for index, value in enumerate(values):
259
+ x = plot.x0 + step * index
260
+ y = baseline - (abs(value) / max_value) * (baseline - plot.y0 - 6)
261
+ points.append(fitz.Point(x, y))
262
+ if len(points) > 1:
263
+ shade = 0.20 + 0.18 * series_index
264
+ page.draw_polyline(points, color=(shade, shade, shade + 0.1), width=1.6)
265
+ for point in points:
266
+ page.draw_circle(point, 2.0, color=ACCENT, fill=ACCENT)
267
+ _draw_categories(page, plot, baseline, categories)
268
+ return
269
+
270
+ group_width = plot.width / max(len(categories), 1)
271
+ bar_width = group_width * 0.6 / max(len(series), 1)
272
+ for index, category in enumerate(categories):
273
+ for series_index, (_, values) in enumerate(series):
274
+ if index >= len(values):
275
+ continue
276
+ value = abs(values[index])
277
+ bar_height = (value / max_value) * (baseline - plot.y0 - 8)
278
+ x0 = plot.x0 + group_width * index + group_width * 0.2 + bar_width * series_index
279
+ bar = fitz.Rect(x0, baseline - bar_height, x0 + bar_width * 0.92, baseline)
280
+ shade = 0.30 + 0.22 * series_index
281
+ page.draw_rect(bar, color=ACCENT, fill=(shade * 0.55, shade * 0.75, shade), width=0.5)
282
+ _draw_text(page, fitz.Rect(bar.x0 - 6, bar.y0 - 12, bar.x1 + 6, bar.y0), _short(values[index]), 7.5, MUTED, 1)
283
+ page.draw_line(fitz.Point(plot.x0, baseline), fitz.Point(plot.x1, baseline), color=RULE, width=0.8)
284
+ _draw_categories(page, plot, baseline, categories)
285
+
286
+
287
+ def _short(value: float) -> str:
288
+ return f"{value:g}"
289
+
290
+
291
+ def _draw_categories(page, plot: fitz.Rect, baseline: float, categories: list[str]) -> None:
292
+ group_width = plot.width / max(max(len(categories), 1), 1)
293
+ for index, category in enumerate(categories):
294
+ cell = fitz.Rect(
295
+ plot.x0 + group_width * index,
296
+ baseline + 2,
297
+ plot.x0 + group_width * (index + 1) - 2,
298
+ baseline + 18,
299
+ )
300
+ _draw_text(page, cell, category[:18], 7.5, MUTED, 1)
301
+
302
+
303
+ def render_preview(pptx_path: str | Path, out_dir: str | Path, width_px: int = 1600) -> dict[str, Any]:
304
+ """Rasterise every slide to `<out_dir>/slide-NN.png`; returns a JSON-able report."""
305
+ presentation = open_presentation(pptx_path)
306
+ destination = Path(out_dir)
307
+ destination.mkdir(parents=True, exist_ok=True)
308
+ slide_w_in, slide_h_in = slide_size_in(presentation)
309
+ page_w = slide_w_in * 72
310
+ page_h = slide_h_in * 72
311
+ zoom = max(1.0, width_px / page_w)
312
+
313
+ files: list[str] = []
314
+ notes: list[dict[str, Any]] = []
315
+ document = fitz.open()
316
+ try:
317
+ for slide_no, slide in enumerate(presentation.slides, start=1):
318
+ page = document.new_page(width=page_w, height=page_h)
319
+ page.draw_rect(fitz.Rect(0, 0, page_w, page_h), color=None, fill=(1, 1, 1))
320
+ try:
321
+ background = slide.background.fill
322
+ rgb = background.fore_color.rgb
323
+ page.draw_rect(
324
+ fitz.Rect(0, 0, page_w, page_h),
325
+ color=None,
326
+ fill=(rgb[0] / 255, rgb[1] / 255, rgb[2] / 255),
327
+ )
328
+ except Exception: # noqa: BLE001 - most decks inherit a white master
329
+ pass
330
+ for shape in iter_shapes(slide.shapes):
331
+ _draw_shape(page, shape, slide_no, notes)
332
+ page.insert_text(
333
+ fitz.Point(page_w - 54, page_h - 10),
334
+ f"{slide_no}",
335
+ fontsize=7,
336
+ fontname="helv",
337
+ color=MUTED,
338
+ )
339
+ png = destination / f"slide-{slide_no:02d}.png"
340
+ page.get_pixmap(matrix=fitz.Matrix(zoom, zoom)).save(str(png))
341
+ files.append(str(png.resolve()))
342
+ finally:
343
+ document.close()
344
+
345
+ report = {
346
+ "ok": True,
347
+ "engine": "draft-preview",
348
+ "fidelity": "geometry-only",
349
+ "note": "Shapes redrawn from OOXML; fonts and theme colours are approximations. Use it to judge placement, collisions and overflow, not typography.",
350
+ "slides": len(files),
351
+ "slideSize": [round(slide_w_in, 3), round(slide_h_in, 3)],
352
+ "files": files,
353
+ "overflow": notes,
354
+ }
355
+ (destination / "preview.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
356
+ return report
357
+
358
+
359
+ def main(argv: list[str] | None = None) -> int:
360
+ import argparse
361
+
362
+ parser = argparse.ArgumentParser(description="Rasterise a .pptx to PNGs using a draft geometry renderer (no Office required).")
363
+ parser.add_argument("presentation", help="path to the .pptx")
364
+ parser.add_argument("--out-dir", required=True, help="directory for slide-NN.png and preview.json")
365
+ parser.add_argument("--width", type=int, default=1600, help="output pixel width (default 1600)")
366
+ args = parser.parse_args(argv)
367
+
368
+ report = render_preview(args.presentation, args.out_dir, args.width)
369
+ json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
370
+ sys.stdout.write("\n")
371
+ return 0 if report["slides"] else 3
372
+
373
+
374
+ if __name__ == "__main__":
375
+ raise SystemExit(main())
@@ -0,0 +1,139 @@
1
+ """Render a deck to PNGs, one per slide, using whatever engine is available.
2
+
3
+ Purpose: the verification loop needs pixels. Ask the plugin CLI first: it drives
4
+ Microsoft Office, WPS Office, or LibreOffice, whichever is installed. When none
5
+ is, fall back to the draft geometry renderer in `preview_render.py` and say so
6
+ loudly in the JSON — a draft preview proves placement, not typography.
7
+
8
+ Inputs may be a .pptx or a .pdf. Output is `<out-dir>/slide-NN.png` for every
9
+ page, plus `<out-dir>/render.json`.
10
+
11
+ Exit codes:
12
+ 0 at least one page rendered
13
+ 2 input missing or unreadable
14
+ 3 no engine produced output
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import shutil
21
+ import subprocess
22
+ import sys
23
+ import tempfile
24
+ from pathlib import Path
25
+
26
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
27
+
28
+
29
+ def office_cli() -> Path:
30
+ """The plugin's helper CLI, which owns renderer discovery."""
31
+ return Path(__file__).resolve().parents[4] / "python" / "office_cli.py"
32
+
33
+
34
+ def rasterise_pdf(pdf_path: Path, out_dir: Path, dpi: int) -> list[str]:
35
+ try:
36
+ import pymupdf as fitz
37
+ except ImportError: # pragma: no cover
38
+ import fitz
39
+
40
+ files: list[str] = []
41
+ document = fitz.open(str(pdf_path))
42
+ try:
43
+ zoom = dpi / 72.0
44
+ for index, page in enumerate(document, start=1):
45
+ png = out_dir / f"slide-{index:02d}.png"
46
+ page.get_pixmap(matrix=fitz.Matrix(zoom, zoom)).save(str(png))
47
+ files.append(str(png))
48
+ finally:
49
+ document.close()
50
+ return files
51
+
52
+
53
+ def render_with_office(source: Path, out_dir: Path, dpi: int) -> dict:
54
+ """Export through the CLI, which tries every installed office suite."""
55
+ scratch = Path(tempfile.mkdtemp(prefix="dsh-render-"))
56
+ try:
57
+ completed = subprocess.run(
58
+ [sys.executable, str(office_cli()), "pptx", "render",
59
+ "--input", str(source), "--out-dir", str(scratch), "--dpi", str(dpi)],
60
+ capture_output=True, text=True, timeout=300, check=False,
61
+ )
62
+ if completed.returncode != 0:
63
+ raise RuntimeError((completed.stderr or completed.stdout or "render failed").strip()[:400])
64
+ report = json.loads(completed.stdout)
65
+ files: list[str] = []
66
+ for index, page in enumerate(report.get("pages") or [], start=1):
67
+ target = out_dir / f"slide-{index:02d}.png"
68
+ shutil.move(page, target)
69
+ files.append(str(target))
70
+ return {
71
+ "ok": bool(files),
72
+ "engine": report.get("renderer") or "office",
73
+ "fidelity": "faithful",
74
+ "slides": len(files),
75
+ "dpi": dpi,
76
+ "files": files,
77
+ }
78
+ finally:
79
+ shutil.rmtree(scratch, ignore_errors=True)
80
+
81
+
82
+ def render(input_path: str | Path, out_dir: str | Path, dpi: int = 110, engine: str = "auto", width: int = 1600) -> dict:
83
+ source = Path(input_path)
84
+ destination = Path(out_dir)
85
+ if not source.is_file():
86
+ return {"ok": False, "error": {"code": "input_missing", "message": f"no such file: {source}"}}
87
+ destination.mkdir(parents=True, exist_ok=True)
88
+
89
+ if source.suffix.lower() == ".pdf":
90
+ files = rasterise_pdf(source, destination, dpi)
91
+ return {"ok": bool(files), "engine": "pdf-raster", "fidelity": "faithful", "slides": len(files), "dpi": dpi, "files": files}
92
+
93
+ notes: list[str] = []
94
+ if engine in {"auto", "office"}:
95
+ try:
96
+ return render_with_office(source, destination, dpi)
97
+ except Exception as exc: # noqa: BLE001 - fall through to the draft engine
98
+ notes.append(f"office render failed: {exc}")
99
+
100
+ if engine == "office":
101
+ return {"ok": False, "engine": "office", "notes": notes, "error": {"code": "no_engine", "message": "no Microsoft Office, WPS Office, or LibreOffice renderer is available"}}
102
+
103
+ from preview_render import render_preview
104
+
105
+ report = render_preview(source, destination, width)
106
+ report["notes"] = notes + [
107
+ "Draft preview: geometry is exact, type is approximate. Treat colours and fonts as indicative only.",
108
+ ]
109
+ report["files"] = [str(Path(item).resolve()) for item in report["files"]]
110
+ return report
111
+
112
+
113
+ def main(argv: list[str] | None = None) -> int:
114
+ import argparse
115
+
116
+ parser = argparse.ArgumentParser(description="Render a .pptx or .pdf into one PNG per page.")
117
+ parser.add_argument("--input", required=True, help="deck or PDF to render")
118
+ parser.add_argument("--out-dir", required=True, help="directory for slide-NN.png and render.json")
119
+ parser.add_argument("--dpi", type=int, default=110, help="raster dpi when a real renderer is used (default 110)")
120
+ parser.add_argument("--width", type=int, default=1600, help="pixel width for the draft renderer (default 1600)")
121
+ parser.add_argument("--engine", choices=("auto", "office", "draft"), default="auto", help="force an engine (default auto)")
122
+ parser.add_argument("--json-out", help="also write the report here")
123
+ args = parser.parse_args(argv)
124
+
125
+ report = render(args.input, args.out_dir, args.dpi, args.engine, args.width)
126
+ out_dir = Path(args.out_dir)
127
+ out_dir.mkdir(parents=True, exist_ok=True)
128
+ (out_dir / "render.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
129
+ if args.json_out:
130
+ Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
131
+ json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
132
+ sys.stdout.write("\n")
133
+ if report.get("ok"):
134
+ return 0
135
+ return 2 if report.get("error", {}).get("code") == "input_missing" else 3
136
+
137
+
138
+ if __name__ == "__main__":
139
+ raise SystemExit(main())
@@ -0,0 +1,168 @@
1
+ """Replace text inside runs of an existing deck.
2
+
3
+ Run-level by design: a match only counts when the whole `find` string lives in a
4
+ single run. Text split across runs by earlier formatting is *not* merged, which
5
+ keeps formatting intact and makes the outcome predictable — the tool reports
6
+ what it matched, and reports what it could not match.
7
+
8
+ Usage
9
+ -----
10
+ <python> replace_text.py --input deck.pptx --find "Q4 2025" --replace "Q4 2026"
11
+ <python> replace_text.py --input deck.pptx --spec replacements.json --out fixed.pptx
12
+
13
+ Exit codes
14
+ ----------
15
+ 0 every requested pattern was applied at least once
16
+ 1 a pattern matched nothing (the file is still written; check the report)
17
+ 2 bad arguments, unreadable input or invalid spec
18
+ 3 the output file could not be written
19
+
20
+ The JSON report on stdout is `{ok, input, output, total, slides[], unmatched[]}`.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import sys
27
+ from pathlib import Path
28
+
29
+
30
+ def _emit(payload: dict, code: int = 0) -> int:
31
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
32
+ return code
33
+
34
+
35
+ def _load_spec(path: str | None) -> list[dict]:
36
+ if not path:
37
+ return []
38
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
39
+ if isinstance(data, dict):
40
+ entries = data.get("replacements") or []
41
+ elif isinstance(data, list):
42
+ entries = data
43
+ else:
44
+ raise ValueError("spec must be an object with 'replacements' or a list")
45
+ resolved = []
46
+ for entry in entries:
47
+ if not isinstance(entry, dict) or "find" not in entry:
48
+ raise ValueError(f"replacement needs a 'find' key: {entry!r}")
49
+ resolved.append(
50
+ {
51
+ "find": str(entry["find"]),
52
+ "replace": str(entry.get("replace", "")),
53
+ "slide": entry.get("slide"),
54
+ "match_case": bool(entry.get("match_case", True)),
55
+ }
56
+ )
57
+ return resolved
58
+
59
+
60
+ def _frames(slide):
61
+ for shape in slide.shapes:
62
+ if shape.has_text_frame:
63
+ yield shape.text_frame
64
+ if getattr(shape, "has_table", False) and shape.has_table:
65
+ for row in shape.table.rows:
66
+ for cell in row.cells:
67
+ yield cell.text_frame
68
+
69
+
70
+ def replace_in_slide(slide, rules: list[dict]) -> list[dict]:
71
+ hits = []
72
+ for frame in _frames(slide):
73
+ for paragraph in frame.paragraphs:
74
+ for run in paragraph.runs:
75
+ original = run.text
76
+ updated = original
77
+ for rule in rules:
78
+ needle = rule["find"] if rule["match_case"] else rule["find"].lower()
79
+ haystack = updated if rule["match_case"] else updated.lower()
80
+ if needle and needle in haystack:
81
+ updated = updated.replace(rule["find"], rule["replace"])
82
+ hits.append({"find": rule["find"], "before": original, "after": updated})
83
+ if updated != original:
84
+ run.text = updated
85
+ return hits
86
+
87
+
88
+ def main() -> int:
89
+ parser = argparse.ArgumentParser(
90
+ description="Replace text inside runs of a .pptx (never across runs).",
91
+ epilog="Exit codes: 0 all patterns applied, 1 a pattern matched nothing, 2 bad input, 3 write failure.",
92
+ )
93
+ parser.add_argument("--input", required=True, help="source deck")
94
+ parser.add_argument("--find", action="append", default=[], help="text to find (repeatable)")
95
+ parser.add_argument("--replace", action="append", default=[], help="replacement (repeatable, pairs with --find)")
96
+ parser.add_argument("--spec", help="JSON file: {replacements:[{find, replace, slide?, match_case?}]}")
97
+ parser.add_argument("--out", help="output deck (default: <input>-edited.pptx)")
98
+ args = parser.parse_args()
99
+
100
+ rules: list[dict] = []
101
+ try:
102
+ for index, needle in enumerate(args.find):
103
+ rules.append(
104
+ {
105
+ "find": needle,
106
+ "replace": args.replace[index] if index < len(args.replace) else "",
107
+ "slide": None,
108
+ "match_case": True,
109
+ }
110
+ )
111
+ rules.extend(_load_spec(args.spec))
112
+ except (OSError, ValueError, json.JSONDecodeError) as error:
113
+ return _emit({"ok": False, "error": f"bad spec: {error}"}, 2)
114
+
115
+ if not rules:
116
+ return _emit({"ok": False, "error": "nothing to replace: pass --find/--replace or --spec"}, 2)
117
+
118
+ source = Path(args.input)
119
+ if not source.is_file():
120
+ return _emit({"ok": False, "error": f"no such file: {source}"}, 2)
121
+ output = Path(args.out) if args.out else source.with_name(f"{source.stem}-edited{source.suffix}")
122
+
123
+ try:
124
+ from pptx import Presentation
125
+ except ImportError as error:
126
+ return _emit({"ok": False, "error": f"python-pptx is required: {error}"}, 2)
127
+
128
+ try:
129
+ prs = Presentation(str(source))
130
+ except Exception as error: # noqa: BLE001 - report, never traceback at the user
131
+ return _emit({"ok": False, "error": f"cannot open {source}: {error}"}, 2)
132
+
133
+ slides_report = []
134
+ total = 0
135
+ matched_patterns: set[str] = set()
136
+ for index, slide in enumerate(prs.slides, start=1):
137
+ applicable = [rule for rule in rules if rule["slide"] in (None, index)]
138
+ hits = replace_in_slide(slide, applicable)
139
+ for hit in hits:
140
+ matched_patterns.add(hit["find"])
141
+ total += len(hits)
142
+ if hits:
143
+ slides_report.append({"slide": index, "replacements": len(hits), "details": hits[:10]})
144
+
145
+ unmatched = sorted({rule["find"] for rule in rules} - matched_patterns)
146
+
147
+ try:
148
+ output.parent.mkdir(parents=True, exist_ok=True)
149
+ prs.save(str(output))
150
+ except Exception as error: # noqa: BLE001
151
+ return _emit({"ok": False, "error": f"cannot write {output}: {error}"}, 3)
152
+
153
+ report = {
154
+ "schema": "replace_text/1",
155
+ "ok": not unmatched,
156
+ "input": str(source),
157
+ "output": str(output),
158
+ "rules": len(rules),
159
+ "total": total,
160
+ "slides": slides_report,
161
+ "unmatched": unmatched,
162
+ "note": "matches only inside a single run; text split by formatting is left alone",
163
+ }
164
+ return _emit(report, 0 if not unmatched else 1)
165
+
166
+
167
+ if __name__ == "__main__":
168
+ sys.exit(main())