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
@@ -21,7 +21,18 @@ import tempfile
21
21
  from pathlib import Path
22
22
  from typing import Any, Iterable
23
23
 
24
- VERSION = "0.1.0"
24
+ PACKAGE_ROOT = Path(__file__).resolve().parents[1]
25
+
26
+
27
+ def _package_version() -> str:
28
+ try:
29
+ data = json.loads((PACKAGE_ROOT / "package.json").read_text(encoding="utf-8"))
30
+ return str(data["version"])
31
+ except (OSError, ValueError, KeyError, TypeError):
32
+ return "0.0.0"
33
+
34
+
35
+ VERSION = _package_version()
25
36
 
26
37
  # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
27
38
  for _stream in (sys.stdout, sys.stderr):
@@ -93,53 +104,121 @@ def find_soffice() -> str | None:
93
104
  return found
94
105
 
95
106
 
96
- def office_binary(kind: str) -> str | None:
107
+ #: Automation entry points per suite and format: (executable, COM ProgID).
108
+ RENDER_ENGINES: dict[str, dict[str, tuple[str, str]]] = {
109
+ "microsoft": {
110
+ "docx": ("WINWORD.EXE", "Word.Application"),
111
+ "pptx": ("POWERPNT.EXE", "PowerPoint.Application"),
112
+ "xlsx": ("EXCEL.EXE", "Excel.Application"),
113
+ },
114
+ "kingsoft": {
115
+ "docx": ("wps.exe", "KWps.Application"),
116
+ "pptx": ("wpp.exe", "KWpp.Application"),
117
+ "xlsx": ("et.exe", "Ket.Application"),
118
+ },
119
+ }
120
+
121
+
122
+ def _kingsoft_dirs() -> list[Path]:
123
+ """WPS Office hides its binaries under a per-version directory."""
124
+ bases = [
125
+ os.environ.get("WPS_OFFICE_HOME"),
126
+ os.path.join(os.environ.get("ProgramFiles", r"C:\Program Files"), "Kingsoft", "WPS Office"),
127
+ os.path.join(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"), "Kingsoft", "WPS Office"),
128
+ os.path.join(os.environ.get("LOCALAPPDATA", ""), "Kingsoft", "WPS Office"),
129
+ ]
130
+ found: list[Path] = []
131
+ for base in bases:
132
+ if not base:
133
+ continue
134
+ root = Path(base)
135
+ if (root / "office6").is_dir():
136
+ found.append(root / "office6")
137
+ continue
138
+ if not root.is_dir():
139
+ continue
140
+ versions = [child for child in root.iterdir() if (child / "office6").is_dir()]
141
+ versions.sort(key=lambda child: [int(part) if part.isdigit() else part for part in child.name.replace("-", ".").split(".")], reverse=True)
142
+ for child in versions:
143
+ found.append(child / "office6")
144
+ return found
145
+
146
+
147
+ def office_engine(kind: str, family: str | None = None) -> tuple[str, str] | None:
148
+ """Return (suite, executable) for an installed Office-compatible application."""
97
149
  if sys.platform != "win32":
98
150
  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)
151
+ families = [family] if family else ["microsoft", "kingsoft"]
152
+ for name in families:
153
+ executable = RENDER_ENGINES[name][kind][0]
154
+ if name == "microsoft":
155
+ roots = [
156
+ r"C:\Program Files\Microsoft Office\root\Office16",
157
+ r"C:\Program Files (x86)\Microsoft Office\root\Office16",
158
+ r"C:\Program Files\Microsoft Office\Office16",
159
+ r"C:\Program Files (x86)\Microsoft Office\Office16",
160
+ ]
161
+ for root in roots:
162
+ candidate = Path(root) / executable
163
+ if candidate.is_file():
164
+ return name, str(candidate)
165
+ continue
166
+ for directory in _kingsoft_dirs():
167
+ candidate = directory / executable
168
+ if candidate.is_file():
169
+ return name, str(candidate)
111
170
  return None
112
171
 
113
172
 
173
+ def office_binary(kind: str) -> str | None:
174
+ engine = office_engine(kind)
175
+ return engine[1] if engine else None
176
+
177
+
178
+ def _engine_path(kind: str, family: str) -> str | None:
179
+ engine = office_engine(kind, family)
180
+ return engine[1] if engine else None
181
+
182
+
114
183
  def _powershell() -> str | None:
115
184
  return shutil.which("powershell") or shutil.which("pwsh")
116
185
 
117
186
 
118
- def _office_script(kind: str) -> str:
187
+ def _office_script(kind: str, family: str) -> str:
188
+ progid = RENDER_ENGINES[family][kind][1]
119
189
  if kind == "docx":
120
- return r"""
190
+ if family == "kingsoft":
191
+ return rf"""
192
+ $ErrorActionPreference = 'Stop'
193
+ $app = New-Object -ComObject {progid}
194
+ $app.Visible = $false
195
+ $app.DisplayAlerts = 0
196
+ $doc = $app.Documents.Open($env:DSH_INPUT, $false, $true)
197
+ try {{ $doc.SaveAs([ref]$env:DSH_PDF, [ref]17) }} finally {{ $doc.Close(0); $app.Quit() }}
198
+ """
199
+ return rf"""
121
200
  $ErrorActionPreference = 'Stop'
122
- $app = New-Object -ComObject Word.Application
201
+ $app = New-Object -ComObject {progid}
123
202
  $app.Visible = $false
124
203
  $app.DisplayAlerts = 0
125
204
  $doc = $app.Documents.Open($env:DSH_INPUT, $false, $true)
126
- try { $doc.ExportAsFixedFormat($env:DSH_PDF, 17) } finally { $doc.Close(0); $app.Quit() }
205
+ try {{ $doc.ExportAsFixedFormat($env:DSH_PDF, 17) }} finally {{ $doc.Close(0); $app.Quit() }}
127
206
  """
128
207
  if kind == "pptx":
129
- return r"""
208
+ return rf"""
130
209
  $ErrorActionPreference = 'Stop'
131
- $app = New-Object -ComObject PowerPoint.Application
210
+ $app = New-Object -ComObject {progid}
132
211
  $app.DisplayAlerts = 1
133
212
  $pres = $app.Presentations.Open($env:DSH_INPUT, $true, $false, $false)
134
- try { $pres.SaveAs($env:DSH_PDF, 32) } finally { $pres.Close(); $app.Quit() }
213
+ try {{ $pres.SaveAs($env:DSH_PDF, 32) }} finally {{ $pres.Close(); $app.Quit() }}
135
214
  """
136
- return r"""
215
+ return rf"""
137
216
  $ErrorActionPreference = 'Stop'
138
- $app = New-Object -ComObject Excel.Application
217
+ $app = New-Object -ComObject {progid}
139
218
  $app.Visible = $false
140
219
  $app.DisplayAlerts = $false
141
220
  $wb = $app.Workbooks.Open($env:DSH_INPUT)
142
- try { $wb.ExportAsFixedFormat(0, $env:DSH_PDF) } finally { $wb.Close($false); $app.Quit() }
221
+ try {{ $wb.ExportAsFixedFormat(0, $env:DSH_PDF) }} finally {{ $wb.Close($false); $app.Quit() }}
143
222
  """
144
223
 
145
224
 
@@ -151,28 +230,33 @@ def export_pdf(input_path: Path, kind: str, out_dir: Path) -> tuple[str, str]:
151
230
  pdf.unlink()
152
231
 
153
232
  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)
233
+ failures: list[str] = []
234
+ if power_shell:
235
+ for family, renderer in (("microsoft", "microsoft-office"), ("kingsoft", "kingsoft-wps")):
236
+ engine = office_engine(kind, family)
237
+ if engine is None:
238
+ continue
239
+ script = Path(tempfile.gettempdir()) / f"dsh-office-{kind}-{os.getpid()}.ps1"
240
+ script.write_text(_office_script(kind, family), encoding="utf-8")
241
+ env = dict(os.environ)
242
+ env["DSH_INPUT"] = str(input_path.resolve())
243
+ env["DSH_PDF"] = str(pdf.resolve())
244
+ try:
245
+ completed = subprocess.run(
246
+ [power_shell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script)],
247
+ capture_output=True,
248
+ text=True,
249
+ timeout=240,
250
+ env=env,
251
+ )
252
+ except subprocess.TimeoutExpired:
253
+ failures.append(f"{family}: timed out")
254
+ continue
255
+ finally:
256
+ script.unlink(missing_ok=True)
257
+ if completed.returncode == 0 and pdf.is_file():
258
+ return renderer, str(pdf)
259
+ failures.append(f"{family}: " + ((completed.stderr or completed.stdout).strip() or "no PDF produced"))
176
260
 
177
261
  soffice = find_soffice()
178
262
  if soffice:
@@ -187,7 +271,10 @@ def export_pdf(input_path: Path, kind: str, out_dir: Path) -> tuple[str, str]:
187
271
  fail(f"LibreOffice export failed for {input_path.name}", detail=detail or None)
188
272
  return "libreoffice", str(pdf)
189
273
 
190
- fail("no document renderer found: install LibreOffice or Microsoft Office (Word/PowerPoint/Excel)")
274
+ fail(
275
+ "no document renderer found: install LibreOffice, Microsoft Office, or WPS Office",
276
+ detail="; ".join(failures) or None,
277
+ )
191
278
 
192
279
 
193
280
  def rasterize_pdf(pdf: str, out_dir: Path, dpi: int = 110) -> list[str]:
@@ -263,6 +350,16 @@ def _docx_add_equation(paragraph, latex: str) -> bool:
263
350
  return False
264
351
 
265
352
 
353
+ def _docx_mark_header_row(row) -> None:
354
+ """Repeat this row across pages and mark it as a header for assistive tech."""
355
+ from docx.oxml.ns import qn
356
+
357
+ properties = row._tr.get_or_add_trPr()
358
+ if properties.find(qn("w:tblHeader")) is None:
359
+ header = properties.makeelement(qn("w:tblHeader"), {})
360
+ properties.append(header)
361
+
362
+
266
363
  def _docx_add_block(document, block: dict[str, Any], counters: dict[str, int]) -> None:
267
364
  from docx.shared import Inches, Pt
268
365
 
@@ -315,6 +412,7 @@ def _docx_add_block(document, block: dict[str, Any], counters: dict[str, int]) -
315
412
  for paragraph in cells[index].paragraphs:
316
413
  for run in paragraph.runs:
317
414
  run.bold = True
415
+ _docx_mark_header_row(table.rows[0])
318
416
  for row in rows:
319
417
  cells = table.add_row().cells
320
418
  for index, value in enumerate(row):
@@ -383,6 +481,10 @@ def docx_create(spec: dict[str, Any], out: Path) -> dict[str, Any]:
383
481
  if spec.get("author") or spec.get("title"):
384
482
  document.core_properties.title = str(spec.get("title", ""))
385
483
  document.core_properties.author = str(spec.get("author", ""))
484
+ # A fresh python-docx document carries the library name in comments; a
485
+ # delivered file should not advertise the tool that wrote it.
486
+ document.core_properties.comments = str(spec.get("comments", ""))
487
+ document.core_properties.last_modified_by = str(spec.get("author", ""))
386
488
  if spec.get("header"):
387
489
  document.sections[0].header.paragraphs[0].text = str(spec["header"])
388
490
  if spec.get("footer"):
@@ -424,38 +526,6 @@ def _replace_in_paragraph(paragraph, find: str, replacement: str) -> int:
424
526
  return count
425
527
 
426
528
 
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
529
  def _docx_has_equation(paragraph) -> bool:
460
530
  tag = "{http://schemas.openxmlformats.org/officeDocument/2006/math}oMath"
461
531
  return bool(paragraph._p.findall(f".//{tag}"))
@@ -615,17 +685,21 @@ def pdf_create(spec: dict[str, Any], out: Path) -> dict[str, Any]:
615
685
  author=str(spec.get("author", "")),
616
686
  )
617
687
  counters = {key: 0 for key in ("headings", "paragraphs", "list_items", "tables", "images")}
688
+ following: list[dict[str, Any]] = []
689
+ for page in spec.get("pages", []):
690
+ if isinstance(page, dict):
691
+ following.extend(page.get("blocks", []))
692
+ if page.get("break", True):
693
+ following.append({"type": "page_break"})
694
+ following.extend(spec.get("blocks", []))
695
+ kinds = {str(block.get("type", "")).lower() for block in following if isinstance(block, dict)}
618
696
  blocks: list[dict[str, Any]] = []
619
- if spec.get("title"):
697
+ # `title` is metadata; only draw it when the blocks do not already carry one.
698
+ if spec.get("title") and "title" not in kinds:
620
699
  blocks.append({"type": "title", "text": spec["title"]})
621
700
  if spec.get("subtitle"):
622
701
  blocks.append({"type": "paragraph", "text": spec["subtitle"]})
623
- for page in spec.get("pages", []):
624
- if isinstance(page, dict):
625
- blocks.extend(page.get("blocks", []))
626
- if page.get("break", True):
627
- blocks.append({"type": "page_break"})
628
- blocks.extend(spec.get("blocks", []))
702
+ blocks.extend(following)
629
703
  document.build(_pdf_flowables(blocks, font, counters))
630
704
  return {"ok": True, "kind": "pdf", "command": "create", "output": str(out), "font": font, "counts": counters}
631
705
 
@@ -667,45 +741,6 @@ def pdf_read(input_path: Path) -> dict[str, Any]:
667
741
  }
668
742
 
669
743
 
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
744
  def pdf_render(input_path: Path, out_dir: Path, dpi: int) -> dict[str, Any]:
710
745
  pages = rasterize_pdf(str(input_path), out_dir, dpi)
711
746
  return {"ok": True, "kind": "pdf", "command": "render", "input": str(input_path), "page_count": len(pages), "pages": pages}
@@ -844,6 +879,10 @@ def pptx_create(spec: dict[str, Any], out: Path) -> dict[str, Any]:
844
879
  prs.slide_height = int(7.5 * 914400)
845
880
  prs.core_properties.title = str(spec.get("title", ""))
846
881
  prs.core_properties.author = str(spec.get("author", "DSH Office Suite"))
882
+ # python-pptx leaves its own name in the properties; a delivered deck should
883
+ # carry the author's, not the library's.
884
+ prs.core_properties.comments = str(spec.get("comments", ""))
885
+ prs.core_properties.last_modified_by = str(spec.get("author", "DSH Office Suite"))
847
886
  counters = {key: 0 for key in ("slides", "bullets", "tables", "charts", "images")}
848
887
  for slide_spec in spec.get("slides", []):
849
888
  if isinstance(slide_spec, dict):
@@ -912,38 +951,6 @@ def pptx_read(input_path: Path) -> dict[str, Any]:
912
951
  }
913
952
 
914
953
 
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
954
  # ---------------------------------------------------------------------------
948
955
  # XLSX
949
956
  # ---------------------------------------------------------------------------
@@ -1113,86 +1120,26 @@ def xlsx_read(input_path: Path, data_only: bool = False) -> dict[str, Any]:
1113
1120
  }
1114
1121
 
1115
1122
 
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
1123
  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")
1124
+ """Force a spreadsheet application to recalculate and store cached formula values."""
1125
+ engine = office_engine("xlsx") if sys.platform == "win32" else None
1126
+ if engine is None or _powershell() is None:
1127
+ fail("formula recalculation requires Microsoft Excel or WPS Spreadsheets on Windows; openpyxl cannot evaluate formulas")
1128
+ family, executable = engine
1129
+ progid = RENDER_ENGINES[family]["xlsx"][1]
1130
+ ensure_parent(str(out))
1131
+ if out.resolve() != input_path.resolve():
1132
+ shutil.copyfile(input_path, out)
1182
1133
  script = Path(tempfile.gettempdir()) / f"dsh-office-recalc-{os.getpid()}.ps1"
1183
- script.write_text(r"""
1134
+ script.write_text(rf"""
1184
1135
  $ErrorActionPreference = 'Stop'
1185
- $app = New-Object -ComObject Excel.Application
1136
+ $app = New-Object -ComObject {progid}
1186
1137
  $app.Visible = $false
1187
1138
  $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() }
1139
+ $wb = $app.Workbooks.Open($env:DSH_OUT)
1140
+ try {{ $app.CalculateFull(); $wb.Save() }} finally {{ $wb.Close($true); $app.Quit() }}
1193
1141
  """, encoding="utf-8")
1194
1142
  env = dict(os.environ)
1195
- env["DSH_INPUT"] = str(input_path.resolve())
1196
1143
  env["DSH_OUT"] = str(out.resolve())
1197
1144
  try:
1198
1145
  completed = subprocess.run(
@@ -1205,8 +1152,28 @@ try {
1205
1152
  finally:
1206
1153
  script.unlink(missing_ok=True)
1207
1154
  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)}
1155
+ fail("formula recalculation failed", detail=(completed.stderr or completed.stdout).strip() or None)
1156
+ cached = 0
1157
+ try:
1158
+ import openpyxl
1159
+
1160
+ workbook = openpyxl.load_workbook(str(out), data_only=True)
1161
+ for sheet in workbook.worksheets:
1162
+ for row in sheet.iter_rows():
1163
+ cached += sum(1 for cell in row if cell.value is not None)
1164
+ workbook.close()
1165
+ except Exception:
1166
+ cached = 0
1167
+ return {
1168
+ "ok": True,
1169
+ "kind": "xlsx",
1170
+ "command": "recalc",
1171
+ "input": str(input_path),
1172
+ "output": str(out),
1173
+ "renderer": f"{family}-office" if family == "microsoft" else "kingsoft-wps",
1174
+ "executable": executable,
1175
+ "cached_values": cached,
1176
+ }
1210
1177
 
1211
1178
 
1212
1179
  # ---------------------------------------------------------------------------
@@ -1246,9 +1213,20 @@ def status() -> dict[str, Any]:
1246
1213
  "renderers": {
1247
1214
  "libreoffice": find_soffice(),
1248
1215
  "microsoft_office": {
1249
- "word": office_binary("docx"),
1250
- "powerpoint": office_binary("pptx"),
1251
- "excel": office_binary("xlsx"),
1216
+ "word": _engine_path("docx", "microsoft"),
1217
+ "powerpoint": _engine_path("pptx", "microsoft"),
1218
+ "excel": _engine_path("xlsx", "microsoft"),
1219
+ },
1220
+ "kingsoft_wps": {
1221
+ "writer": _engine_path("docx", "kingsoft"),
1222
+ "presentation": _engine_path("pptx", "kingsoft"),
1223
+ "spreadsheet": _engine_path("xlsx", "kingsoft"),
1224
+ },
1225
+ "can_render": {
1226
+ "docx": bool(office_engine("docx") or find_soffice()),
1227
+ "pptx": bool(office_engine("pptx") or find_soffice()),
1228
+ "xlsx": bool(office_engine("xlsx") or find_soffice()),
1229
+ "pdf": True,
1252
1230
  },
1253
1231
  },
1254
1232
  }
@@ -1280,13 +1258,6 @@ def main() -> None:
1280
1258
  render.add_argument("--dpi", type=int, default=110)
1281
1259
  render.add_argument("--json-out")
1282
1260
 
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
1261
  if kind == "xlsx":
1291
1262
  recalc = commands.add_parser("recalc")
1292
1263
  recalc.add_argument("--input", required=True)
@@ -1321,18 +1292,6 @@ def main() -> None:
1321
1292
  result = pptx_read(input_path)
1322
1293
  else:
1323
1294
  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
1295
  elif args.command == "render":
1337
1296
  input_path = require_file(args.input)
1338
1297
  out_dir = Path(args.out_dir)