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,260 @@
1
+ #!/usr/bin/env python3
2
+ """Validate the Office runtime that the format skills promise.
3
+
4
+ Imports every distribution listed in python/requirements.txt, then asks the
5
+ helper CLI for its own `status` report (packages and renderers) and fails with
6
+ a readable message when anything the skills depend on is missing.
7
+
8
+ Exit codes:
9
+ 0 all required packages import and the helper CLI answered
10
+ 1 one or more required packages is missing or fails to import
11
+ 2 helper CLI missing, crashed, or did not return a status report
12
+ 3 bad path or argument
13
+ 4 --require-renderer was given and no document renderer was found
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import importlib
20
+ import importlib.metadata
21
+ import json
22
+ import re
23
+ import subprocess
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ # Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
28
+ for _stream in (sys.stdout, sys.stderr):
29
+ try:
30
+ _stream.reconfigure(encoding="utf-8", errors="replace")
31
+ except (AttributeError, ValueError):
32
+ pass
33
+
34
+ DEFAULT_ROOT = Path(__file__).resolve().parents[3]
35
+ RENDERER_HINT = (
36
+ "docx/pptx/xlsx rendering needs LibreOffice, Microsoft Office, or WPS Office; "
37
+ "pdf rendering works without any of them (PyMuPDF)"
38
+ )
39
+
40
+
41
+ def canonical(name: str) -> str:
42
+ """Normalise a distribution name the way packaging does."""
43
+ return re.sub(r"[-_.]+", "-", name).lower()
44
+
45
+
46
+ def parse_requirements(path: Path) -> list[str]:
47
+ """Return distribution names from a requirements file, one per line."""
48
+ names: list[str] = []
49
+ for raw in path.read_text(encoding="utf-8").splitlines():
50
+ line = raw.split("#", 1)[0].strip()
51
+ if not line or line.startswith("-"):
52
+ continue
53
+ line = line.split(";", 1)[0].strip()
54
+ name = re.split(r"[<>=!~\[( ]", line, maxsplit=1)[0].strip()
55
+ if name and canonical(name) not in {canonical(n) for n in names}:
56
+ names.append(name)
57
+ return names
58
+
59
+
60
+ def module_map() -> dict[str, list[str]]:
61
+ """Map canonical distribution name -> its top-level import modules."""
62
+ mapping: dict[str, list[str]] = {}
63
+ for module, dists in importlib.metadata.packages_distributions().items():
64
+ for dist in dists:
65
+ mapping.setdefault(canonical(dist), []).append(module)
66
+ return mapping
67
+
68
+
69
+ def check_packages(requirements: list[str]) -> list[dict]:
70
+ """Import each distribution's modules and report per-package state."""
71
+ mapping = module_map()
72
+ results = []
73
+ for name in requirements:
74
+ key = canonical(name)
75
+ modules = sorted(mapping.get(key, []))
76
+ if "pymupdf" in modules:
77
+ # PyMuPDF ships a legacy `fitz` alias that prints a deprecation warning.
78
+ modules = [m for m in modules if m != "fitz"]
79
+ entry: dict = {"distribution": name, "modules": modules}
80
+ try:
81
+ entry["version"] = importlib.metadata.version(name)
82
+ except importlib.metadata.PackageNotFoundError:
83
+ entry.update(ok=False, error="not installed for this interpreter")
84
+ results.append(entry)
85
+ continue
86
+ failures = []
87
+ for module in modules:
88
+ try:
89
+ importlib.import_module(module)
90
+ except Exception as exc: # noqa: BLE001 - report any import failure verbatim
91
+ failures.append(f"{module}: {type(exc).__name__}: {exc}")
92
+ if failures:
93
+ entry.update(ok=False, error="; ".join(failures))
94
+ else:
95
+ entry.update(ok=True)
96
+ results.append(entry)
97
+ return results
98
+
99
+
100
+ def cli_status(python: Path, cli: Path, timeout: float) -> dict:
101
+ """Run `<python> <cli> status` and return the parsed JSON report."""
102
+ proc = subprocess.run(
103
+ [str(python), str(cli), "status"],
104
+ capture_output=True,
105
+ text=True,
106
+ encoding="utf-8",
107
+ errors="replace",
108
+ timeout=timeout,
109
+ cwd=str(cli.parent),
110
+ )
111
+ if proc.returncode != 0:
112
+ raise RuntimeError(
113
+ f"status exited {proc.returncode}: "
114
+ f"{(proc.stderr or proc.stdout).strip()[:800]}"
115
+ )
116
+ try:
117
+ return json.loads(proc.stdout)
118
+ except json.JSONDecodeError as exc:
119
+ raise RuntimeError(f"status did not print JSON: {exc}") from exc
120
+
121
+
122
+ def renderer_summary(report: dict) -> dict:
123
+ """Reduce the CLI renderer block to a simple availability answer."""
124
+ renderers = report.get("renderers") or {}
125
+ office = renderers.get("microsoft_office") or {}
126
+ wps = renderers.get("kingsoft_wps") or {}
127
+ available = {
128
+ "libreoffice": renderers.get("libreoffice") or None,
129
+ "microsoft_office": {k: office.get(k) or None for k in ("word", "powerpoint", "excel")},
130
+ "kingsoft_wps": {k: wps.get(k) or None for k in ("writer", "presentation", "spreadsheet")},
131
+ }
132
+ available["any"] = (
133
+ bool(available["libreoffice"])
134
+ or any(available["microsoft_office"].values())
135
+ or any(available["kingsoft_wps"].values())
136
+ )
137
+ return available
138
+
139
+
140
+ def main(argv: list[str] | None = None) -> int:
141
+ parser = argparse.ArgumentParser(
142
+ description="Validate the Office runtime the skills promise (packages + renderer status).",
143
+ )
144
+ parser.add_argument("--python", default=sys.executable, help="interpreter to validate (default: this one)")
145
+ parser.add_argument(
146
+ "--requirements",
147
+ default=str(DEFAULT_ROOT / "python" / "requirements.txt"),
148
+ help="requirements file (default: <plugin>/python/requirements.txt)",
149
+ )
150
+ parser.add_argument(
151
+ "--cli",
152
+ default=str(DEFAULT_ROOT / "python" / "office_cli.py"),
153
+ help="helper CLI path (default: <plugin>/python/office_cli.py)",
154
+ )
155
+ parser.add_argument("--require-renderer", action="store_true", help="exit 4 when no document renderer is installed")
156
+ parser.add_argument("--timeout", type=float, default=180.0, help="seconds allowed for the status call")
157
+ parser.add_argument("--json", action="store_true", help="print one JSON object instead of prose")
158
+ args = parser.parse_args(argv)
159
+
160
+ python = Path(args.python)
161
+ requirements_path = Path(args.requirements)
162
+ cli = Path(args.cli)
163
+ for label, path in (("requirements", requirements_path), ("cli", cli)):
164
+ if not path.is_file():
165
+ print(f"error: {label} file not found: {path}", file=sys.stderr)
166
+ return 3
167
+ if not python.exists():
168
+ print(f"error: interpreter not found: {python}", file=sys.stderr)
169
+ return 3
170
+
171
+ requirements = parse_requirements(requirements_path)
172
+ if not requirements:
173
+ print(f"error: no packages listed in {requirements_path}", file=sys.stderr)
174
+ return 3
175
+
176
+ packages = check_packages(requirements)
177
+ missing = [p["distribution"] for p in packages if not p["ok"]]
178
+
179
+ report: dict = {
180
+ "ok": False,
181
+ "python": {"executable": str(python), "version": sys.version.split()[0] if python == Path(sys.executable) else None},
182
+ "requirements": {"path": str(requirements_path), "distributions": requirements},
183
+ "cli": {"path": str(cli)},
184
+ "packages": packages,
185
+ "missing": missing,
186
+ "warnings": [],
187
+ }
188
+
189
+ if missing:
190
+ report["error"] = f"{len(missing)} of {len(requirements)} required package(s) missing: {', '.join(missing)}"
191
+ report["remedy"] = f'"{python}" -m pip install -r "{requirements_path}"'
192
+ emit(args, report)
193
+ return 1
194
+
195
+ try:
196
+ status = cli_status(python, cli, args.timeout)
197
+ except (OSError, subprocess.SubprocessError, RuntimeError) as exc:
198
+ report["error"] = f"helper CLI status failed: {exc}"
199
+ emit(args, report)
200
+ return 2
201
+
202
+ if status.get("command") != "status":
203
+ report["error"] = "helper CLI did not identify itself as the status command"
204
+ emit(args, report)
205
+ return 2
206
+
207
+ cli_python = (status.get("python") or {}).get("executable")
208
+ report["python"]["version"] = (status.get("python") or {}).get("version") or report["python"]["version"]
209
+ if cli_python and Path(cli_python).resolve() != python.resolve():
210
+ report["warnings"].append(f"status reported a different interpreter: {cli_python}")
211
+
212
+ cli_missing = [p.get("package") for p in status.get("packages") or [] if not p.get("installed")]
213
+ if cli_missing:
214
+ report["error"] = f"status disagrees with direct imports; still missing: {', '.join(str(p) for p in cli_missing)}"
215
+ emit(args, report)
216
+ return 2
217
+
218
+ renderers = renderer_summary(status)
219
+ report["renderers"] = renderers
220
+ if not renderers["any"]:
221
+ report["warnings"].append(f"no document renderer found: {RENDERER_HINT}")
222
+ if args.require_renderer:
223
+ report["error"] = "no document renderer found (--require-renderer)"
224
+ emit(args, report)
225
+ return 4
226
+
227
+ report["ok"] = True
228
+ emit(args, report)
229
+ return 0
230
+
231
+
232
+ def emit(args: argparse.Namespace, report: dict) -> None:
233
+ """Print the machine report or a short human summary."""
234
+ if args.json:
235
+ print(json.dumps(report, indent=2, ensure_ascii=False))
236
+ return
237
+
238
+ print(f"interpreter {report['python']['executable']} (python {report['python']['version'] or '?'})")
239
+ print(f"requirements {report['requirements']['path']} ({len(report['requirements']['distributions'])} distributions)")
240
+ for item in report["packages"]:
241
+ mark = "ok " if item["ok"] else "FAIL"
242
+ detail = item.get("version") or item.get("error", "")
243
+ modules = f" ({', '.join(item['modules'])})" if item["modules"] else ""
244
+ print(f" [{mark}] {item['distribution']:<16} {detail}{modules}")
245
+ if "renderers" in report:
246
+ r = report["renderers"]
247
+ print(f"renderer libreoffice={r['libreoffice'] or 'none'}; "
248
+ f"office={r['microsoft_office']}; wps={r['kingsoft_wps']}")
249
+ for warning in report["warnings"]:
250
+ print(f"warning {warning}")
251
+ if report.get("error"):
252
+ print(f"FAILED {report['error']}", file=sys.stderr)
253
+ if report.get("remedy"):
254
+ print(f"remedy {report['remedy']}", file=sys.stderr)
255
+ return
256
+ print(f"OK {len(report['packages'])}/{len(report['packages'])} packages import; helper CLI status matches")
257
+
258
+
259
+ if __name__ == "__main__":
260
+ sys.exit(main())
@@ -0,0 +1,139 @@
1
+ # Shared workflow
2
+
3
+ Every format pack follows this loop. The helper CLI is an accelerator for common
4
+ operations, not the boundary of what is possible: it shortens the common path,
5
+ and the skill teaches the rest. The format router adds the spec keys and the
6
+ commands; the order, the checks and the failure handling are the same.
7
+
8
+ ## 0. Read the runtime block first
9
+
10
+ Call the `office_status` tool, then read the `## Office runtime` section that is
11
+ appended to the end of the active `SKILL.md`. It is a fenced JSON block:
12
+
13
+ ```json
14
+ { "python": "...\\python.exe", "cli": "...\\python\\office_cli.py", "skill": "...\\assets\\skills\\office-documents", "shared": "...\\assets\\skills\\shared", "version": "3.13.12" }
15
+ ```
16
+
17
+ - `python` and `cli` are verified absolute paths. Use them verbatim and quote
18
+ them in commands. Never call a bare `python`, `py`, `soffice` or `pdftoppm`.
19
+ - `skill` is the directory of the format pack; its scripts live under
20
+ `<skill>/scripts/`.
21
+ - `shared` is this directory (workflow and annotation recipes).
22
+ - If the block is missing entirely and the skill body instead says *No working
23
+ Python runtime is available*, stop: do not run any helper command and do not
24
+ fabricate a document.
25
+
26
+ `office_status` returns the same facts as JSON with `runtime.state`, the package
27
+ list and `renderers`. Use it when a command fails and you need to know why.
28
+
29
+ ## 1. Decide: create or edit
30
+
31
+ - No existing file → create. Write the output into the user's workspace, not a
32
+ temp directory, unless the user names a location.
33
+ - Existing file to change → edit. **Never write back over the original.** The
34
+ output is `<original>-edited.<ext>` next to the original (or the name the user
35
+ asked for): `report.docx` → `report-edited.docx`. State both paths in the
36
+ final summary.
37
+ - "Improve this document" means read it first (`read` verb) and edit the real
38
+ content; do not rebuild it from scratch and drop its formatting.
39
+
40
+ ## 2. Understand the file, choose the route, then change it
41
+
42
+ Read or inspect the file before changing it (`read` verb first), then pick the
43
+ cheapest route that actually does the job:
44
+
45
+ 1. **CLI verb** — the common case. The helper CLI is spec-driven and prints
46
+ exactly one JSON object on stdout. Failures print
47
+ `{ "ok": false, "error": "..." }` on stderr and exit non-zero.
48
+
49
+ ```bash
50
+ "<python>" "<cli>" docx|pptx|xlsx|pdf create --spec "<spec.json>" --out "<file>"
51
+ "<python>" "<cli>" docx|pptx|xlsx|pdf read --input "<file>"
52
+ "<python>" "<cli>" docx|pptx|xlsx|pdf edit --input "<file>" --spec "<spec.json>" --out "<file-edited>"
53
+ "<python>" "<cli>" docx|pptx|xlsx|pdf render --input "<file>" --out-dir "<dir>"
54
+ "<python>" "<cli>" xlsx recalc --input "<file>" --out "<file>"
55
+ ```
56
+
57
+ Write the spec as a JSON file in the workspace; quote absolute paths.
58
+ `xlsx read` accepts `--data-only`; every command accepts `--json-out <path>`
59
+ when you want the report on disk instead of stdout.
60
+
61
+ 2. **Pack script** — a specialised job the CLI has no verb for. Use the script
62
+ listed in the format router (`<skill>/scripts/`, or `container_tools/` for
63
+ pptx/xlsx). Scripts are argparse-based, support `--help`, and take absolute
64
+ paths; run them with the same `"<python>"`.
65
+
66
+ 3. **Library snippet** — the long tail. When neither the CLI nor a pack script
67
+ covers the change, write a short runnable script in the workspace using the
68
+ installed libraries (`python-docx`, `openpyxl`, `python-pptx`, `pypdf`,
69
+ `PyMuPDF`, `reportlab`, `pdfplumber`), run it with `"<python>"`, then verify
70
+ the file through the same render step. Keep it small and keep the user's
71
+ original untouched.
72
+
73
+ Never answer "the CLI does not support this, so it cannot be done". A missing
74
+ verb is a missing shortcut, not a missing capability: give the library-level
75
+ route, run it, and verify the result like any other change.
76
+
77
+ `check_runtime.py` in this directory validates the interpreter: it imports every
78
+ package from `python/requirements.txt` and runs `<cli> status`. A non-zero exit
79
+ means the environment is broken, not the spec.
80
+
81
+ ## 3. Render and look at the result
82
+
83
+ ```bash
84
+ "<python>" "<cli>" docx render --input "<file>" --out-dir "<dir>"
85
+ ```
86
+
87
+ Output: `<dir>/page-1.png`, `page-2.png`, ... (rasterised at `--dpi`, default
88
+ 110). For docx/pptx/xlsx the intermediate PDF is kept at `<dir>/<stem>.pdf` —
89
+ use it for annotation geometry. `pdf render` rasterises with PyMuPDF and needs
90
+ no external renderer.
91
+
92
+ Open each page with `read_image`. Look for: text overflowing or clipped at
93
+ margins, overlapping shapes, unexplained blank pages, tables spilling past the
94
+ page, fonts replaced by boxes, missing chart/table content, and numbers that do
95
+ not match what the user asked for.
96
+
97
+ Only after the pages look right do you present the file.
98
+
99
+ ## 4. Fix, re-render, present
100
+
101
+ Edit the spec (or the edit spec), re-run the same command, re-render, look
102
+ again. Two or three passes is normal; if a problem survives three passes, say
103
+ what still looks wrong instead of pretending it is fixed.
104
+
105
+ Then call `present` with the final file and reply with one sentence about what
106
+ changed. For an edit, mention the original path and the edited path.
107
+
108
+ ## Rasterizing art
109
+
110
+ Decks, workbooks and documents embed raster images only, so an SVG or a chosen
111
+ PDF page has to become a PNG first; the same call downscales an oversized raster.
112
+
113
+ ```bash
114
+ "<python>" "<shared>/scripts/ensure_raster_image.py" --input "<art.svg>" --dpi 300 --out "<art.png>"
115
+ "<python>" "<shared>/scripts/ensure_raster_image.py" --input "<deck.pdf>" --page 2 --width 1600 --out "<slide-2.png>"
116
+ ```
117
+
118
+ It prints one JSON object with the PNG's `width`/`height` and never overwrites
119
+ the input. Open that PNG with `read_image` and look at it before embedding it:
120
+ a blank, clipped or miscoloured raster is invisible inside the host file.
121
+
122
+ ## Failure ladder
123
+
124
+ | Symptom | Meaning | What you do and say |
125
+ | --- | --- | --- |
126
+ | No `## Office runtime` JSON; skill says *No working Python runtime is available* | The plugin cannot install or find a compatible interpreter | Do not run helper commands, do not write a document by hand. Give the user the remedy command from the skill body, ask them to run it, then call `office_status` again. |
127
+ | `office_status` shows `cli.exists: false`, or `python`/`cli` file not found | The bundled helper or its install is incomplete | Report the missing path. Do not substitute another tool that produces a different file. |
128
+ | `render` fails: `no document renderer found: install LibreOffice, Microsoft Office, or WPS Office` | Create/read/edit work, but docx/pptx/xlsx cannot be rasterised here | Do not claim you inspected it. Say the file was built and validated structurally (read verb), but this machine has no renderer so you could not check its appearance; the preview stays unavailable until Microsoft Office, WPS Office, or LibreOffice is installed. `pdf render` still works. If the user needs visual confidence now, offer to rebuild from a spec or to present the unverified file explicitly labelled as such. |
129
+ | Annotation region does not land on anything, or the payload says `unmapped: true` | The drawn region cannot be tied to an object in the file | Do not silently shift the region or guess a cell/shape. Say what you could identify (preview page, text, resolved cells) and which item you could not locate, then ask the user for the text or cell. |
130
+ | `{"ok": false, "error": "..."}` and non-zero exit | The spec was rejected (unknown key, bad type, missing file) | Read the error text, fix the spec key it names, re-run the same command. Never strip a failing block to make the command pass — that silently deletes user content. |
131
+ | `xlsx recalc` fails with *requires Microsoft Excel or WPS Spreadsheets* | openpyxl cannot evaluate formulas | Report formula cells as uncalculated; do not invent computed values. |
132
+ | The helper has no verb for what the requirement needs | The common path is short; the capability is not limited to it | Take the pack script route, or write a short snippet against `python-docx` / `openpyxl` / `python-pptx` / `pypdf` / `PyMuPDF` / `reportlab`, run it with `<python>`, then `read` the file back and render. Never refuse a job because a verb is missing. |
133
+ | `read` returns an empty structure for a file the user supplied | Unsupported or corrupt input | Say what failed and stop; do not overwrite the user's file with an empty one. |
134
+
135
+ ## Naming rule for edits (restated)
136
+
137
+ `<original>-edited.<ext>`, in the same directory as the original, unless the
138
+ user asked for a different name. Never overwrite the original, never delete it,
139
+ never "clean up" a temp copy over it. Deliver both paths, original first.
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env python3
2
+ """Rasterise SVG artwork, one PDF page, or a raster image to a PNG.
3
+
4
+ Decks, workbooks and documents can only embed raster images, so vector art has
5
+ to become pixels before it can go in, and an oversized raster has to be scaled
6
+ down before it makes a file huge. SVG and PDF go through PyMuPDF in-process;
7
+ raster inputs go through Pillow (first frame, alpha kept or flattened).
8
+
9
+ Success prints exactly one JSON object on stdout. Failures print a JSON error
10
+ object on stderr and exit non-zero. The input file is never overwritten.
11
+
12
+ Exit codes: 0 success, 1 bad arguments, 2 unreadable input or write failure.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ try:
23
+ import pymupdf
24
+ except ImportError: # pragma: no cover - PyMuPDF is a required dependency
25
+ import fitz as pymupdf # type: ignore[no-redef]
26
+
27
+ from PIL import Image, ImageColor
28
+
29
+ VECTOR_SUFFIXES = {".svg", ".pdf"}
30
+ RASTER_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff", ".gif"}
31
+
32
+ for _stream in (sys.stdout, sys.stderr):
33
+ try:
34
+ _stream.reconfigure(encoding="utf-8", errors="replace")
35
+ except (AttributeError, ValueError):
36
+ pass
37
+
38
+
39
+ def fail(message, code=1):
40
+ print(json.dumps({"ok": False, "error": message}, ensure_ascii=False), file=sys.stderr)
41
+ raise SystemExit(code)
42
+
43
+
44
+ def background_color(spec):
45
+ """Return an RGB tuple, or None for a transparent background."""
46
+ if spec.strip().lower() == "transparent":
47
+ return None
48
+ try:
49
+ return ImageColor.getrgb(spec.strip())[:3]
50
+ except ValueError:
51
+ fail(f"--background must be 'transparent' or a colour such as #ffffff, got {spec!r}")
52
+
53
+
54
+ def fit_size(width, height, target_width, target_height):
55
+ """Scale a native pixel size toward the requested box, keeping the aspect ratio.
56
+
57
+ One requested axis sizes the other; with both, the image fits inside the box.
58
+ """
59
+ if not target_width and not target_height:
60
+ return round(width), round(height)
61
+ scales = [t / n for t, n in ((target_width, width), (target_height, height)) if t]
62
+ scale = min(scales)
63
+ return max(1, round(width * scale)), max(1, round(height * scale))
64
+
65
+
66
+ def flatten(image, color):
67
+ """Composite an RGBA image over a solid colour and drop the alpha channel."""
68
+ canvas = Image.new("RGB", image.size, color)
69
+ canvas.paste(image, (0, 0), image)
70
+ return canvas
71
+
72
+
73
+ def vector_page(source, args, background):
74
+ """Rasterise one SVG/PDF page at --dpi, optionally scaled or flattened."""
75
+ try:
76
+ document = pymupdf.open(str(source))
77
+ except Exception as exc: # noqa: BLE001 - surface the library message verbatim
78
+ fail(f"cannot open input: {exc}", code=2)
79
+ if not 1 <= args.page <= document.page_count:
80
+ fail(f"--page {args.page} is outside 1-{document.page_count} for {source.name}")
81
+ page = document[args.page - 1]
82
+ if page.rect.width <= 0 or page.rect.height <= 0:
83
+ fail(f"{source.name} page {args.page} has no drawable area", code=2)
84
+
85
+ base = args.dpi / 72.0
86
+ natural_width = page.rect.width * base
87
+ natural_height = page.rect.height * base
88
+ out_width, out_height = fit_size(natural_width, natural_height, args.width, args.height)
89
+ zoom = base * (out_width / natural_width)
90
+ try:
91
+ pixmap = page.get_pixmap(
92
+ matrix=pymupdf.Matrix(zoom, zoom),
93
+ colorspace=pymupdf.csRGB,
94
+ alpha=True,
95
+ )
96
+ except Exception as exc: # noqa: BLE001 - rasterisation failure is an input problem
97
+ fail(f"cannot rasterize page {args.page}: {exc}", code=2)
98
+ image = Image.frombytes("RGBA", (pixmap.width, pixmap.height), pixmap.samples)
99
+ return flatten(image, background) if background is not None else image
100
+
101
+
102
+ def raster_image(source, args, background):
103
+ """Convert the first frame of a raster input to RGBA, then resize/flatten."""
104
+ try:
105
+ with Image.open(source) as raw:
106
+ raw.seek(0)
107
+ image = raw.convert("RGBA")
108
+ except (OSError, ValueError) as exc:
109
+ fail(f"cannot read raster input: {exc}", code=2)
110
+ if args.width or args.height:
111
+ image = image.resize(fit_size(*image.size, args.width, args.height), Image.Resampling.LANCZOS)
112
+ return flatten(image, background) if background is not None else image
113
+
114
+
115
+ def main():
116
+ parser = argparse.ArgumentParser(
117
+ description="Rasterise an SVG file, one PDF page, or a raster image to PNG.",
118
+ epilog='Example: ensure_raster_image.py --input logo.svg --dpi 300 --out logo.png',
119
+ )
120
+ parser.add_argument("--input", required=True, help="SVG, PDF, or raster image (.png/.jpg/.webp/.bmp/.tif/.gif)")
121
+ parser.add_argument("--out", help="PNG output path (default: <input>-p<page>.png beside the input)")
122
+ parser.add_argument("--dpi", type=int, default=150, help="resolution for SVG/PDF input (default: 150)")
123
+ parser.add_argument("--page", type=int, default=1, help="1-based page for PDF/SVG input (default: 1)")
124
+ parser.add_argument("--width", type=int, help="target width in pixels; the height follows the aspect ratio")
125
+ parser.add_argument("--height", type=int, help="target height in pixels; the width follows the aspect ratio")
126
+ parser.add_argument(
127
+ "--background",
128
+ default="#ffffff",
129
+ help="colour behind transparent pixels, or 'transparent' to keep alpha (default: #ffffff)",
130
+ )
131
+ args = parser.parse_args()
132
+
133
+ source = Path(args.input).expanduser()
134
+ suffix = source.suffix.lower()
135
+ if not source.is_file():
136
+ fail(f"input not found: {source}", code=2)
137
+ if suffix in VECTOR_SUFFIXES:
138
+ kind = suffix.lstrip(".")
139
+ elif suffix in RASTER_SUFFIXES:
140
+ kind = "raster"
141
+ else:
142
+ fail(f"unsupported input extension {suffix or '(none)'}; expected .svg, .pdf, or a raster image")
143
+ if args.dpi <= 0:
144
+ fail("--dpi must be a positive integer")
145
+ if args.page < 1:
146
+ fail("--page must be 1 or greater")
147
+ for label, value in (("--width", args.width), ("--height", args.height)):
148
+ if value is not None and value <= 0:
149
+ fail(f"{label} must be a positive integer")
150
+ if kind == "raster" and args.page != 1:
151
+ fail("raster inputs have one frame; use --page 1")
152
+ background = background_color(args.background)
153
+
154
+ out = Path(args.out).expanduser() if args.out else source.with_name(f"{source.stem}-p{args.page}.png")
155
+ if out.resolve() == source.resolve():
156
+ fail("--out points at the input file; refusing to overwrite it")
157
+ try:
158
+ out.parent.mkdir(parents=True, exist_ok=True)
159
+ except OSError as exc:
160
+ fail(f"cannot create output directory {out.parent}: {exc}", code=2)
161
+
162
+ image = raster_image(source, args, background) if kind == "raster" else vector_page(source, args, background)
163
+ try:
164
+ image.save(str(out), "PNG")
165
+ except OSError as exc:
166
+ fail(f"cannot write {out}: {exc}", code=2)
167
+
168
+ print(
169
+ json.dumps(
170
+ {
171
+ "ok": True,
172
+ "input": str(source),
173
+ "out": str(out),
174
+ "source_kind": kind,
175
+ "page": args.page,
176
+ "width": image.width,
177
+ "height": image.height,
178
+ "dpi": args.dpi if kind != "raster" else None,
179
+ "bytes": out.stat().st_size,
180
+ },
181
+ ensure_ascii=False,
182
+ )
183
+ )
184
+
185
+
186
+ if __name__ == "__main__":
187
+ main()