mirror of
https://github.com/anthropics/skills.git
synced 2026-08-02 13:05:28 +08:00
Update docx, pptx, and xlsx skills (#1447)
Add support for template formats (.dotx, .potx, .xltx) across validation and the helper scripts. Consolidate the shared office helpers into a single module, replacing the pack/unpack pipeline with explicit zip/unzip steps. Extraction now rejects symlink and path-traversal archive entries. Move run merging to a standalone docx script, since it only ever applied to Word documents. Fix the redlining validator so it compares against the original even when a document has no tracked changes, which is when an untracked edit would otherwise go unreported. Provision a LibreOffice user profile per invocation so conversions work in sandboxed environments. Trim the skill docs to the guidance that earns its place.
This commit is contained in:
+168
-44
@@ -3,20 +3,29 @@ Excel Formula Recalculation Script
|
||||
Recalculates all formulas in an Excel file using LibreOffice
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from office.soffice import get_soffice_env
|
||||
from office.soffice import get_soffice_env, run_soffice
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
MACRO_DIR_MACOS = "~/Library/Application Support/LibreOffice/4/user/basic/Standard"
|
||||
MACRO_DIR_LINUX = "~/.config/libreoffice/4/user/basic/Standard"
|
||||
MACRO_FILENAME = "Module1.xba"
|
||||
SOFFICE_MISSING = "soffice not found on PATH; LibreOffice is required to recalculate"
|
||||
|
||||
MAX_LOCATIONS = 100
|
||||
|
||||
EXTERNAL_REF_RE = re.compile(r"""(?<![\w"\[])'?\[\d+\][^!"\[\]]*'?!""")
|
||||
|
||||
RECALCULATE_MACRO = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE script:module PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "module.dtd">
|
||||
@@ -39,63 +48,168 @@ def has_gtimeout():
|
||||
return False
|
||||
|
||||
|
||||
def setup_libreoffice_macro():
|
||||
macro_dir = os.path.expanduser(
|
||||
MACRO_DIR_MACOS if platform.system() == "Darwin" else MACRO_DIR_LINUX
|
||||
)
|
||||
macro_file = os.path.join(macro_dir, MACRO_FILENAME)
|
||||
def _stamp(path):
|
||||
st = os.stat(path)
|
||||
return st.st_mtime_ns, st.st_size
|
||||
|
||||
if (
|
||||
os.path.exists(macro_file)
|
||||
and "RecalculateAndSave" in Path(macro_file).read_text()
|
||||
):
|
||||
return True
|
||||
|
||||
if not os.path.exists(macro_dir):
|
||||
subprocess.run(
|
||||
["soffice", "--headless", "--terminate_after_init"],
|
||||
def setup_libreoffice_macro(profile_dir: Path, timeout=30):
|
||||
url = profile_dir.as_uri()
|
||||
try:
|
||||
run_soffice(
|
||||
["--headless", "--terminate_after_init", f"-env:UserInstallation={url}"],
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
env=get_soffice_env(),
|
||||
timeout=timeout,
|
||||
)
|
||||
os.makedirs(macro_dir, exist_ok=True)
|
||||
except FileNotFoundError:
|
||||
return None, SOFFICE_MISSING
|
||||
except subprocess.TimeoutExpired:
|
||||
return None, "LibreOffice timed out creating its profile; formulas were NOT recalculated"
|
||||
|
||||
macro_dir = profile_dir / "user" / "basic" / "Standard"
|
||||
if not macro_dir.exists():
|
||||
return None, "LibreOffice did not create a usable profile; formulas were NOT recalculated"
|
||||
|
||||
try:
|
||||
Path(macro_file).write_text(RECALCULATE_MACRO)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
(macro_dir / MACRO_FILENAME).write_text(RECALCULATE_MACRO)
|
||||
except OSError as e:
|
||||
return None, f"Could not install the recalculation macro: {e}"
|
||||
|
||||
return url, None
|
||||
|
||||
|
||||
def recalc(filename, timeout=30):
|
||||
def external_links_at_risk(filename):
|
||||
try:
|
||||
with zipfile.ZipFile(filename) as archive:
|
||||
names = archive.namelist()
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return []
|
||||
if not any(n.startswith("xl/externalLinks/") for n in names):
|
||||
return []
|
||||
|
||||
with contextlib.ExitStack() as stack:
|
||||
formulas = load_workbook(filename, data_only=False)
|
||||
stack.callback(formulas.close)
|
||||
values = load_workbook(filename, data_only=True)
|
||||
stack.callback(values.close)
|
||||
|
||||
external_names = [
|
||||
name
|
||||
for name, dn in formulas.defined_names.items()
|
||||
if isinstance(getattr(dn, "value", None), str) and EXTERNAL_REF_RE.search(dn.value)
|
||||
]
|
||||
name_re = (
|
||||
re.compile(r"\b(" + "|".join(re.escape(n) for n in external_names) + r")\b")
|
||||
if external_names
|
||||
else None
|
||||
)
|
||||
|
||||
at_risk = []
|
||||
for sheet in formulas.sheetnames:
|
||||
ws = formulas[sheet]
|
||||
if not hasattr(ws, "iter_rows"):
|
||||
continue
|
||||
cached = values[sheet]
|
||||
for row in ws.iter_rows():
|
||||
for cell in row:
|
||||
v = cell.value
|
||||
if not (isinstance(v, str) and v.startswith("=")):
|
||||
continue
|
||||
reaches_out = EXTERNAL_REF_RE.search(v) or (name_re and name_re.search(v))
|
||||
if reaches_out and cached[cell.coordinate].value is None:
|
||||
at_risk.append(f"{sheet}!{cell.coordinate}")
|
||||
return at_risk
|
||||
|
||||
|
||||
def recalc(filename, timeout=30, force=False):
|
||||
if not Path(filename).exists():
|
||||
return {"error": f"File {filename} does not exist"}
|
||||
|
||||
abs_path = str(Path(filename).absolute())
|
||||
|
||||
if not setup_libreoffice_macro():
|
||||
return {"error": "Failed to setup LibreOffice macro"}
|
||||
if not os.access(abs_path, os.W_OK):
|
||||
return {"error": f"{filename} is not writable; recalculation rewrites the file in place"}
|
||||
|
||||
try:
|
||||
get_soffice_env()
|
||||
except Exception as e:
|
||||
return {"error": f"Could not prepare the LibreOffice environment: {e}"}
|
||||
|
||||
if not force:
|
||||
try:
|
||||
at_risk = external_links_at_risk(filename)
|
||||
except Exception as e:
|
||||
return {"error": f"Could not inspect {filename} for external links: {e}"}
|
||||
if at_risk:
|
||||
shown = at_risk[:MAX_LOCATIONS]
|
||||
return {
|
||||
"error": (
|
||||
"Refusing to recalculate: this workbook links to another workbook, and "
|
||||
f"{len(at_risk)} linked cell(s) have lost their cached value (openpyxl strips "
|
||||
"these on save). Recalculating would resolve them to #NAME? and delete the "
|
||||
"external links for good. Copy those cells' values from the original file "
|
||||
"before saving, or pass --force to accept the loss. Charts and conditional "
|
||||
"formats can hold external references too, so this list may not be exhaustive."
|
||||
),
|
||||
"external_link_cells": shown,
|
||||
"external_link_cells_truncated": max(0, len(at_risk) - len(shown)),
|
||||
}
|
||||
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="recalc-lo-profile-", ignore_cleanup_errors=True
|
||||
) as profile_dir:
|
||||
return _recalc_with_profile(filename, abs_path, timeout, Path(profile_dir))
|
||||
|
||||
|
||||
def _recalc_with_profile(filename, abs_path, timeout, profile_dir: Path):
|
||||
started = time.monotonic()
|
||||
profile_url, err = setup_libreoffice_macro(profile_dir, timeout=timeout)
|
||||
if err:
|
||||
return {"error": err}
|
||||
|
||||
timeout = max(5, int(timeout - (time.monotonic() - started)))
|
||||
|
||||
before = _stamp(abs_path)
|
||||
|
||||
cmd = [
|
||||
"soffice",
|
||||
"--headless",
|
||||
"--norestore",
|
||||
f"-env:UserInstallation={profile_url}",
|
||||
"vnd.sun.star.script:Standard.Module1.RecalculateAndSave?language=Basic&location=application",
|
||||
abs_path,
|
||||
]
|
||||
|
||||
if platform.system() == "Linux":
|
||||
if platform.system() == "Linux" and shutil.which("timeout"):
|
||||
cmd = ["timeout", str(timeout)] + cmd
|
||||
elif platform.system() == "Darwin" and has_gtimeout():
|
||||
cmd = ["gtimeout", str(timeout)] + cmd
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, env=get_soffice_env())
|
||||
timed_out = f"LibreOffice timed out after {timeout}s; formulas were NOT recalculated. Re-run with a longer timeout."
|
||||
|
||||
if result.returncode != 0 and result.returncode != 124:
|
||||
error_msg = result.stderr or "Unknown error during recalculation"
|
||||
if "Module1" in error_msg or "RecalculateAndSave" not in error_msg:
|
||||
return {"error": "LibreOffice macro not configured properly"}
|
||||
return {"error": error_msg}
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, env=get_soffice_env(), timeout=timeout + 15
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"error": timed_out}
|
||||
except FileNotFoundError:
|
||||
return {"error": SOFFICE_MISSING}
|
||||
|
||||
if result.returncode == 124:
|
||||
return {"error": timed_out}
|
||||
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or "").strip() or f"soffice exited {result.returncode}"
|
||||
return {"error": f"LibreOffice failed to recalculate: {detail}"}
|
||||
|
||||
if _stamp(abs_path) == before:
|
||||
return {
|
||||
"error": (
|
||||
"LibreOffice exited cleanly but never rewrote the file, so nothing was "
|
||||
"recalculated. Check that no other LibreOffice instance is running, then retry."
|
||||
)
|
||||
}
|
||||
|
||||
try:
|
||||
wb = load_workbook(filename, data_only=True)
|
||||
@@ -114,6 +228,8 @@ def recalc(filename, timeout=30):
|
||||
|
||||
for sheet_name in wb.sheetnames:
|
||||
ws = wb[sheet_name]
|
||||
if not hasattr(ws, "iter_rows"):
|
||||
continue
|
||||
for row in ws.iter_rows():
|
||||
for cell in row:
|
||||
if cell.value is not None and isinstance(cell.value, str):
|
||||
@@ -124,8 +240,6 @@ def recalc(filename, timeout=30):
|
||||
total_errors += 1
|
||||
break
|
||||
|
||||
wb.close()
|
||||
|
||||
result = {
|
||||
"status": "success" if total_errors == 0 else "errors_found",
|
||||
"total_errors": total_errors,
|
||||
@@ -134,15 +248,19 @@ def recalc(filename, timeout=30):
|
||||
|
||||
for err_type, locations in error_details.items():
|
||||
if locations:
|
||||
result["error_summary"][err_type] = {
|
||||
"count": len(locations),
|
||||
"locations": locations[:20],
|
||||
}
|
||||
entry = {"count": len(locations), "locations": locations[:MAX_LOCATIONS]}
|
||||
if len(locations) > MAX_LOCATIONS:
|
||||
entry["locations_truncated"] = len(locations) - MAX_LOCATIONS
|
||||
result["error_summary"][err_type] = entry
|
||||
|
||||
wb.close()
|
||||
|
||||
wb_formulas = load_workbook(filename, data_only=False)
|
||||
formula_count = 0
|
||||
for sheet_name in wb_formulas.sheetnames:
|
||||
ws = wb_formulas[sheet_name]
|
||||
if not hasattr(ws, "iter_rows"):
|
||||
continue
|
||||
for row in ws.iter_rows():
|
||||
for cell in row:
|
||||
if (
|
||||
@@ -162,8 +280,11 @@ def recalc(filename, timeout=30):
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python recalc.py <excel_file> [timeout_seconds]")
|
||||
args = [a for a in sys.argv[1:] if a != "--force"]
|
||||
force = "--force" in sys.argv[1:]
|
||||
|
||||
if not args:
|
||||
print("Usage: python recalc.py <excel_file> [timeout_seconds] [--force]")
|
||||
print("\nRecalculates all formulas in an Excel file using LibreOffice")
|
||||
print("\nReturns JSON with error details:")
|
||||
print(" - status: 'success' or 'errors_found'")
|
||||
@@ -171,13 +292,16 @@ def main():
|
||||
print(" - total_formulas: Number of formulas in the file")
|
||||
print(" - error_summary: Breakdown by error type with locations")
|
||||
print(" - #VALUE!, #DIV/0!, #REF!, #NAME?, #NULL!, #NUM!, #N/A")
|
||||
print("\nOn any failure the JSON has an 'error' key and no 'status'.")
|
||||
print("--force recalculates even when it would destroy external links.")
|
||||
sys.exit(1)
|
||||
|
||||
filename = sys.argv[1]
|
||||
timeout = int(sys.argv[2]) if len(sys.argv) > 2 else 30
|
||||
filename = args[0]
|
||||
timeout = int(args[1]) if len(args) > 1 else 30
|
||||
|
||||
result = recalc(filename, timeout)
|
||||
result = recalc(filename, timeout, force=force)
|
||||
print(json.dumps(result, indent=2))
|
||||
sys.exit(1 if "error" in result else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user