@cteno/sdk-linux-x64 0.0.0-stage → 2.10.13

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 (116) hide show
  1. package/LICENSE.daemon +31 -0
  2. package/README.md +4 -2
  3. package/agents/flow-analyzer/AGENT.md +71 -0
  4. package/bin/cteno-host-cli +4 -0
  5. package/bin/cteno-wiki +0 -0
  6. package/bin/rg +0 -0
  7. package/bin/secret-exec +0 -0
  8. package/manifest.json +12 -0
  9. package/package.json +33 -4
  10. package/plugins/agent-secrets/.cteno-plugin/plugin.json +133 -0
  11. package/plugins/agent-secrets/bin/linux-x64/fnox +4 -0
  12. package/plugins/agent-secrets/bin/linux-x64/secret-exec +0 -0
  13. package/plugins/agent-secrets/skills/secret/SKILL.md +201 -0
  14. package/plugins/cteno.browser/.cteno-plugin/plugin.json +65 -0
  15. package/plugins/cteno.browser/skills/browser/SKILL.md +28 -0
  16. package/plugins/cteno.inbox/.cteno-plugin/plugin.json +128 -0
  17. package/plugins/cteno.inbox/bin/all/cteno-connector-credential +31 -0
  18. package/plugins/cteno.inbox/bin/all/cteno-telegram-connector +33 -0
  19. package/plugins/cteno.inbox/bin/linux-x64/cteno-inbox +0 -0
  20. package/plugins/cteno.inbox/skills/cteno-inbox/SKILL.md +374 -0
  21. package/plugins/cteno.inbox/tools/inbox_focus/TOOL.md +44 -0
  22. package/plugins/cteno.memory/.cteno-plugin/plugin.json +176 -0
  23. package/plugins/cteno.memory/agents/llm-wiki/AGENT.md +96 -0
  24. package/plugins/cteno.memory/bin/linux-x64/cteno-wiki +0 -0
  25. package/plugins/cteno.memory/skills/codebase-wiki/SKILL.md +52 -0
  26. package/plugins/cteno.memory/skills/cteno.memory.native/SKILL.md +110 -0
  27. package/plugins/cteno.memory/tools/memory/TOOL.md +492 -0
  28. package/plugins/cteno.memory/ui/index.html +822 -0
  29. package/plugins/cteno.models/.cteno-plugin/plugin.json +191 -0
  30. package/plugins/cteno.websearch/.cteno-plugin/plugin.json +45 -0
  31. package/plugins/jev/.cteno-plugin/plugin.json +305 -0
  32. package/plugins/jev/skills/jev-browser/SKILL.md +66 -0
  33. package/plugins/jev/skills/jev-browser/jev_browser.py +335 -0
  34. package/skills/agent-create/SKILL.md +86 -0
  35. package/skills/cteno-host-cli/SKILL.md +248 -0
  36. package/skills/docx/LICENSE +23 -0
  37. package/skills/docx/NOTICE.md +8 -0
  38. package/skills/docx/SKILL.md +69 -0
  39. package/skills/docx/references/cjk_typography.md +357 -0
  40. package/skills/docx/references/design_principles.md +819 -0
  41. package/skills/docx/references/revisions-and-comments.md +88 -0
  42. package/skills/docx/references/track_changes_guide.md +200 -0
  43. package/skills/docx/references/typography_guide.md +294 -0
  44. package/skills/docx/scripts/cjk_fonts.py +244 -0
  45. package/skills/docx/scripts/docx_comments.py +289 -0
  46. package/skills/docx/scripts/docx_common.py +92 -0
  47. package/skills/docx/scripts/docx_create.py +259 -0
  48. package/skills/docx/scripts/docx_edit.py +250 -0
  49. package/skills/docx/scripts/docx_read.py +149 -0
  50. package/skills/docx/scripts/docx_revisions.py +146 -0
  51. package/skills/docx/scripts/docx_template.py +70 -0
  52. package/skills/docx/scripts/docx_validate.py +156 -0
  53. package/skills/docx/scripts/render.py +250 -0
  54. package/skills/docx/tests/test_docx_skill.py +525 -0
  55. package/skills/home-devices/SKILL.md +121 -0
  56. package/skills/mcp/SKILL.md +72 -0
  57. package/skills/mcp/scripts/mcp_setup.py +439 -0
  58. package/skills/pdf/LICENSE +23 -0
  59. package/skills/pdf/NOTICE.md +8 -0
  60. package/skills/pdf/SKILL.md +63 -0
  61. package/skills/pdf/references/design-tokens.md +381 -0
  62. package/skills/pdf/references/forms.md +99 -0
  63. package/skills/pdf/references/nano-pdf-editing.md +40 -0
  64. package/skills/pdf/references/ocr-extraction.md +165 -0
  65. package/skills/pdf/scripts/_raster.py +76 -0
  66. package/skills/pdf/scripts/extract_marker.py +88 -0
  67. package/skills/pdf/scripts/extract_pymupdf.py +101 -0
  68. package/skills/pdf/scripts/pdf_create.py +191 -0
  69. package/skills/pdf/scripts/pdf_fill_form.py +97 -0
  70. package/skills/pdf/scripts/pdf_form_layout.py +168 -0
  71. package/skills/pdf/scripts/pdf_make_form.py +145 -0
  72. package/skills/pdf/scripts/pdf_merge.py +50 -0
  73. package/skills/pdf/scripts/pdf_meta.py +115 -0
  74. package/skills/pdf/scripts/pdf_page_image.py +99 -0
  75. package/skills/pdf/scripts/pdf_read.py +153 -0
  76. package/skills/pdf/scripts/pdf_secure.py +71 -0
  77. package/skills/pdf/scripts/pdf_split.py +84 -0
  78. package/skills/pdf/scripts/pdf_stamp.py +143 -0
  79. package/skills/pdf/scripts/pdf_watermark.py +51 -0
  80. package/skills/pdf/scripts/render.py +250 -0
  81. package/skills/pdf/tests/test_pdf_skill.py +415 -0
  82. package/skills/pptx/LICENSE +23 -0
  83. package/skills/pptx/NOTICE.md +8 -0
  84. package/skills/pptx/SKILL.md +62 -0
  85. package/skills/pptx/references/design-system.md +392 -0
  86. package/skills/pptx/references/pptxgenjs.md +420 -0
  87. package/skills/pptx/references/slide-types.md +413 -0
  88. package/skills/pptx/scripts/cjk_fonts.py +244 -0
  89. package/skills/pptx/scripts/pptx_create.py +240 -0
  90. package/skills/pptx/scripts/pptx_edit.py +436 -0
  91. package/skills/pptx/scripts/pptx_from_template.py +88 -0
  92. package/skills/pptx/scripts/pptx_read.py +131 -0
  93. package/skills/pptx/scripts/pptx_render.py +96 -0
  94. package/skills/pptx/scripts/render.py +250 -0
  95. package/skills/pptx/tests/test_powerpoint_skill.py +477 -0
  96. package/skills/skill-create/SKILL.md +114 -0
  97. package/skills/video-generation/SKILL.md +27 -0
  98. package/skills/xlsx/LICENSE +23 -0
  99. package/skills/xlsx/NOTICE.md +8 -0
  100. package/skills/xlsx/SKILL.md +63 -0
  101. package/skills/xlsx/references/format.md +71 -0
  102. package/skills/xlsx/references/restructuring.md +71 -0
  103. package/skills/xlsx/scripts/csv_to_xlsx.py +110 -0
  104. package/skills/xlsx/scripts/formula_check.py +428 -0
  105. package/skills/xlsx/scripts/render.py +250 -0
  106. package/skills/xlsx/scripts/xlsx_create.py +265 -0
  107. package/skills/xlsx/scripts/xlsx_edit.py +269 -0
  108. package/skills/xlsx/scripts/xlsx_read.py +166 -0
  109. package/skills/xlsx/scripts/xlsx_recalc.py +119 -0
  110. package/skills/xlsx/scripts/xlsx_restructure.py +343 -0
  111. package/skills/xlsx/scripts/xlsx_to_csv.py +75 -0
  112. package/skills/xlsx/tests/test_xlsx_skill.py +544 -0
  113. package/tools/create_goal/TOOL.md +36 -0
  114. package/tools/image_generation/TOOL.md +81 -0
  115. package/tools/skill/TOOL.md +117 -0
  116. package/tools/update_profile_notes/TOOL.md +39 -0
@@ -0,0 +1,428 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: MIT
3
+ """
4
+ formula_check.py — Static formula validator for xlsx files.
5
+
6
+ Usage:
7
+ python3 formula_check.py <input.xlsx>
8
+ python3 formula_check.py <input.xlsx> --json # machine-readable output
9
+ python3 formula_check.py <input.xlsx> --report # standardized validation report (JSON)
10
+ python3 formula_check.py <input.xlsx> --report -o out # report to file
11
+ python3 formula_check.py <input.xlsx> --sheet Sales # limit to one sheet
12
+ python3 formula_check.py <input.xlsx> --summary # error counts only, no details
13
+
14
+ What it checks:
15
+ 1. Error-value cells: <c t="e"><v>#REF!</v></c> — all 7 Excel error types
16
+ 2. Broken cross-sheet references: formula references a sheet not in workbook.xml
17
+ 3. Broken named-range references: formula references a name not in workbook.xml <definedNames>
18
+ 4. Shared formula integrity: shared formula primary cell exists and has formula text
19
+ 5. Missing <v> on t="e" cells (malformed XML)
20
+
21
+ Checks NOT performed (require dynamic recalculation):
22
+ - Runtime errors that only appear after formulas execute (#DIV/0! on empty denominator, etc.)
23
+ -> Use libreoffice_recalc.py + re-run formula_check.py for dynamic validation
24
+
25
+ Exit code:
26
+ 0 — no errors found
27
+ 1 — errors detected (or file cannot be opened)
28
+ """
29
+
30
+ import sys
31
+ import zipfile
32
+ import xml.etree.ElementTree as ET
33
+ import re
34
+ import json
35
+
36
+ # OOXML SpreadsheetML namespace
37
+ NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
38
+ NSP = f"{{{NS}}}"
39
+
40
+ # All 7 standard Excel formula error types
41
+ EXCEL_ERRORS = {"#REF!", "#DIV/0!", "#VALUE!", "#NAME?", "#NULL!", "#NUM!", "#N/A"}
42
+
43
+ # Excel built-in function names (subset of common ones) — used for #NAME? heuristic
44
+ # Full list: https://support.microsoft.com/en-us/office/excel-functions-alphabetical
45
+ _BUILTIN_FUNCTIONS = {
46
+ "ABS", "AND", "AVERAGE", "AVERAGEIF", "AVERAGEIFS", "CEILING", "CHOOSE",
47
+ "COUNTA", "COUNTIF", "COUNTIFS", "COUNT", "DATE", "EDATE", "EOMONTH",
48
+ "FALSE", "FILTER", "FIND", "FLOOR", "IF", "IFERROR", "IFNA", "IFS",
49
+ "INDEX", "INDIRECT", "INT", "IRR", "ISBLANK", "ISERROR", "ISNA", "ISNUMBER",
50
+ "LARGE", "LEFT", "LEN", "LOOKUP", "LOWER", "MATCH", "MAX", "MID", "MIN",
51
+ "MOD", "MONTH", "NETWORKDAYS", "NOT", "NOW", "NPV", "OFFSET", "OR",
52
+ "PMT", "PV", "RAND", "RANK", "RIGHT", "ROUND", "ROUNDDOWN", "ROUNDUP",
53
+ "ROW", "ROWS", "SEARCH", "SMALL", "SORT", "SQRT", "SUBSTITUTE", "SUM",
54
+ "SUMIF", "SUMIFS", "SUMPRODUCT", "TEXT", "TODAY", "TRANSPOSE", "TRIM",
55
+ "TRUE", "UNIQUE", "UPPER", "VALUE", "VLOOKUP", "HLOOKUP", "XLOOKUP",
56
+ "XMATCH", "XNPV", "XIRR", "YEAR", "YEARFRAC",
57
+ }
58
+
59
+
60
+ def get_sheet_names(z: zipfile.ZipFile) -> dict[str, str]:
61
+ """Return dict of {r:id -> sheet_name} from workbook.xml."""
62
+ wb_xml = z.read("xl/workbook.xml")
63
+ wb = ET.fromstring(wb_xml)
64
+ rel_ns = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
65
+ sheets = {}
66
+ for sheet in wb.findall(f".//{NSP}sheet"):
67
+ name = sheet.get("name", "")
68
+ rid = sheet.get(f"{{{rel_ns}}}id", "")
69
+ sheets[rid] = name
70
+ return sheets
71
+
72
+
73
+ def get_defined_names(z: zipfile.ZipFile) -> set[str]:
74
+ """Return set of named ranges defined in workbook.xml <definedNames>."""
75
+ wb_xml = z.read("xl/workbook.xml")
76
+ wb = ET.fromstring(wb_xml)
77
+ names = set()
78
+ for dn in wb.findall(f".//{NSP}definedName"):
79
+ n = dn.get("name", "")
80
+ if n:
81
+ names.add(n)
82
+ return names
83
+
84
+
85
+ def get_sheet_files(z: zipfile.ZipFile) -> dict[str, str]:
86
+ """Return dict of {r:id -> xl/worksheets/sheetN.xml} from workbook.xml.rels."""
87
+ rels_xml = z.read("xl/_rels/workbook.xml.rels")
88
+ rels = ET.fromstring(rels_xml)
89
+ mapping = {}
90
+ for rel in rels:
91
+ rid = rel.get("Id", "")
92
+ target = rel.get("Target", "")
93
+ if "worksheets" in target:
94
+ # Target may be relative: "worksheets/sheet1.xml" -> "xl/worksheets/sheet1.xml"
95
+ if not target.startswith("xl/"):
96
+ target = "xl/" + target
97
+ mapping[rid] = target
98
+ return mapping
99
+
100
+
101
+ def extract_sheet_refs(formula: str) -> list[str]:
102
+ """
103
+ Extract all sheet names referenced in a formula string.
104
+
105
+ Handles:
106
+ - 'Sheet Name'!A1 (quoted, may contain spaces)
107
+ - SheetName!A1 (unquoted, no spaces)
108
+
109
+ Returns a list of sheet name strings (may contain duplicates if the same
110
+ sheet is referenced multiple times in one formula).
111
+ """
112
+ refs = []
113
+ # Quoted sheet names: 'Sheet Name'!
114
+ for m in re.finditer(r"'([^']+)'!", formula):
115
+ refs.append(m.group(1))
116
+ # Unquoted sheet names: SheetName! (not preceded by a single quote)
117
+ for m in re.finditer(r"(?<!')([A-Za-z_\u4e00-\u9fff][A-Za-z0-9_.·\u4e00-\u9fff]*)!", formula):
118
+ refs.append(m.group(1))
119
+ return refs
120
+
121
+
122
+ def extract_name_refs(formula: str) -> list[str]:
123
+ """
124
+ Extract identifiers in a formula that could be named range references.
125
+
126
+ Heuristic: identifiers that:
127
+ - Are not preceded by a sheet reference (no "!" before them)
128
+ - Are not followed by "(" (which would make them function calls)
129
+ - Match the pattern of a name (letters/underscore start, alphanumeric/underscore body)
130
+ - Are not single-letter column references or row references
131
+
132
+ This is approximate. False positives are possible; false negatives are rare.
133
+ """
134
+ names = []
135
+ # Remove quoted sheet references first to avoid false matches
136
+ formula_clean = re.sub(r"'[^']*'![A-Z$0-9:]+", "", formula)
137
+ formula_clean = re.sub(r"[A-Za-z_][A-Za-z0-9_.]*![A-Z$0-9:]+", "", formula_clean)
138
+ # Find identifiers not followed by "(" (not function calls)
139
+ for m in re.finditer(r"\b([A-Za-z_][A-Za-z0-9_]{2,})\b(?!\s*\()", formula_clean):
140
+ candidate = m.group(1)
141
+ # Exclude Excel cell references like A1, B10, AA100
142
+ if re.fullmatch(r"[A-Z]{1,3}[0-9]+", candidate):
143
+ continue
144
+ # Exclude built-in function names (they appear without parens sometimes in array formulas)
145
+ if candidate.upper() in _BUILTIN_FUNCTIONS:
146
+ continue
147
+ names.append(candidate)
148
+ return names
149
+
150
+
151
+ def check(xlsx_path: str, sheet_filter: str | None = None) -> dict:
152
+ """
153
+ Run all static checks on the given xlsx file.
154
+
155
+ Args:
156
+ xlsx_path: path to the .xlsx file
157
+ sheet_filter: if provided, only check the sheet with this name
158
+
159
+ Returns:
160
+ A dict with keys:
161
+ file, sheets_checked, formula_count, shared_formula_ranges,
162
+ error_count, errors
163
+ """
164
+ results = {
165
+ "file": xlsx_path,
166
+ "sheets_checked": [],
167
+ "formula_count": 0,
168
+ "shared_formula_ranges": 0, # number of shared formula definitions
169
+ "error_count": 0,
170
+ "errors": [],
171
+ }
172
+
173
+ try:
174
+ z = zipfile.ZipFile(xlsx_path, "r")
175
+ except (zipfile.BadZipFile, FileNotFoundError) as e:
176
+ results["errors"].append({"type": "file_error", "message": str(e)})
177
+ results["error_count"] = 1
178
+ return results
179
+
180
+ with z:
181
+ sheet_names = get_sheet_names(z)
182
+ sheet_files = get_sheet_files(z)
183
+ valid_sheet_names = set(sheet_names.values())
184
+ defined_names = get_defined_names(z)
185
+
186
+ for rid, sheet_name in sheet_names.items():
187
+ # Apply sheet filter if requested
188
+ if sheet_filter and sheet_name != sheet_filter:
189
+ continue
190
+
191
+ ws_file = sheet_files.get(rid)
192
+ if not ws_file or ws_file not in z.namelist():
193
+ continue
194
+
195
+ results["sheets_checked"].append(sheet_name)
196
+ ws_xml = z.read(ws_file)
197
+ ws = ET.fromstring(ws_xml)
198
+
199
+ # Track shared formula IDs seen on this sheet (si -> primary cell ref)
200
+ shared_primary: dict[str, str] = {}
201
+
202
+ for cell in ws.findall(f".//{NSP}c"):
203
+ cell_ref = cell.get("r", "?")
204
+ cell_type = cell.get("t", "n")
205
+
206
+ # ── Check 1: error-value cell ──────────────────────────────
207
+ if cell_type == "e":
208
+ v_elem = cell.find(f"{NSP}v")
209
+ if v_elem is None:
210
+ # Malformed: t="e" but no <v> — record as structural issue
211
+ results["errors"].append(
212
+ {
213
+ "type": "malformed_error_cell",
214
+ "sheet": sheet_name,
215
+ "cell": cell_ref,
216
+ "detail": "Cell has t='e' but no <v> child element",
217
+ }
218
+ )
219
+ results["error_count"] += 1
220
+ else:
221
+ error_val = v_elem.text or "#UNKNOWN"
222
+ f_elem = cell.find(f"{NSP}f")
223
+ results["errors"].append(
224
+ {
225
+ "type": "error_value",
226
+ "error": error_val,
227
+ "sheet": sheet_name,
228
+ "cell": cell_ref,
229
+ # Include formula text if present
230
+ "formula": f_elem.text if (f_elem is not None and f_elem.text) else None,
231
+ }
232
+ )
233
+ results["error_count"] += 1
234
+
235
+ # ── Check 2 & 3: formulas ──────────────────────────────────
236
+ f_elem = cell.find(f"{NSP}f")
237
+ if f_elem is None:
238
+ continue
239
+
240
+ f_type = f_elem.get("t", "") # "shared", "array", or "" for normal
241
+ f_si = f_elem.get("si") # shared formula group ID
242
+
243
+ # Count formulas:
244
+ # - Normal formulas: always count
245
+ # - Shared formula PRIMARY (has text + ref attribute): count once
246
+ # - Shared formula CONSUMER (si only, no text): do NOT count separately
247
+ # (they are covered by the primary's ref range)
248
+ if f_type == "shared" and f_elem.text is None:
249
+ # Consumer cell: skip formula counting and cross-ref checks
250
+ # (the primary cell already covers this formula)
251
+ continue
252
+
253
+ formula = f_elem.text or ""
254
+
255
+ if f_type == "shared" and f_elem.get("ref"):
256
+ results["shared_formula_ranges"] += 1
257
+ if f_si is not None:
258
+ shared_primary[f_si] = cell_ref
259
+
260
+ if formula:
261
+ results["formula_count"] += 1
262
+
263
+ # Check 2: cross-sheet references
264
+ for ref_sheet in extract_sheet_refs(formula):
265
+ if ref_sheet not in valid_sheet_names:
266
+ results["errors"].append(
267
+ {
268
+ "type": "broken_sheet_ref",
269
+ "sheet": sheet_name,
270
+ "cell": cell_ref,
271
+ "formula": formula,
272
+ "missing_sheet": ref_sheet,
273
+ "valid_sheets": sorted(valid_sheet_names),
274
+ }
275
+ )
276
+ results["error_count"] += 1
277
+
278
+ # Check 3: named range references
279
+ # Only flag if the name is not a built-in and not a sheet-prefixed ref
280
+ for name_ref in extract_name_refs(formula):
281
+ if name_ref not in defined_names:
282
+ results["errors"].append(
283
+ {
284
+ "type": "unknown_name_ref",
285
+ "sheet": sheet_name,
286
+ "cell": cell_ref,
287
+ "formula": formula,
288
+ "unknown_name": name_ref,
289
+ "defined_names": sorted(defined_names),
290
+ "note": "Heuristic check — verify manually if this is a false positive",
291
+ }
292
+ )
293
+ results["error_count"] += 1
294
+
295
+ return results
296
+
297
+
298
+ def build_report(results: dict) -> dict:
299
+ """
300
+ Transform raw check() output into a standardized validation report.
301
+
302
+ Usage:
303
+ python3 formula_check.py <input.xlsx> --report # JSON report to stdout
304
+ python3 formula_check.py <input.xlsx> --report -o out # JSON report to file
305
+ """
306
+ from collections import Counter
307
+
308
+ errors = results.get("errors", [])
309
+ error_types = [e.get("error", e.get("type", "unknown")) for e in errors]
310
+
311
+ return {
312
+ "status": "success" if results["error_count"] == 0 else "errors_found",
313
+ "file": results["file"],
314
+ "sheets_checked": results["sheets_checked"],
315
+ "total_formulas": results["formula_count"],
316
+ "total_errors": results["error_count"],
317
+ "shared_formula_ranges": results.get("shared_formula_ranges", 0),
318
+ "errors_by_type": dict(Counter(error_types)) if errors else {},
319
+ "errors": errors,
320
+ }
321
+
322
+
323
+ def main() -> None:
324
+ # Text I/O is UTF-8 regardless of the locale (GBK on Chinese Windows).
325
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
326
+ try:
327
+ stream.reconfigure(encoding="utf-8")
328
+ except Exception:
329
+ pass
330
+ use_json = "--json" in sys.argv
331
+ use_report = "--report" in sys.argv
332
+ summary_only = "--summary" in sys.argv
333
+ output_file = None
334
+ sheet_filter = None
335
+ args_clean = []
336
+
337
+ i = 1
338
+ while i < len(sys.argv):
339
+ arg = sys.argv[i]
340
+ if arg == "--sheet" and i + 1 < len(sys.argv):
341
+ sheet_filter = sys.argv[i + 1]
342
+ i += 2
343
+ elif arg == "-o" and i + 1 < len(sys.argv):
344
+ output_file = sys.argv[i + 1]
345
+ i += 2
346
+ elif arg.startswith("--"):
347
+ i += 1 # skip flags already handled
348
+ else:
349
+ args_clean.append(arg)
350
+ i += 1
351
+
352
+ if not args_clean:
353
+ print("Usage: formula_check.py <input.xlsx> [--json] [--report [-o FILE]] [--sheet NAME] [--summary]")
354
+ sys.exit(1)
355
+
356
+ results = check(args_clean[0], sheet_filter=sheet_filter)
357
+
358
+ if use_report:
359
+ report = build_report(results)
360
+ output = json.dumps(report, indent=2, ensure_ascii=False)
361
+ if output_file:
362
+ with open(output_file, "w", encoding="utf-8") as f:
363
+ f.write(output + "\n")
364
+ else:
365
+ print(output)
366
+ sys.exit(1 if results["error_count"] > 0 else 0)
367
+
368
+ if use_json:
369
+ print(json.dumps(results, indent=2, ensure_ascii=False))
370
+ sys.exit(1 if results["error_count"] > 0 else 0)
371
+
372
+ # Human-readable output
373
+ sheets = ", ".join(results["sheets_checked"]) or "(none)"
374
+ if sheet_filter:
375
+ sheets = f"{sheet_filter} (filtered)"
376
+
377
+ print(f"File : {results['file']}")
378
+ print(f"Sheets : {sheets}")
379
+ print(f"Formulas checked : {results['formula_count']} distinct formula cells")
380
+ print(f"Shared formula ranges : {results['shared_formula_ranges']} ranges")
381
+ print(f"Errors found : {results['error_count']}")
382
+
383
+ if not summary_only and results["errors"]:
384
+ print("\n── Error Details ──")
385
+ for e in results["errors"]:
386
+ if e["type"] == "error_value":
387
+ formula_hint = f" (formula: {e['formula']})" if e.get("formula") else ""
388
+ print(f" [FAIL] [{e['sheet']}!{e['cell']}] contains {e['error']}{formula_hint}")
389
+ elif e["type"] == "broken_sheet_ref":
390
+ print(
391
+ f" [FAIL] [{e['sheet']}!{e['cell']}] references missing sheet "
392
+ f"'{e['missing_sheet']}'"
393
+ )
394
+ print(f" Formula: {e['formula']}")
395
+ print(f" Valid sheets: {e.get('valid_sheets', [])}")
396
+ elif e["type"] == "unknown_name_ref":
397
+ print(
398
+ f" [WARN] [{e['sheet']}!{e['cell']}] uses unknown name "
399
+ f"'{e['unknown_name']}' (heuristic — verify manually)"
400
+ )
401
+ print(f" Formula: {e['formula']}")
402
+ print(f" Defined names: {e.get('defined_names', [])}")
403
+ elif e["type"] == "malformed_error_cell":
404
+ print(f" [FAIL] [{e['sheet']}!{e['cell']}] malformed error cell: {e['detail']}")
405
+ elif e["type"] == "file_error":
406
+ print(f" [FAIL] File error: {e['message']}")
407
+ print()
408
+
409
+ if results["error_count"] == 0:
410
+ print("PASS — No formula errors detected")
411
+ else:
412
+ # Separate definitive failures from heuristic warnings
413
+ hard_errors = [e for e in results["errors"] if e["type"] != "unknown_name_ref"]
414
+ warnings = [e for e in results["errors"] if e["type"] == "unknown_name_ref"]
415
+ if hard_errors:
416
+ print(f"FAIL — {len(hard_errors)} error(s) must be fixed before delivery")
417
+ if warnings:
418
+ print(f"WARN — {len(warnings)} heuristic warning(s) require manual review")
419
+ sys.exit(1)
420
+ else:
421
+ # Only heuristic warnings — do not block delivery but alert
422
+ print(f"PASS with WARN — {len(warnings)} heuristic warning(s) require manual review")
423
+ # Exit 0: heuristic warnings alone do not block delivery
424
+ sys.exit(0)
425
+
426
+
427
+ if __name__ == "__main__":
428
+ main()
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env python3
2
+ """Render an office file (or PDF) to page/slide PNGs so you can LOOK at the result.
3
+
4
+ Usage:
5
+ python3 render.py FILE [--outdir DIR] [--dpi 110] [--pages 1-3] [--pdf-only]
6
+
7
+ FILE may be .docx/.doc/.pptx/.ppt/.xlsx/.xls/.odt/.odp/.ods/.rtf/.csv or .pdf.
8
+ Office files are converted to PDF with LibreOffice (soffice) in an isolated,
9
+ throw-away user profile (so a running LibreOffice or a stale lock never hangs
10
+ the conversion), then split into PNGs with pdftoppm, or pypdfium2 if poppler
11
+ is missing.
12
+
13
+ Prints JSON:
14
+ {"rendered": true, "pdf": "DIR/name.pdf", "pages": N, "files": ["DIR/name-01.png", ...]}
15
+ {"rendered": false, "missing": [...], "install": "..."} (exit 0, nothing rendered)
16
+ Exit 1 only when a tool was present but the conversion itself failed.
17
+
18
+ This file is shared verbatim by the docx, pptx, xlsx and pdf skills.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import glob
24
+ import json
25
+ import os
26
+ import platform
27
+ import shutil
28
+ import subprocess
29
+ import sys
30
+ import tempfile
31
+
32
+ OFFICE_EXT = {".docx", ".doc", ".dotx", ".pptx", ".ppt", ".potx", ".xlsx", ".xlsm", ".xls",
33
+ ".odt", ".odp", ".ods", ".rtf", ".csv"}
34
+
35
+
36
+ def find_soffice() -> str | None:
37
+ override = os.environ.get("SOFFICE")
38
+ if override is not None: # explicit path, or "" to mean "no LibreOffice"
39
+ return override if override and os.path.exists(override) else None
40
+ for name in ("soffice", "libreoffice"):
41
+ p = shutil.which(name)
42
+ if p:
43
+ return p
44
+ for p in ("/Applications/LibreOffice.app/Contents/MacOS/soffice",
45
+ r"C:\Program Files\LibreOffice\program\soffice.exe",
46
+ r"C:\Program Files (x86)\LibreOffice\program\soffice.exe",
47
+ "/usr/lib/libreoffice/program/soffice", "/opt/libreoffice/program/soffice"):
48
+ if os.path.exists(p):
49
+ return p
50
+ return None
51
+
52
+
53
+ def install_hint(missing: list[str]) -> str:
54
+ sysname = platform.system()
55
+ tips = []
56
+ if "soffice" in missing:
57
+ tips.append({"Darwin": "brew install --cask libreoffice (or download from libreoffice.org)",
58
+ "Linux": "sudo apt-get install -y libreoffice-core libreoffice-writer libreoffice-calc libreoffice-impress",
59
+ "Windows": "winget install TheDocumentFoundation.LibreOffice"}.get(sysname, "install LibreOffice"))
60
+ if "pdftoppm" in missing:
61
+ tips.append({"Darwin": "brew install poppler", "Linux": "sudo apt-get install -y poppler-utils",
62
+ "Windows": "pip install pypdfium2"}.get(sysname, "pip install pypdfium2"))
63
+ return "; ".join(tips)
64
+
65
+
66
+ # Windows/Office CJK font names -> fonts that actually exist on this machine, so the
67
+ # preview shows real glyphs with similar metrics instead of empty boxes. Only the
68
+ # throw-away LibreOffice profile used for rendering is changed; the document is not.
69
+ CJK_SUBSTITUTES = {
70
+ "Darwin": {
71
+ "SimSun": "Songti SC", "宋体": "Songti SC", "NSimSun": "Songti SC", "SimSun-ExtB": "Songti SC",
72
+ "SimHei": "Heiti SC", "黑体": "Heiti SC", "Microsoft YaHei": "Hiragino Sans GB",
73
+ "微软雅黑": "Hiragino Sans GB", "Microsoft JhengHei": "Heiti TC", "PMingLiU": "Songti TC",
74
+ "FangSong": "STFangsong", "FangSong_GB2312": "STFangsong", "仿宋": "STFangsong", "仿宋_GB2312": "STFangsong",
75
+ "KaiTi": "Kaiti SC", "KaiTi_GB2312": "Kaiti SC", "楷体": "Kaiti SC", "楷体_GB2312": "Kaiti SC",
76
+ "DengXian": "Hiragino Sans GB", "等线": "Hiragino Sans GB",
77
+ "FZXiaoBiaoSong-B05S": "Songti SC", "方正小标宋简体": "Songti SC",
78
+ },
79
+ "Linux": {k: v for k, v in (
80
+ ("SimSun", "Noto Serif CJK SC"), ("宋体", "Noto Serif CJK SC"), ("NSimSun", "Noto Serif CJK SC"),
81
+ ("SimHei", "Noto Sans CJK SC"), ("黑体", "Noto Sans CJK SC"), ("Microsoft YaHei", "Noto Sans CJK SC"),
82
+ ("微软雅黑", "Noto Sans CJK SC"), ("FangSong", "Noto Serif CJK SC"), ("FangSong_GB2312", "Noto Serif CJK SC"),
83
+ ("KaiTi", "Noto Serif CJK SC"), ("KaiTi_GB2312", "Noto Serif CJK SC"), ("DengXian", "Noto Sans CJK SC"),
84
+ ("FZXiaoBiaoSong-B05S", "Noto Serif CJK SC"))},
85
+ }
86
+
87
+
88
+ SYSTEM_CJK_FONT_FILES = [
89
+ # LibreOffice on macOS does not see these system fonts; link them into the profile so CJK renders.
90
+ "/System/Library/Fonts/Hiragino Sans GB.ttc", "/System/Library/Fonts/STHeiti Medium.ttc",
91
+ "/System/Library/Fonts/STHeiti Light.ttc", "/System/Library/Fonts/Supplemental/Songti.ttc",
92
+ "/System/Library/Fonts/PingFang.ttc", "/System/Library/Fonts/Supplemental/Kaiti.ttc",
93
+ "/System/Library/Fonts/Supplemental/STFangsong.ttf", "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
94
+ ]
95
+
96
+
97
+ def write_profile(prof: str) -> None:
98
+ fonts_dir = os.path.join(prof, "user", "fonts")
99
+ os.makedirs(fonts_dir, exist_ok=True)
100
+ for f in SYSTEM_CJK_FONT_FILES:
101
+ if os.path.exists(f):
102
+ try:
103
+ os.symlink(f, os.path.join(fonts_dir, os.path.basename(f)))
104
+ except OSError:
105
+ shutil.copy(f, fonts_dir)
106
+ pairs = CJK_SUBSTITUTES.get(platform.system())
107
+ if not pairs:
108
+ return # Windows has the real fonts
109
+ from xml.sax.saxutils import quoteattr
110
+ nodes = "".join(
111
+ f'<item oor:path="/org.openoffice.VCL/FontSubstitutions"><node oor:name={quoteattr("p" + str(i))} oor:op="replace">'
112
+ f'<prop oor:name="ReplaceFont"><value>{src}</value></prop>'
113
+ f'<prop oor:name="SubstituteFont"><value>{dst}</value></prop>'
114
+ f'<prop oor:name="Always"><value>true</value></prop>'
115
+ f'<prop oor:name="OnScreenOnly"><value>false</value></prop></node></item>'
116
+ for i, (src, dst) in enumerate(pairs.items()))
117
+ nodes += ('<item oor:path="/org.openoffice.VCL"><prop oor:name="FontSubstitutionsEnabled" oor:op="fuse">'
118
+ '<value>true</value></prop></item>'
119
+ '<item oor:path="/org.openoffice.Office.Common/Font/Substitution"><prop oor:name="Replacement" oor:op="fuse">'
120
+ '<value>true</value></prop></item>'
121
+ '<item oor:path="/org.openoffice.Office.Common/Font/Substitution"><prop oor:name="FontPairs" oor:op="fuse">'
122
+ '<value>' + "".join(
123
+ f'<it><prop oor:name="Always"><value>true</value></prop>'
124
+ f'<prop oor:name="OnScreenOnly"><value>false</value></prop>'
125
+ f'<prop oor:name="ReplaceFont"><value>{s}</value></prop>'
126
+ f'<prop oor:name="SubstituteFont"><value>{d}</value></prop></it>'
127
+ for s, d in pairs.items()) + '</value></prop></item>')
128
+ os.makedirs(os.path.join(prof, "user"), exist_ok=True)
129
+ with open(os.path.join(prof, "user", "registrymodifications.xcu"), "w", encoding="utf-8") as f:
130
+ f.write('<?xml version="1.0" encoding="UTF-8"?>\n<oor:items xmlns:oor="http://openoffice.org/2001/registry" '
131
+ 'xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
132
+ + nodes + '</oor:items>\n')
133
+
134
+
135
+ def to_pdf(src: str, outdir: str, timeout: int) -> str:
136
+ soffice = find_soffice()
137
+ if not soffice:
138
+ raise LookupError("soffice")
139
+ with tempfile.TemporaryDirectory(prefix="lo-profile-") as prof:
140
+ write_profile(prof)
141
+ profile_url = "file:///" + prof.replace("\\", "/").lstrip("/")
142
+ cmd = [soffice, f"-env:UserInstallation={profile_url}", "--headless", "--norestore",
143
+ "--nolockcheck", "--convert-to", "pdf", "--outdir", outdir, src]
144
+ proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8",
145
+ errors="replace", timeout=timeout, check=False)
146
+ pdf = os.path.join(outdir, os.path.splitext(os.path.basename(src))[0] + ".pdf")
147
+ if proc.returncode != 0 or not os.path.exists(pdf):
148
+ raise RuntimeError(f"soffice conversion failed (rc={proc.returncode}): {proc.stderr.strip() or proc.stdout.strip()}")
149
+ return pdf
150
+
151
+
152
+ def parse_pages(spec: str | None, n: int) -> list[int]:
153
+ if not spec:
154
+ return list(range(1, n + 1))
155
+ out: list[int] = []
156
+ for part in spec.split(","):
157
+ a, _, b = part.partition("-")
158
+ lo, hi = int(a), int(b or a)
159
+ out += [p for p in range(lo, hi + 1) if 1 <= p <= n]
160
+ return out
161
+
162
+
163
+ def pdf_pages(pdf: str) -> int:
164
+ try:
165
+ from pypdf import PdfReader
166
+ return len(PdfReader(pdf).pages)
167
+ except Exception:
168
+ info = shutil.which("pdfinfo")
169
+ if info:
170
+ r = subprocess.run([info, pdf], capture_output=True, text=True, check=False)
171
+ for line in r.stdout.splitlines():
172
+ if line.startswith("Pages:"):
173
+ return int(line.split()[1])
174
+ return 0
175
+
176
+
177
+ def rasterize(pdf: str, outdir: str, stem: str, dpi: int, pages: list[int]) -> list[str]:
178
+ width = max(2, len(str(max(pages) if pages else 1)))
179
+ files = []
180
+ tool = shutil.which("pdftoppm")
181
+ if tool:
182
+ for p in pages:
183
+ prefix = os.path.join(outdir, f"{stem}-{p:0{width}d}")
184
+ subprocess.run([tool, "-png", "-r", str(dpi), "-f", str(p), "-l", str(p), "-singlefile", pdf, prefix],
185
+ check=True, capture_output=True)
186
+ files.append(prefix + ".png")
187
+ return files
188
+ import pypdfium2 as pdfium # raises ImportError -> reported as missing
189
+ doc = pdfium.PdfDocument(pdf)
190
+ for p in pages:
191
+ img = doc[p - 1].render(scale=dpi / 72).to_pil()
192
+ path = os.path.join(outdir, f"{stem}-{p:0{width}d}.png")
193
+ img.save(path)
194
+ files.append(path)
195
+ return files
196
+
197
+
198
+ def main(argv=None) -> int:
199
+ if hasattr(sys.stdout, "reconfigure"):
200
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
201
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0],
202
+ epilog=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
203
+ ap.add_argument("file")
204
+ ap.add_argument("--outdir", default=None, help="default: <file dir>/render")
205
+ ap.add_argument("--dpi", type=int, default=110)
206
+ ap.add_argument("--pages", default=None, help="e.g. 1-3,7 (default: all)")
207
+ ap.add_argument("--pdf-only", action="store_true", help="convert to PDF, skip PNGs")
208
+ ap.add_argument("--timeout", type=int, default=240)
209
+ a = ap.parse_args(argv)
210
+
211
+ src = os.path.abspath(a.file)
212
+ outdir = os.path.abspath(a.outdir or os.path.join(os.path.dirname(src), "render"))
213
+ os.makedirs(outdir, exist_ok=True)
214
+ stem = os.path.splitext(os.path.basename(src))[0]
215
+ ext = os.path.splitext(src)[1].lower()
216
+ missing: list[str] = []
217
+ try:
218
+ if ext == ".pdf":
219
+ pdf = src
220
+ elif ext in OFFICE_EXT:
221
+ pdf = to_pdf(src, outdir, a.timeout)
222
+ else:
223
+ print(json.dumps({"rendered": False, "error": f"unsupported file type {ext}"}))
224
+ return 1
225
+ except LookupError:
226
+ missing.append("soffice")
227
+ except (RuntimeError, subprocess.TimeoutExpired) as e:
228
+ print(json.dumps({"rendered": False, "error": str(e)}, ensure_ascii=False))
229
+ return 1
230
+ if missing:
231
+ print(json.dumps({"rendered": False, "missing": missing, "install": install_hint(missing)}, ensure_ascii=False))
232
+ return 0
233
+ n = pdf_pages(pdf)
234
+ if a.pdf_only:
235
+ print(json.dumps({"rendered": True, "pdf": pdf, "pages": n, "files": []}, ensure_ascii=False))
236
+ return 0
237
+ try:
238
+ for old in glob.glob(os.path.join(outdir, f"{stem}-*.png")):
239
+ os.remove(old)
240
+ files = rasterize(pdf, outdir, stem, a.dpi, parse_pages(a.pages, n))
241
+ except ImportError:
242
+ print(json.dumps({"rendered": False, "pdf": pdf, "missing": ["pdftoppm"],
243
+ "install": install_hint(["pdftoppm"])}, ensure_ascii=False))
244
+ return 0
245
+ print(json.dumps({"rendered": True, "pdf": pdf, "pages": n, "files": files}, ensure_ascii=False))
246
+ return 0
247
+
248
+
249
+ if __name__ == "__main__":
250
+ sys.exit(main())