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,382 @@
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
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
52
+ for _stream in (sys.stdout, sys.stderr):
53
+ try:
54
+ _stream.reconfigure(encoding="utf-8", errors="replace")
55
+ except (AttributeError, ValueError):
56
+ pass
57
+
58
+ INK = (0.13, 0.15, 0.18)
59
+ MUTED = (0.42, 0.45, 0.50)
60
+ RULE = (0.80, 0.82, 0.85)
61
+ ACCENT = (0.16, 0.36, 0.62)
62
+ PANEL = (0.96, 0.97, 0.98)
63
+ CJK_FONT = "china-s"
64
+
65
+
66
+ def _has_cjk(text: str) -> bool:
67
+ return any(WIDE_CHAR.match(char) for char in text)
68
+
69
+
70
+ def _font_for(text: str, size: float) -> tuple[str, str | None]:
71
+ if _has_cjk(text):
72
+ return CJK_FONT, None
73
+ return ("helv" if size < 20 else "hebo"), None
74
+
75
+
76
+ def _solid_rgb(shape) -> tuple[float, float, float] | None:
77
+ try:
78
+ from pptx.enum.dml import MSO_FILL
79
+
80
+ if shape.fill.type != MSO_FILL.SOLID:
81
+ return None
82
+ rgb = shape.fill.fore_color.rgb
83
+ return (rgb[0] / 255, rgb[1] / 255, rgb[2] / 255)
84
+ except Exception: # noqa: BLE001 - theme colours raise; preview just skips them
85
+ return None
86
+
87
+
88
+ def _text_rgb(shape) -> tuple[float, float, float]:
89
+ for paragraph in getattr(shape, "text_frame", None).paragraphs if has_text_frame(shape) else []:
90
+ for run in paragraph.runs:
91
+ try:
92
+ rgb = run.font.color.rgb
93
+ except Exception: # noqa: BLE001
94
+ continue
95
+ if rgb is not None:
96
+ return (rgb[0] / 255, rgb[1] / 255, rgb[2] / 255)
97
+ return INK
98
+
99
+
100
+ def _draw_text(page, rect: fitz.Rect, text: str, size: float, color, align: int = 0) -> float:
101
+ if not text.strip():
102
+ return 1.0
103
+ fontname, _ = _font_for(text, size)
104
+ try:
105
+ return page.insert_textbox(rect, text, fontsize=size, fontname=fontname, color=color, align=align, lineheight=1.22)
106
+ except Exception: # noqa: BLE001 - a bad font name must not kill the render
107
+ return page.insert_textbox(rect, text, fontsize=size, color=color, align=align)
108
+
109
+
110
+ def _draw_shape(page, shape, slide_no: int, notes: list[dict[str, Any]]) -> None:
111
+ geometry = box(shape)
112
+ if geometry is None:
113
+ return
114
+ left, top, width, height = geometry
115
+ rect = fitz.Rect(left * 72, top * 72, (left + width) * 72, (top + height) * 72)
116
+ if rect.is_empty:
117
+ return
118
+
119
+ if is_picture(shape):
120
+ try:
121
+ page.insert_image(rect, stream=shape.image.blob, keep_proportion=False)
122
+ except Exception as exc: # noqa: BLE001
123
+ notes.append({"slide": slide_no, "shape": shape.name, "issue": f"picture not drawn: {exc}"})
124
+ return
125
+
126
+ if is_table(shape):
127
+ _draw_table(page, shape, rect, slide_no, notes)
128
+ return
129
+
130
+ if is_chart(shape):
131
+ _draw_chart(page, shape, rect, slide_no, notes)
132
+ return
133
+
134
+ fill = _solid_rgb(shape)
135
+ if fill is not None:
136
+ page.draw_rect(rect, color=RULE, fill=fill, width=0.6)
137
+ elif getattr(shape, "shape_type", None) is not None and shape.shape_type is not None:
138
+ page.draw_rect(rect, color=RULE, width=0.4, dashes="[2 2] 0")
139
+
140
+ if not has_text_frame(shape):
141
+ return
142
+
143
+ frame = shape.text_frame
144
+ inset = fitz.Rect(
145
+ rect.x0 + (frame.margin_left or 0) / EMU_PER_INCH * 72,
146
+ rect.y0 + (frame.margin_top or 0) / EMU_PER_INCH * 72,
147
+ rect.x1 - (frame.margin_right or 0) / EMU_PER_INCH * 72,
148
+ rect.y1 - (frame.margin_bottom or 0) / EMU_PER_INCH * 72,
149
+ )
150
+ text = text_of(shape)
151
+ if not text.strip():
152
+ return
153
+ size = run_size_pt(shape) or default_size_pt(shape)
154
+ color = _text_rgb(shape)
155
+ kind = (placeholder_kind(shape) or "").upper()
156
+ align = 1 if kind in {"CENTER_TITLE", "TITLE"} else 0
157
+ # Title-like placeholders inherit middle anchoring from the master; the
158
+ # preview would otherwise pin every title to the top of its box.
159
+ if kind in {"CENTER_TITLE", "TITLE", "SUBTITLE"}:
160
+ needed = estimated_text_height_in(shape) or 0.0
161
+ slack = inset.height - needed * 72
162
+ if slack > 4:
163
+ inset = fitz.Rect(inset.x0, inset.y0 + slack / 2, inset.x1, inset.y1)
164
+ spare = _draw_text(page, inset, text, size, color, align)
165
+ if spare < 0:
166
+ # PyMuPDF draws nothing at all when the text cannot fit. Grow the frame
167
+ # downward and retry so the preview shows the overflow instead of an
168
+ # empty box, and still report it.
169
+ grown = fitz.Rect(inset.x0, inset.y0, inset.x1, inset.y1 + abs(spare) + 24)
170
+ _draw_text(page, grown, text, size, color, align)
171
+ notes.append(
172
+ {
173
+ "slide": slide_no,
174
+ "shape": shape.name,
175
+ "issue": "text overflows its frame",
176
+ "fontPt": round(size, 1),
177
+ "overflowPt": round(-spare, 1),
178
+ }
179
+ )
180
+
181
+
182
+ def _draw_table(page, shape, rect: fitz.Rect, slide_no: int, notes: list[dict[str, Any]]) -> None:
183
+ table = shape.table
184
+ columns = len(table.columns)
185
+ rows = len(table.rows)
186
+ if not columns or not rows:
187
+ return
188
+ widths = [column.width or 0 for column in table.columns]
189
+ total_w = sum(widths) or 1
190
+ heights = [row.height or int(0.4 * EMU_PER_INCH) for row in table.rows]
191
+ total_h = sum(heights) or 1
192
+ x = rect.x0
193
+ y = rect.y0
194
+ for row_index in range(rows):
195
+ row_h = rect.height * (heights[row_index] / total_h)
196
+ x = rect.x0
197
+ for column_index in range(columns):
198
+ column_w = rect.width * (widths[column_index] / total_w)
199
+ cell_rect = fitz.Rect(x, y, x + column_w, y + row_h)
200
+ page.draw_rect(cell_rect, color=RULE, width=0.5, fill=PANEL if row_index == 0 else None)
201
+ cell = table.cell(row_index, column_index)
202
+ cell_text = (cell.text or "").strip()
203
+ if cell_text:
204
+ size = 10.0 if row_index else 10.5
205
+ inner = fitz.Rect(cell_rect.x0 + 4, cell_rect.y0 + 2, cell_rect.x1 - 4, cell_rect.y1 - 2)
206
+ spare = _draw_text(page, inner, cell_text, size, INK)
207
+ if spare < 0:
208
+ notes.append({"slide": slide_no, "shape": f"{shape.name}[{row_index},{column_index}]", "issue": "table cell clips"})
209
+ x += column_w
210
+ y += row_h
211
+
212
+
213
+ def _draw_chart(page, shape, rect: fitz.Rect, slide_no: int, notes: list[dict[str, Any]]) -> None:
214
+ chart = shape.chart
215
+ page.draw_rect(rect, color=RULE, fill=(1, 1, 1), width=0.8)
216
+ title = ""
217
+ try:
218
+ if chart.has_title and chart.chart_title.text_frame.text.strip():
219
+ title = chart.chart_title.text_frame.text.strip()
220
+ except Exception: # noqa: BLE001
221
+ title = ""
222
+ plot = fitz.Rect(rect.x0 + 18, rect.y0 + 12, rect.x1 - 14, rect.y1 - 26)
223
+ if title:
224
+ _draw_text(page, fitz.Rect(plot.x0, rect.y0 + 4, plot.x1, rect.y0 + 26), title, 11, INK)
225
+ plot = fitz.Rect(plot.x0, rect.y0 + 30, plot.x1, rect.y1 - 26)
226
+
227
+ try:
228
+ categories = [str(category) for category in chart.plots[0].categories]
229
+ except Exception: # noqa: BLE001
230
+ categories = []
231
+ series: list[tuple[str, list[float]]] = []
232
+ try:
233
+ for plot_series in chart.series:
234
+ values = [float(value) if value is not None else 0.0 for value in plot_series.values]
235
+ series.append((str(plot_series.name), values))
236
+ except Exception: # noqa: BLE001 - a broken chart cache must not kill the render
237
+ notes.append({"slide": slide_no, "shape": shape.name, "issue": "chart values unreadable"})
238
+
239
+ if not series or not categories:
240
+ _draw_text(page, plot, "chart data unavailable", 11, MUTED)
241
+ return
242
+
243
+ kind = str(getattr(chart, "chart_type", ""))
244
+ max_value = max([abs(value) for _, values in series for value in values] + [1.0])
245
+ baseline = plot.y1 - 16
246
+
247
+ if "PIE" in kind.upper() or "DOUGHNUT" in kind.upper():
248
+ values = series[0][1]
249
+ total = sum(abs(value) for value in values) or 1.0
250
+ center = fitz.Point((plot.x0 + plot.x1) / 2, (plot.y0 + baseline) / 2)
251
+ radius = min(plot.width, baseline - plot.y0) * 0.34
252
+ start = 0.0
253
+ shades = [0.25, 0.40, 0.55, 0.70, 0.85, 0.95]
254
+ for index, value in enumerate(values):
255
+ sweep = 360.0 * abs(value) / total
256
+ shade = shades[index % len(shades)]
257
+ 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)
258
+ start += sweep
259
+ return
260
+
261
+ if "LINE" in kind.upper():
262
+ step = plot.width / max(len(categories) - 1, 1)
263
+ for series_index, (_, values) in enumerate(series):
264
+ points = []
265
+ for index, value in enumerate(values):
266
+ x = plot.x0 + step * index
267
+ y = baseline - (abs(value) / max_value) * (baseline - plot.y0 - 6)
268
+ points.append(fitz.Point(x, y))
269
+ if len(points) > 1:
270
+ shade = 0.20 + 0.18 * series_index
271
+ page.draw_polyline(points, color=(shade, shade, shade + 0.1), width=1.6)
272
+ for point in points:
273
+ page.draw_circle(point, 2.0, color=ACCENT, fill=ACCENT)
274
+ _draw_categories(page, plot, baseline, categories)
275
+ return
276
+
277
+ group_width = plot.width / max(len(categories), 1)
278
+ bar_width = group_width * 0.6 / max(len(series), 1)
279
+ for index, category in enumerate(categories):
280
+ for series_index, (_, values) in enumerate(series):
281
+ if index >= len(values):
282
+ continue
283
+ value = abs(values[index])
284
+ bar_height = (value / max_value) * (baseline - plot.y0 - 8)
285
+ x0 = plot.x0 + group_width * index + group_width * 0.2 + bar_width * series_index
286
+ bar = fitz.Rect(x0, baseline - bar_height, x0 + bar_width * 0.92, baseline)
287
+ shade = 0.30 + 0.22 * series_index
288
+ page.draw_rect(bar, color=ACCENT, fill=(shade * 0.55, shade * 0.75, shade), width=0.5)
289
+ _draw_text(page, fitz.Rect(bar.x0 - 6, bar.y0 - 12, bar.x1 + 6, bar.y0), _short(values[index]), 7.5, MUTED, 1)
290
+ page.draw_line(fitz.Point(plot.x0, baseline), fitz.Point(plot.x1, baseline), color=RULE, width=0.8)
291
+ _draw_categories(page, plot, baseline, categories)
292
+
293
+
294
+ def _short(value: float) -> str:
295
+ return f"{value:g}"
296
+
297
+
298
+ def _draw_categories(page, plot: fitz.Rect, baseline: float, categories: list[str]) -> None:
299
+ group_width = plot.width / max(max(len(categories), 1), 1)
300
+ for index, category in enumerate(categories):
301
+ cell = fitz.Rect(
302
+ plot.x0 + group_width * index,
303
+ baseline + 2,
304
+ plot.x0 + group_width * (index + 1) - 2,
305
+ baseline + 18,
306
+ )
307
+ _draw_text(page, cell, category[:18], 7.5, MUTED, 1)
308
+
309
+
310
+ def render_preview(pptx_path: str | Path, out_dir: str | Path, width_px: int = 1600) -> dict[str, Any]:
311
+ """Rasterise every slide to `<out_dir>/slide-NN.png`; returns a JSON-able report."""
312
+ presentation = open_presentation(pptx_path)
313
+ destination = Path(out_dir)
314
+ destination.mkdir(parents=True, exist_ok=True)
315
+ slide_w_in, slide_h_in = slide_size_in(presentation)
316
+ page_w = slide_w_in * 72
317
+ page_h = slide_h_in * 72
318
+ zoom = max(1.0, width_px / page_w)
319
+
320
+ files: list[str] = []
321
+ notes: list[dict[str, Any]] = []
322
+ document = fitz.open()
323
+ try:
324
+ for slide_no, slide in enumerate(presentation.slides, start=1):
325
+ page = document.new_page(width=page_w, height=page_h)
326
+ page.draw_rect(fitz.Rect(0, 0, page_w, page_h), color=None, fill=(1, 1, 1))
327
+ try:
328
+ background = slide.background.fill
329
+ rgb = background.fore_color.rgb
330
+ page.draw_rect(
331
+ fitz.Rect(0, 0, page_w, page_h),
332
+ color=None,
333
+ fill=(rgb[0] / 255, rgb[1] / 255, rgb[2] / 255),
334
+ )
335
+ except Exception: # noqa: BLE001 - most decks inherit a white master
336
+ pass
337
+ for shape in iter_shapes(slide.shapes):
338
+ _draw_shape(page, shape, slide_no, notes)
339
+ page.insert_text(
340
+ fitz.Point(page_w - 54, page_h - 10),
341
+ f"{slide_no}",
342
+ fontsize=7,
343
+ fontname="helv",
344
+ color=MUTED,
345
+ )
346
+ png = destination / f"slide-{slide_no:02d}.png"
347
+ page.get_pixmap(matrix=fitz.Matrix(zoom, zoom)).save(str(png))
348
+ files.append(str(png.resolve()))
349
+ finally:
350
+ document.close()
351
+
352
+ report = {
353
+ "ok": True,
354
+ "engine": "draft-preview",
355
+ "fidelity": "geometry-only",
356
+ "note": "Shapes redrawn from OOXML; fonts and theme colours are approximations. Use it to judge placement, collisions and overflow, not typography.",
357
+ "slides": len(files),
358
+ "slideSize": [round(slide_w_in, 3), round(slide_h_in, 3)],
359
+ "files": files,
360
+ "overflow": notes,
361
+ }
362
+ (destination / "preview.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
363
+ return report
364
+
365
+
366
+ def main(argv: list[str] | None = None) -> int:
367
+ import argparse
368
+
369
+ parser = argparse.ArgumentParser(description="Rasterise a .pptx to PNGs using a draft geometry renderer (no Office required).")
370
+ parser.add_argument("presentation", help="path to the .pptx")
371
+ parser.add_argument("--out-dir", required=True, help="directory for slide-NN.png and preview.json")
372
+ parser.add_argument("--width", type=int, default=1600, help="output pixel width (default 1600)")
373
+ args = parser.parse_args(argv)
374
+
375
+ report = render_preview(args.presentation, args.out_dir, args.width)
376
+ json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
377
+ sys.stdout.write("\n")
378
+ return 0 if report["slides"] else 3
379
+
380
+
381
+ if __name__ == "__main__":
382
+ raise SystemExit(main())
@@ -0,0 +1,146 @@
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
+ # 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
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
34
+
35
+
36
+ def office_cli() -> Path:
37
+ """The plugin's helper CLI, which owns renderer discovery."""
38
+ return Path(__file__).resolve().parents[4] / "python" / "office_cli.py"
39
+
40
+
41
+ def rasterise_pdf(pdf_path: Path, out_dir: Path, dpi: int) -> list[str]:
42
+ try:
43
+ import pymupdf as fitz
44
+ except ImportError: # pragma: no cover
45
+ import fitz
46
+
47
+ files: list[str] = []
48
+ document = fitz.open(str(pdf_path))
49
+ try:
50
+ zoom = dpi / 72.0
51
+ for index, page in enumerate(document, start=1):
52
+ png = out_dir / f"slide-{index:02d}.png"
53
+ page.get_pixmap(matrix=fitz.Matrix(zoom, zoom)).save(str(png))
54
+ files.append(str(png))
55
+ finally:
56
+ document.close()
57
+ return files
58
+
59
+
60
+ def render_with_office(source: Path, out_dir: Path, dpi: int) -> dict:
61
+ """Export through the CLI, which tries every installed office suite."""
62
+ scratch = Path(tempfile.mkdtemp(prefix="dsh-render-"))
63
+ try:
64
+ completed = subprocess.run(
65
+ [sys.executable, str(office_cli()), "pptx", "render",
66
+ "--input", str(source), "--out-dir", str(scratch), "--dpi", str(dpi)],
67
+ capture_output=True, text=True, timeout=300, check=False,
68
+ )
69
+ if completed.returncode != 0:
70
+ raise RuntimeError((completed.stderr or completed.stdout or "render failed").strip()[:400])
71
+ report = json.loads(completed.stdout)
72
+ files: list[str] = []
73
+ for index, page in enumerate(report.get("pages") or [], start=1):
74
+ target = out_dir / f"slide-{index:02d}.png"
75
+ shutil.move(page, target)
76
+ files.append(str(target))
77
+ return {
78
+ "ok": bool(files),
79
+ "engine": report.get("renderer") or "office",
80
+ "fidelity": "faithful",
81
+ "slides": len(files),
82
+ "dpi": dpi,
83
+ "files": files,
84
+ }
85
+ finally:
86
+ shutil.rmtree(scratch, ignore_errors=True)
87
+
88
+
89
+ def render(input_path: str | Path, out_dir: str | Path, dpi: int = 110, engine: str = "auto", width: int = 1600) -> dict:
90
+ source = Path(input_path)
91
+ destination = Path(out_dir)
92
+ if not source.is_file():
93
+ return {"ok": False, "error": {"code": "input_missing", "message": f"no such file: {source}"}}
94
+ destination.mkdir(parents=True, exist_ok=True)
95
+
96
+ if source.suffix.lower() == ".pdf":
97
+ files = rasterise_pdf(source, destination, dpi)
98
+ return {"ok": bool(files), "engine": "pdf-raster", "fidelity": "faithful", "slides": len(files), "dpi": dpi, "files": files}
99
+
100
+ notes: list[str] = []
101
+ if engine in {"auto", "office"}:
102
+ try:
103
+ return render_with_office(source, destination, dpi)
104
+ except Exception as exc: # noqa: BLE001 - fall through to the draft engine
105
+ notes.append(f"office render failed: {exc}")
106
+
107
+ if engine == "office":
108
+ return {"ok": False, "engine": "office", "notes": notes, "error": {"code": "no_engine", "message": "no Microsoft Office, WPS Office, or LibreOffice renderer is available"}}
109
+
110
+ from preview_render import render_preview
111
+
112
+ report = render_preview(source, destination, width)
113
+ report["notes"] = notes + [
114
+ "Draft preview: geometry is exact, type is approximate. Treat colours and fonts as indicative only.",
115
+ ]
116
+ report["files"] = [str(Path(item).resolve()) for item in report["files"]]
117
+ return report
118
+
119
+
120
+ def main(argv: list[str] | None = None) -> int:
121
+ import argparse
122
+
123
+ parser = argparse.ArgumentParser(description="Render a .pptx or .pdf into one PNG per page.")
124
+ parser.add_argument("--input", required=True, help="deck or PDF to render")
125
+ parser.add_argument("--out-dir", required=True, help="directory for slide-NN.png and render.json")
126
+ parser.add_argument("--dpi", type=int, default=110, help="raster dpi when a real renderer is used (default 110)")
127
+ parser.add_argument("--width", type=int, default=1600, help="pixel width for the draft renderer (default 1600)")
128
+ parser.add_argument("--engine", choices=("auto", "office", "draft"), default="auto", help="force an engine (default auto)")
129
+ parser.add_argument("--json-out", help="also write the report here")
130
+ args = parser.parse_args(argv)
131
+
132
+ report = render(args.input, args.out_dir, args.dpi, args.engine, args.width)
133
+ out_dir = Path(args.out_dir)
134
+ out_dir.mkdir(parents=True, exist_ok=True)
135
+ (out_dir / "render.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
136
+ if args.json_out:
137
+ Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
138
+ json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
139
+ sys.stdout.write("\n")
140
+ if report.get("ok"):
141
+ return 0
142
+ return 2 if report.get("error", {}).get("code") == "input_missing" else 3
143
+
144
+
145
+ if __name__ == "__main__":
146
+ raise SystemExit(main())