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
@@ -93,53 +93,121 @@ def find_soffice() -> str | None:
93
93
  return found
94
94
 
95
95
 
96
- def office_binary(kind: str) -> str | None:
96
+ #: Automation entry points per suite and format: (executable, COM ProgID).
97
+ RENDER_ENGINES: dict[str, dict[str, tuple[str, str]]] = {
98
+ "microsoft": {
99
+ "docx": ("WINWORD.EXE", "Word.Application"),
100
+ "pptx": ("POWERPNT.EXE", "PowerPoint.Application"),
101
+ "xlsx": ("EXCEL.EXE", "Excel.Application"),
102
+ },
103
+ "kingsoft": {
104
+ "docx": ("wps.exe", "KWps.Application"),
105
+ "pptx": ("wpp.exe", "KWpp.Application"),
106
+ "xlsx": ("et.exe", "Ket.Application"),
107
+ },
108
+ }
109
+
110
+
111
+ def _kingsoft_dirs() -> list[Path]:
112
+ """WPS Office hides its binaries under a per-version directory."""
113
+ bases = [
114
+ os.environ.get("WPS_OFFICE_HOME"),
115
+ os.path.join(os.environ.get("ProgramFiles", r"C:\Program Files"), "Kingsoft", "WPS Office"),
116
+ os.path.join(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"), "Kingsoft", "WPS Office"),
117
+ os.path.join(os.environ.get("LOCALAPPDATA", ""), "Kingsoft", "WPS Office"),
118
+ ]
119
+ found: list[Path] = []
120
+ for base in bases:
121
+ if not base:
122
+ continue
123
+ root = Path(base)
124
+ if (root / "office6").is_dir():
125
+ found.append(root / "office6")
126
+ continue
127
+ if not root.is_dir():
128
+ continue
129
+ versions = [child for child in root.iterdir() if (child / "office6").is_dir()]
130
+ versions.sort(key=lambda child: [int(part) if part.isdigit() else part for part in child.name.replace("-", ".").split(".")], reverse=True)
131
+ for child in versions:
132
+ found.append(child / "office6")
133
+ return found
134
+
135
+
136
+ def office_engine(kind: str, family: str | None = None) -> tuple[str, str] | None:
137
+ """Return (suite, executable) for an installed Office-compatible application."""
97
138
  if sys.platform != "win32":
98
139
  return None
99
- names = {"docx": "WINWORD.EXE", "pptx": "POWERPNT.EXE", "xlsx": "EXCEL.EXE"}
100
- name = names[kind]
101
- roots = [
102
- r"C:\Program Files\Microsoft Office\root\Office16",
103
- r"C:\Program Files (x86)\Microsoft Office\root\Office16",
104
- r"C:\Program Files\Microsoft Office\Office16",
105
- r"C:\Program Files (x86)\Microsoft Office\Office16",
106
- ]
107
- for root in roots:
108
- candidate = Path(root) / name
109
- if candidate.is_file():
110
- return str(candidate)
140
+ families = [family] if family else ["microsoft", "kingsoft"]
141
+ for name in families:
142
+ executable = RENDER_ENGINES[name][kind][0]
143
+ if name == "microsoft":
144
+ roots = [
145
+ r"C:\Program Files\Microsoft Office\root\Office16",
146
+ r"C:\Program Files (x86)\Microsoft Office\root\Office16",
147
+ r"C:\Program Files\Microsoft Office\Office16",
148
+ r"C:\Program Files (x86)\Microsoft Office\Office16",
149
+ ]
150
+ for root in roots:
151
+ candidate = Path(root) / executable
152
+ if candidate.is_file():
153
+ return name, str(candidate)
154
+ continue
155
+ for directory in _kingsoft_dirs():
156
+ candidate = directory / executable
157
+ if candidate.is_file():
158
+ return name, str(candidate)
111
159
  return None
112
160
 
113
161
 
162
+ def office_binary(kind: str) -> str | None:
163
+ engine = office_engine(kind)
164
+ return engine[1] if engine else None
165
+
166
+
167
+ def _engine_path(kind: str, family: str) -> str | None:
168
+ engine = office_engine(kind, family)
169
+ return engine[1] if engine else None
170
+
171
+
114
172
  def _powershell() -> str | None:
115
173
  return shutil.which("powershell") or shutil.which("pwsh")
116
174
 
117
175
 
118
- def _office_script(kind: str) -> str:
176
+ def _office_script(kind: str, family: str) -> str:
177
+ progid = RENDER_ENGINES[family][kind][1]
119
178
  if kind == "docx":
120
- return r"""
179
+ if family == "kingsoft":
180
+ return rf"""
181
+ $ErrorActionPreference = 'Stop'
182
+ $app = New-Object -ComObject {progid}
183
+ $app.Visible = $false
184
+ $app.DisplayAlerts = 0
185
+ $doc = $app.Documents.Open($env:DSH_INPUT, $false, $true)
186
+ try {{ $doc.SaveAs([ref]$env:DSH_PDF, [ref]17) }} finally {{ $doc.Close(0); $app.Quit() }}
187
+ """
188
+ return rf"""
121
189
  $ErrorActionPreference = 'Stop'
122
- $app = New-Object -ComObject Word.Application
190
+ $app = New-Object -ComObject {progid}
123
191
  $app.Visible = $false
124
192
  $app.DisplayAlerts = 0
125
193
  $doc = $app.Documents.Open($env:DSH_INPUT, $false, $true)
126
- try { $doc.ExportAsFixedFormat($env:DSH_PDF, 17) } finally { $doc.Close(0); $app.Quit() }
194
+ try {{ $doc.ExportAsFixedFormat($env:DSH_PDF, 17) }} finally {{ $doc.Close(0); $app.Quit() }}
127
195
  """
128
196
  if kind == "pptx":
129
- return r"""
197
+ return rf"""
130
198
  $ErrorActionPreference = 'Stop'
131
- $app = New-Object -ComObject PowerPoint.Application
199
+ $app = New-Object -ComObject {progid}
132
200
  $app.DisplayAlerts = 1
133
201
  $pres = $app.Presentations.Open($env:DSH_INPUT, $true, $false, $false)
134
- try { $pres.SaveAs($env:DSH_PDF, 32) } finally { $pres.Close(); $app.Quit() }
202
+ try {{ $pres.SaveAs($env:DSH_PDF, 32) }} finally {{ $pres.Close(); $app.Quit() }}
135
203
  """
136
- return r"""
204
+ return rf"""
137
205
  $ErrorActionPreference = 'Stop'
138
- $app = New-Object -ComObject Excel.Application
206
+ $app = New-Object -ComObject {progid}
139
207
  $app.Visible = $false
140
208
  $app.DisplayAlerts = $false
141
209
  $wb = $app.Workbooks.Open($env:DSH_INPUT)
142
- try { $wb.ExportAsFixedFormat(0, $env:DSH_PDF) } finally { $wb.Close($false); $app.Quit() }
210
+ try {{ $wb.ExportAsFixedFormat(0, $env:DSH_PDF) }} finally {{ $wb.Close($false); $app.Quit() }}
143
211
  """
144
212
 
145
213
 
@@ -151,28 +219,33 @@ def export_pdf(input_path: Path, kind: str, out_dir: Path) -> tuple[str, str]:
151
219
  pdf.unlink()
152
220
 
153
221
  power_shell = _powershell()
154
- if power_shell and office_binary(kind):
155
- script = Path(tempfile.gettempdir()) / f"dsh-office-{kind}-{os.getpid()}.ps1"
156
- script.write_text(_office_script(kind), encoding="utf-8")
157
- env = dict(os.environ)
158
- env["DSH_INPUT"] = str(input_path.resolve())
159
- env["DSH_PDF"] = str(pdf.resolve())
160
- try:
161
- completed = subprocess.run(
162
- [power_shell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script)],
163
- capture_output=True,
164
- text=True,
165
- timeout=240,
166
- env=env,
167
- )
168
- except subprocess.TimeoutExpired:
169
- fail(f"Microsoft Office export timed out for {input_path.name}")
170
- finally:
171
- script.unlink(missing_ok=True)
172
- if completed.returncode != 0 or not pdf.is_file():
173
- detail = (completed.stderr or completed.stdout).strip()
174
- fail(f"Microsoft Office export failed for {input_path.name}", detail=detail or None)
175
- return "microsoft-office", str(pdf)
222
+ failures: list[str] = []
223
+ if power_shell:
224
+ for family, renderer in (("microsoft", "microsoft-office"), ("kingsoft", "kingsoft-wps")):
225
+ engine = office_engine(kind, family)
226
+ if engine is None:
227
+ continue
228
+ script = Path(tempfile.gettempdir()) / f"dsh-office-{kind}-{os.getpid()}.ps1"
229
+ script.write_text(_office_script(kind, family), encoding="utf-8")
230
+ env = dict(os.environ)
231
+ env["DSH_INPUT"] = str(input_path.resolve())
232
+ env["DSH_PDF"] = str(pdf.resolve())
233
+ try:
234
+ completed = subprocess.run(
235
+ [power_shell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script)],
236
+ capture_output=True,
237
+ text=True,
238
+ timeout=240,
239
+ env=env,
240
+ )
241
+ except subprocess.TimeoutExpired:
242
+ failures.append(f"{family}: timed out")
243
+ continue
244
+ finally:
245
+ script.unlink(missing_ok=True)
246
+ if completed.returncode == 0 and pdf.is_file():
247
+ return renderer, str(pdf)
248
+ failures.append(f"{family}: " + ((completed.stderr or completed.stdout).strip() or "no PDF produced"))
176
249
 
177
250
  soffice = find_soffice()
178
251
  if soffice:
@@ -187,7 +260,10 @@ def export_pdf(input_path: Path, kind: str, out_dir: Path) -> tuple[str, str]:
187
260
  fail(f"LibreOffice export failed for {input_path.name}", detail=detail or None)
188
261
  return "libreoffice", str(pdf)
189
262
 
190
- fail("no document renderer found: install LibreOffice or Microsoft Office (Word/PowerPoint/Excel)")
263
+ fail(
264
+ "no document renderer found: install LibreOffice, Microsoft Office, or WPS Office",
265
+ detail="; ".join(failures) or None,
266
+ )
191
267
 
192
268
 
193
269
  def rasterize_pdf(pdf: str, out_dir: Path, dpi: int = 110) -> list[str]:
@@ -424,38 +500,6 @@ def _replace_in_paragraph(paragraph, find: str, replacement: str) -> int:
424
500
  return count
425
501
 
426
502
 
427
- def docx_edit(spec: dict[str, Any], input_path: Path, out: Path) -> dict[str, Any]:
428
- from docx import Document
429
-
430
- document = Document(str(input_path))
431
- replace_count = 0
432
- for item in spec.get("replace", []):
433
- find = str(item.get("find", ""))
434
- replacement = str(item.get("replace", ""))
435
- if not find:
436
- continue
437
- for paragraph in _docx_iter_paragraphs(document):
438
- replace_count += _replace_in_paragraph(paragraph, find, replacement)
439
- counters = {key: 0 for key in ("headings", "paragraphs", "list_items", "tables", "images", "equations", "equation_fallbacks")}
440
- _docx_add_blocks(document, spec.get("blocks", []), counters)
441
- core = spec.get("core")
442
- if isinstance(core, dict):
443
- for key in ("title", "author", "subject", "keywords", "comments"):
444
- if key in core:
445
- setattr(document.core_properties, key, str(core[key]))
446
- ensure_parent(str(out))
447
- document.save(str(out))
448
- return {
449
- "ok": True,
450
- "kind": "docx",
451
- "command": "edit",
452
- "input": str(input_path),
453
- "output": str(out),
454
- "replacements": replace_count,
455
- "counts": counters,
456
- }
457
-
458
-
459
503
  def _docx_has_equation(paragraph) -> bool:
460
504
  tag = "{http://schemas.openxmlformats.org/officeDocument/2006/math}oMath"
461
505
  return bool(paragraph._p.findall(f".//{tag}"))
@@ -667,45 +711,6 @@ def pdf_read(input_path: Path) -> dict[str, Any]:
667
711
  }
668
712
 
669
713
 
670
- def pdf_edit(spec: dict[str, Any], input_path: Path, out: Path) -> dict[str, Any]:
671
- from pypdf import PdfReader, PdfWriter
672
-
673
- writer = PdfWriter()
674
- reader = PdfReader(str(input_path))
675
- for page in reader.pages:
676
- writer.add_page(page)
677
- rotate = int(spec.get("rotate", 0))
678
- targets = spec.get("pages")
679
- if rotate:
680
- for index, page in enumerate(writer.pages):
681
- if targets is None or index in targets:
682
- page.rotate(rotate)
683
- for extra in spec.get("merge", []):
684
- extra_reader = PdfReader(str(require_file(str(extra), "PDF to merge")))
685
- for page in extra_reader.pages:
686
- writer.add_page(page)
687
- if spec.get("remove_pages"):
688
- remove = set(int(value) for value in spec["remove_pages"])
689
- writer = PdfWriter()
690
- for index, page in enumerate(reader.pages):
691
- if index not in remove:
692
- writer.add_page(page)
693
- for extra in spec.get("merge", []):
694
- extra_reader = PdfReader(str(require_file(str(extra), "PDF to merge")))
695
- for page in extra_reader.pages:
696
- writer.add_page(page)
697
- if isinstance(spec.get("metadata"), dict):
698
- metadata = {}
699
- for key, value in spec["metadata"].items():
700
- name = str(key)
701
- metadata[name if name.startswith("/") else f"/{name}"] = str(value)
702
- writer.add_metadata(metadata)
703
- ensure_parent(str(out))
704
- with open(out, "wb") as handle:
705
- writer.write(handle)
706
- return {"ok": True, "kind": "pdf", "command": "edit", "input": str(input_path), "output": str(out), "page_count": len(writer.pages)}
707
-
708
-
709
714
  def pdf_render(input_path: Path, out_dir: Path, dpi: int) -> dict[str, Any]:
710
715
  pages = rasterize_pdf(str(input_path), out_dir, dpi)
711
716
  return {"ok": True, "kind": "pdf", "command": "render", "input": str(input_path), "page_count": len(pages), "pages": pages}
@@ -912,38 +917,6 @@ def pptx_read(input_path: Path) -> dict[str, Any]:
912
917
  }
913
918
 
914
919
 
915
- def pptx_edit(spec: dict[str, Any], input_path: Path, out: Path) -> dict[str, Any]:
916
- from pptx import Presentation
917
-
918
- prs = Presentation(str(input_path))
919
- replacements = 0
920
- for item in spec.get("replace", []):
921
- find = str(item.get("find", ""))
922
- replacement = str(item.get("replace", ""))
923
- if not find:
924
- continue
925
- for slide in prs.slides:
926
- for shape in slide.shapes:
927
- if not shape.has_text_frame:
928
- continue
929
- for paragraph in shape.text_frame.paragraphs:
930
- for run in paragraph.runs:
931
- if find in run.text:
932
- run.text = run.text.replace(find, replacement)
933
- replacements += 1
934
- counters = {key: 0 for key in ("slides", "bullets", "tables", "charts", "images")}
935
- for slide_spec in spec.get("slides", []):
936
- if isinstance(slide_spec, dict):
937
- _pptx_add_slide(prs, slide_spec, counters)
938
- if isinstance(spec.get("core"), dict):
939
- for key in ("title", "author", "subject", "keywords", "comments"):
940
- if key in spec["core"]:
941
- setattr(prs.core_properties, key, str(spec["core"][key]))
942
- ensure_parent(str(out))
943
- prs.save(str(out))
944
- return {"ok": True, "kind": "pptx", "command": "edit", "input": str(input_path), "output": str(out), "replacements": replacements, "added": counters}
945
-
946
-
947
920
  # ---------------------------------------------------------------------------
948
921
  # XLSX
949
922
  # ---------------------------------------------------------------------------
@@ -1113,86 +1086,26 @@ def xlsx_read(input_path: Path, data_only: bool = False) -> dict[str, Any]:
1113
1086
  }
1114
1087
 
1115
1088
 
1116
- def xlsx_edit(spec: dict[str, Any], input_path: Path, out: Path) -> dict[str, Any]:
1117
- from openpyxl import load_workbook
1118
- from openpyxl.drawing.image import Image as XLImage
1119
-
1120
- workbook = load_workbook(str(input_path))
1121
- changed = 0
1122
- for operation in spec.get("operations", []):
1123
- if not isinstance(operation, dict):
1124
- continue
1125
- action = str(operation.get("action", "set"))
1126
- if action == "add_sheet":
1127
- title = str(operation.get("name", "Sheet"))
1128
- worksheet = workbook.create_sheet(title)
1129
- elif action == "delete_sheet":
1130
- name = str(operation.get("name", ""))
1131
- if name not in workbook.sheetnames:
1132
- fail(f"sheet not found: {name}")
1133
- workbook.remove(workbook[name])
1134
- continue
1135
- elif action == "rename_sheet":
1136
- old = str(operation.get("from", ""))
1137
- new = str(operation.get("to", ""))
1138
- if old not in workbook.sheetnames:
1139
- fail(f"sheet not found: {old}")
1140
- workbook[old].title = new
1141
- continue
1142
- else:
1143
- name = str(operation.get("sheet", workbook.sheetnames[0]))
1144
- if name not in workbook.sheetnames:
1145
- fail(f"sheet not found: {name}")
1146
- worksheet = workbook[name]
1147
- if action == "set_cells":
1148
- for address, value in (operation.get("cells") or {}).items():
1149
- worksheet[str(address)] = _xlsx_value(value)
1150
- changed += 1
1151
- elif action == "append_rows":
1152
- for row in operation.get("rows", []):
1153
- worksheet.append([_xlsx_value(value) for value in row])
1154
- changed += 1
1155
- elif action == "style":
1156
- for address, style in (operation.get("cells") or {}).items():
1157
- if isinstance(style, dict):
1158
- for cell in _xlsx_cells(worksheet, str(address)):
1159
- _xlsx_apply_style(cell, style)
1160
- changed += 1
1161
- elif action == "chart":
1162
- _xlsx_add_chart(worksheet, operation)
1163
- changed += 1
1164
- elif action == "image":
1165
- path = require_file(str(operation.get("path", "")), "image")
1166
- worksheet.add_image(XLImage(str(path)), str(operation.get("anchor", "A1")))
1167
- changed += 1
1168
- elif action == "rows":
1169
- for row in operation.get("rows", []):
1170
- worksheet.append([_xlsx_value(value) for value in row])
1171
- changed += 1
1172
- ensure_parent(str(out))
1173
- workbook.save(str(out))
1174
- workbook.close()
1175
- return {"ok": True, "kind": "xlsx", "command": "edit", "input": str(input_path), "output": str(out), "changes": changed}
1176
-
1177
-
1178
1089
  def xlsx_recalc(input_path: Path, out: Path) -> dict[str, Any]:
1179
- """Force Microsoft Excel to recalculate and save cached formula values."""
1180
- if sys.platform != "win32" or office_binary("xlsx") is None:
1181
- fail("formula recalculation requires Microsoft Excel on Windows; openpyxl cannot evaluate formulas")
1090
+ """Force a spreadsheet application to recalculate and store cached formula values."""
1091
+ engine = office_engine("xlsx") if sys.platform == "win32" else None
1092
+ if engine is None or _powershell() is None:
1093
+ fail("formula recalculation requires Microsoft Excel or WPS Spreadsheets on Windows; openpyxl cannot evaluate formulas")
1094
+ family, executable = engine
1095
+ progid = RENDER_ENGINES[family]["xlsx"][1]
1096
+ ensure_parent(str(out))
1097
+ if out.resolve() != input_path.resolve():
1098
+ shutil.copyfile(input_path, out)
1182
1099
  script = Path(tempfile.gettempdir()) / f"dsh-office-recalc-{os.getpid()}.ps1"
1183
- script.write_text(r"""
1100
+ script.write_text(rf"""
1184
1101
  $ErrorActionPreference = 'Stop'
1185
- $app = New-Object -ComObject Excel.Application
1102
+ $app = New-Object -ComObject {progid}
1186
1103
  $app.Visible = $false
1187
1104
  $app.DisplayAlerts = $false
1188
- $wb = $app.Workbooks.Open($env:DSH_INPUT)
1189
- try {
1190
- $app.CalculateFull()
1191
- $wb.SaveAs($env:DSH_OUT, 51)
1192
- } finally { $wb.Close($false); $app.Quit() }
1105
+ $wb = $app.Workbooks.Open($env:DSH_OUT)
1106
+ try {{ $app.CalculateFull(); $wb.Save() }} finally {{ $wb.Close($true); $app.Quit() }}
1193
1107
  """, encoding="utf-8")
1194
1108
  env = dict(os.environ)
1195
- env["DSH_INPUT"] = str(input_path.resolve())
1196
1109
  env["DSH_OUT"] = str(out.resolve())
1197
1110
  try:
1198
1111
  completed = subprocess.run(
@@ -1205,8 +1118,28 @@ try {
1205
1118
  finally:
1206
1119
  script.unlink(missing_ok=True)
1207
1120
  if completed.returncode != 0 or not out.is_file():
1208
- fail("Excel recalculation failed", detail=(completed.stderr or completed.stdout).strip() or None)
1209
- return {"ok": True, "kind": "xlsx", "command": "recalc", "input": str(input_path), "output": str(out)}
1121
+ fail("formula recalculation failed", detail=(completed.stderr or completed.stdout).strip() or None)
1122
+ cached = 0
1123
+ try:
1124
+ import openpyxl
1125
+
1126
+ workbook = openpyxl.load_workbook(str(out), data_only=True)
1127
+ for sheet in workbook.worksheets:
1128
+ for row in sheet.iter_rows():
1129
+ cached += sum(1 for cell in row if cell.value is not None)
1130
+ workbook.close()
1131
+ except Exception:
1132
+ cached = 0
1133
+ return {
1134
+ "ok": True,
1135
+ "kind": "xlsx",
1136
+ "command": "recalc",
1137
+ "input": str(input_path),
1138
+ "output": str(out),
1139
+ "renderer": f"{family}-office" if family == "microsoft" else "kingsoft-wps",
1140
+ "executable": executable,
1141
+ "cached_values": cached,
1142
+ }
1210
1143
 
1211
1144
 
1212
1145
  # ---------------------------------------------------------------------------
@@ -1246,9 +1179,20 @@ def status() -> dict[str, Any]:
1246
1179
  "renderers": {
1247
1180
  "libreoffice": find_soffice(),
1248
1181
  "microsoft_office": {
1249
- "word": office_binary("docx"),
1250
- "powerpoint": office_binary("pptx"),
1251
- "excel": office_binary("xlsx"),
1182
+ "word": _engine_path("docx", "microsoft"),
1183
+ "powerpoint": _engine_path("pptx", "microsoft"),
1184
+ "excel": _engine_path("xlsx", "microsoft"),
1185
+ },
1186
+ "kingsoft_wps": {
1187
+ "writer": _engine_path("docx", "kingsoft"),
1188
+ "presentation": _engine_path("pptx", "kingsoft"),
1189
+ "spreadsheet": _engine_path("xlsx", "kingsoft"),
1190
+ },
1191
+ "can_render": {
1192
+ "docx": bool(office_engine("docx") or find_soffice()),
1193
+ "pptx": bool(office_engine("pptx") or find_soffice()),
1194
+ "xlsx": bool(office_engine("xlsx") or find_soffice()),
1195
+ "pdf": True,
1252
1196
  },
1253
1197
  },
1254
1198
  }
@@ -1280,13 +1224,6 @@ def main() -> None:
1280
1224
  render.add_argument("--dpi", type=int, default=110)
1281
1225
  render.add_argument("--json-out")
1282
1226
 
1283
- if kind in {"docx", "pdf", "pptx", "xlsx"}:
1284
- edit = commands.add_parser("edit")
1285
- edit.add_argument("--input", required=True)
1286
- edit.add_argument("--spec", required=True)
1287
- edit.add_argument("--out", required=True)
1288
- edit.add_argument("--json-out")
1289
-
1290
1227
  if kind == "xlsx":
1291
1228
  recalc = commands.add_parser("recalc")
1292
1229
  recalc.add_argument("--input", required=True)
@@ -1321,18 +1258,6 @@ def main() -> None:
1321
1258
  result = pptx_read(input_path)
1322
1259
  else:
1323
1260
  result = xlsx_read(input_path, data_only=bool(getattr(args, "data_only", False)))
1324
- elif args.command == "edit":
1325
- input_path = require_file(args.input)
1326
- spec = load_json(args.spec)
1327
- out = Path(args.out)
1328
- if args.format == "docx":
1329
- result = docx_edit(spec, input_path, out)
1330
- elif args.format == "pdf":
1331
- result = pdf_edit(spec, input_path, out)
1332
- elif args.format == "pptx":
1333
- result = pptx_edit(spec, input_path, out)
1334
- else:
1335
- result = xlsx_edit(spec, input_path, out)
1336
1261
  elif args.command == "render":
1337
1262
  input_path = require_file(args.input)
1338
1263
  out_dir = Path(args.out_dir)