Skill: xixihaha123123123123/wps-xlsx (version 1.0.0)

--- name: xlsx description: 创建、编辑或分析 .xlsx / .xlsm / .csv / .tsv 文件。当用户要求生成、处理、下载 Excel 表格，或提及表格文件名/路径并希望对其操作时，使用本技能。包括：打开、读取、编辑、修复已有文件（增列、计算、格式化、图表、数据清洗），从零或其他数据源创建新表格，以及表格格式间的转换。对于结构混乱的表格数据（错行、表头错位、垃圾数据）清洗为规范表格也适用。交付物须为表格文件；当用户未指定格式时默认生成 .xlsx 而非 .csv 等其他格式。若主要产出为 Word 文档、HTML 报告、独立 Python 脚本或数据库管道，则不应触发本技能。 --- # 核心原则 每份交付的 Excel 文件须同时满足以下四点： 1. **零公式错误**：不得出现 `#REF!` `#DIV/0!` `#VALUE!` `#N/A` `#NAME?` 2. **视觉美观**：使用统一的专业字体（如 Arial、微软雅黑），表头与数据区层次分明，数字格式统一，列宽适配内容，整体风格协调 3. **图表配套**：数据中存在对比、趋势或占比维度时，配套生成相应图表（折线图 / 柱状图 / 饼图等），提升可阅读性 4. **保留已有模板**：修改现有文件时，必须完全匹配原有格式、样式和约定，绝不覆盖已有样式，现有约定优先于本指南 5. **区分任务类型，保护原表数据**：先判断用户意图——若是多个文件汇总为一个新表，可创建新文件；若是基于现有文件进行处理（如分析、汇总、增加图表），则必须在原文件基础上操作，保留所有原始 Sheet 及其数据，将结果写入新增的 Sheet，不得丢弃或覆盖原始数据 --- # 工作流 ## 工具使用规则 所有 Python 代码统一通过 **`jupyter_cell_exec`工具** 执行（用户每次提问时自动启动新内核）。输出文件保存到工作目录（`OUTPUT_ROOT`）下。注意：用户每发起新提问时 Jupyter 环境会重置，变量与状态仅在当轮多次调用之间保留。 ## 执行步骤 1. **规划结构**：确定 Sheet 划分、数据布局、需要生成的图表类型 2. **选择库**：数据分析用 pandas，公式 / 格式 / 图表处理用 openpyxl 3. **编写并执行代码**：通过 `jupyter_cell_exec`工具 在**同一个代码块**中完成文件创建与公式重算。使用了公式时，必须在保存文件后紧接着调用 `recalc()` 验证： ```python # ... 创建/编辑 Excel 文件的代码 ... wb.save(output_path) # 重算公式并验证（使用了公式时必须执行） import sys, os sys.path.insert(0, os.path.join(os.getenv('skill_path'), 'xlsx', 'scripts')) from recalc import recalc result = recalc(output_path) print(result) ``` - 函数签名：`recalc(filename: str, timeout: int = 60) -> dict` - 纯 Python 重算所有公式，并扫描全部单元格检查错误（无需安装外部软件） 4. **验证与修复**： - 若返回 `status: "errors_found"`，根据 `error_summary` 中的位置修复后再次调用 `recalc()` - 常见错误：`#REF!`（无效引用）、`#DIV/0!`（除以零）、`#VALUE!`（数据类型错误）、`#NAME?`（未识别的公式名） --- # 关键原则：使用公式，不要硬编码值 **始终使用 Excel 公式，而非在 Python 中计算后将结果硬编码写入。** 这样表格才能在源数据变化时自动重算。 ### ❌ 错误做法 ```python total = df['Sales'].sum() sheet['B10'] = total # 硬编码为 5000 growth = (df.iloc[-1]['Revenue'] - df.iloc[0]['Revenue']) / df.iloc[0]['Revenue'] sheet['C5'] = growth # 硬编码为 0.15 ``` ### ✅ 正确做法 ```python sheet['B10'] = '=SUM(B2:B9)' sheet['C5'] = '=(C4-C2)/C2' sheet['D20'] = '=AVERAGE(D2:D19)' ``` 所有计算——合计、百分比、比率、差值——均适用此原则。 --- # 多 Sheet 处理复杂任务 当任务涉及多个维度、多个数据集或数据量较大时，将内容合理拆分到多个 Sheet，而非堆在一张表上。每个 Sheet 聚焦单一主题，并在顶部用简短说明交代该 Sheet 的用途与数据范围，帮助读者快速定位所需信息。 --- # 技术参考 ## 读取与分析数据 ### 使用 pandas（推荐用 jupyter_cell_exec工具） ```python import pandas as pd df = pd.read_excel('file.xlsx') all_sheets = pd.read_excel('file.xlsx', sheet_name=None) df.head() df.info() df.describe() df.to_excel('output.xlsx', index=False) ``` ## 创建新 Excel 文件 ```python from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment wb = Workbook() sheet = wb.active sheet['A1'] = 'Hello' sheet['B1'] = 'World' sheet.append(['Row', 'of', 'data']) sheet['B2'] = '=SUM(A1:A10)' sheet['A1'].font = Font(bold=True, color='FF0000') sheet['A1'].fill = PatternFill('solid', start_color='FFFF00') sheet['A1'].alignment = Alignment(horizontal='center') sheet.column_dimensions['A'].width = 20 wb.save('output.xlsx') ``` ## 编辑现有 Excel 文件 ```python from openpyxl import load_workbook wb = load_workbook('existing.xlsx') sheet = wb.active sheet['A1'] = 'New Value' sheet.insert_rows(2) sheet.delete_cols(3) new_sheet = wb.create_sheet('NewSheet') new_sheet['A1'] = 'Data' wb.save('modified.xlsx') ``` ## 重算公式 openpyxl 写入的公式只是字符串，Excel 打开前不会有计算值。通过 `recalc` 模块使用纯 Python 引擎重算所有公式，并扫描全部单元格检查错误。无需安装任何外部软件。 ### 使用方式 ```python import sys, os sys.path.insert(0, os.path.join(os.getenv('skill_path'), 'xlsx', 'scripts')) from recalc import recalc result = recalc("/path/to/output.xlsx") print(result) ``` ### 函数签名 ```python recalc(filename: str, timeout: int = 60) -> dict ``` - `filename`：xlsx 文件的绝对路径 - `timeout`：重算超时秒数，默认 60 ### 返回值 ```json { "status": "success", "total_errors": 0, "total_formulas": 42, "error_summary": {} } ``` 若 `status` 为 `errors_found`，`error_summary` 会列出每种错误的位置（最多 20 处）： ```json { "status": "errors_found", "total_errors": 2, "total_formulas": 42, "error_summary": { "#REF!": { "count": 2, "locations": ["Sheet1!B5", "Sheet1!C10"] } } } ``` ### 支持的函数范围 recalc 使用纯 Python 公式引擎，覆盖大多数常用 Excel 函数： - 数学：SUM, AVERAGE, COUNT, COUNTA, MIN, MAX, ROUND, ABS, MOD - 逻辑：IF, AND, OR, NOT, IFERROR - 查找：VLOOKUP, HLOOKUP, INDEX, MATCH, OFFSET - 条件：SUMIF, SUMIFS, COUNTIF, COUNTIFS, AVERAGEIF - 文本：LEFT, RIGHT, MID, LEN, CONCATENATE, TRIM, UPPER, LOWER - 日期：DATE, YEAR, MONTH, DAY, TODAY, NOW - 财务：NPV, IRR, PMT, FV, PV 若遇到不支持的函数，recalc 会降级为静态分析（检查引用和结构），并在返回值中包含 `warning` 字段说明情况。 ## 公式验证清单 ### 必要验证 - [ ] 先在 2-3 个单元格上测试公式，确认值正确后再批量应用 - [ ] 列映射：确认 Excel 列对应关系（第 64 列 = BL） - [ ] 行偏移：Excel 行从 1 开始（DataFrame 第 5 行 = Excel 第 6 行） - [ ] 验证公式引用的所有单元格确实存在 ### 常见陷阱 - [ ] NaN 处理：用 `pd.notna()` 检查空值 - [ ] 远右侧列：财年数据常在第 50+ 列 - [ ] 多重匹配：搜索所有匹配项而非只取第一个 - [ ] 除以零：在公式中使用 `/` 前检查分母（`#DIV/0!`） - [ ] 跨表引用：使用正确格式（`Sheet1!A1`） - [ ] 边界测试：包含零值、负数和超大数值的场景 --- # 财务模型规范 ### 颜色编码（除非用户或现有模板另有说明） - **蓝色文字（RGB: 0,0,255）**：硬编码输入值 - **黑色文字（RGB: 0,0,0）**：所有公式和计算结果 - **绿色文字（RGB: 0,128,0）**：同一工作簿内跨工作表的引用链接 - **红色文字（RGB: 255,0,0）**：链接到其他文件的外部引用 - **黄色背景（RGB: 255,255,0）**：关键假设或需要更新的单元格 ### 数字格式 - **年份**：文本字符串（如 "2024" 而非 "2,024"） - **货币**：`$#,##0`，表头注明单位（如 "Revenue ($mm)"） - **零值**：显示为 "-"（格式：`$#,##0;($#,##0);-`） - **百分比**：`0.0%` - **倍数**：`0.0x` - **负数**：括号形式 `(123)` ### 公式构建 - 将所有假设放在独立单元格，公式引用单元格而非硬编码：`=B5*(1+$B$6)` 而非 `=B5*1.05` - 修改现有模板时，必须完全匹配原有格式、样式和约定，现有约定优先于本指南 ### 硬编码值来源文档 - 所有硬编码数值须标注来源，格式：`Source: [系统/文档], [日期], [具体引用], [URL（如有）]` - 示例： - `Source: 公司年报, FY2024, 第45页, 营收附注` - `Source: Bloomberg Terminal, 2025/8/15, AAPL US Equity` - `Source: Wind, 2025/8/20, 一致预期数据` ### 图表布局（TwoCellAnchor 网格系统） 使用 `TwoCellAnchor` 精确定位，**必须遵循网格布局规则防止重叠**。 #### 网格布局规则（必须遵守） 将图表区域看作网格，先确定布局参数，再算每个图表的坐标： ``` 布局参数（先确定再写代码）: DATA_END_ROW = 数据区最后一行（0-indexed） CHART_START = DATA_END_ROW + 2（图表起始行，留 1 行缓冲） COLS_PER_CHART = 7（每个图表占的列数，推荐 6-8） ROWS_PER_CHART = 15（每个图表占的行数，推荐 14-16） GAP_COLS = 1（列间距，必须 ≥ 1） GAP_ROWS = 2（行间距，必须 ≥ 2） 网格坐标计算公式: 第 i 行第 j 列图表（i, j 从 0 开始）: from_col = j * (COLS_PER_CHART + GAP_COLS) from_row = CHART_START + i * (ROWS_PER_CHART + GAP_ROWS) to_col = from_col + COLS_PER_CHART to_row = from_row + ROWS_PER_CHART ``` **核心规则**： 1. 相邻图表的坐标范围**绝不允许重叠** — 即一个图表的 `to_col` 必须 ≤ 下一列图表的 `from_col`，`to_row` 必须 ≤ 下一行图表的 `from_row` 2. **图表区域内禁止存放任何数据** — 图表网格占据的整个矩形区域（从 `CHART_START` 行、第 0 列开始，到最后一个图表的 `to_row`、`to_col`）内不得写入辅助数据 3. 图表需要的辅助数据（如分布统计、饼图源数据等）必须放在**图表区域之外**：写在主数据表的右侧空列（确保不与图表列重叠），或写在单独的 sheet 中 #### 代码模板（6 个图表 2×3 网格） ```python from openpyxl.chart import BarChart, PieChart, LineChart from openpyxl.drawing.spreadsheet_drawing import TwoCellAnchor # 1. 确定布局参数 CHART_START = 19 # 数据区结束后 +2 COLS = 7 # 每图表占 7 列 ROWS = 15 # 每图表占 15 行 GAP_C = 1 # 列间距（图表之间留 1 列空白） GAP_R = 2 # 行间距（图表之间留 2 行空白） def place_chart(ws, chart, grid_row, grid_col): """将图表放入网格的 (grid_row, grid_col) 位置，0-indexed""" a = TwoCellAnchor() a._from.col = grid_col * (COLS + GAP_C) a._from.row = CHART_START + grid_row * (ROWS + GAP_R) a.to.col = a._from.col + COLS a.to.row = a._from.row + ROWS chart.anchor = a ws._charts.append(chart) # 2. 创建图表并放入网格（图表之间自动留白） # 第 0 行: chart1(0,0) [1列空白] chart2(0,1) # [2行空白] # 第 1 行: chart3(1,0) [1列空白] chart4(1,1) # [2行空白] # 第 2 行: chart5(2,0) [1列空白] chart6(2,1) place_chart(ws, chart1, 0, 0) # A20:G34 place_chart(ws, chart2, 0, 1) # I20:O34 (H列空白) place_chart(ws, chart3, 1, 0) # A37:G51 (35-36行空白) place_chart(ws, chart4, 1, 1) # I37:O51 place_chart(ws, chart5, 2, 0) # A54:G68 place_chart(ws, chart6, 2, 1) # I54:O68 ``` **关键点**： - `anchor._from.col/row` 和 `anchor.to.col/row` 都是 **0-indexed** - 第 1 行 = `row = 0`，A 列 = `col = 0` - 占据行数 = `to.row - _from.row` - **不要手动计算每个图表的坐标** — 用 `place_chart` 函数或等价的公式统一计算，避免算错 #### 图表数据引用（Reference） **关键原则**：`Reference` 的行列范围必须精确匹配数据区域，避免包含多余的表头或空行。 ```python from openpyxl.chart import Reference # 假设数据结构： # A1: 姓名 B1: 语文 C1: 数学 D1: 英语 E1: 总分 # A2: 张三 B2: 85 C2: 90 D2: 88 E2: 263 # A3: 李四 B3: 78 C3: 82 D3: 85 E3: 245 # ... (共 8 行数据，A2:E9) # ✅ 正确：柱状图显示总分 data = Reference(ws, min_col=5, min_row=1, max_row=9) # E1:E9（包含表头"总分"） categories = Reference(ws, min_col=1, min_row=2, max_row=9) # A2:A9（学生姓名，不含表头） chart.add_data(data, titles_from_data=True) # titles_from_data=True 会把 E1 作为系列名 chart.set_categories(categories) # ✅ 正确：折线图显示三科成绩 data = Reference(ws, min_col=2, max_col=4, min_row=1, max_row=9) # B1:D9（包含表头） categories = Reference(ws, min_col=1, min_row=2, max_row=9) # A2:A9（学生姓名） chart.add_data(data, titles_from_data=True) # B1:D1 作为系列名（语文、数学、英语） chart.set_categories(categories) # ❌ 错误：data 包含了姓名列 data = Reference(ws, min_col=1, max_col=4, min_row=1, max_row=9) # 错误地包含了 A 列 chart.add_data(data, titles_from_data=True) # 会把"姓名"也当成数据系列 # ❌ 错误：categories 包含了表头 categories = Reference(ws, min_col=1, min_row=1, max_row=9) # 错误地包含了 A1 chart.set_categories(categories) # 横轴会显示"姓名 张三 李四..." ``` **关键点**： - `min_row=1` 且 `titles_from_data=True` → 第 1 行作为系列名（图例） - `min_row=2` → 从第 2 行开始取数据 - `categories` 通常不包含表头（`min_row=2`） - `data` 包含表头时设置 `titles_from_data=True` **饼图特殊说明**： ```python # 饼图通常只有一个数据系列，不需要系列名 # ✅ 正确：data 和 categories 都不包含表头 data = Reference(ws, min_col=2, min_row=2, max_row=5) # B2:B5（数值） categories = Reference(ws, min_col=1, min_row=2, max_row=5) # A2:A5（分类名） pie_chart.add_data(data, titles_from_data=False) # 饼图不需要系列名 pie_chart.set_categories(categories) # ❌ 错误：data 包含表头会导致标签显示 "表头名, 分类名, 值, 百分比" data = Reference(ws, min_col=2, min_row=1, max_row=5) # B1:B5（包含表头） pie_chart.add_data(data, titles_from_data=True) # 错误！饼图标签会变成 "人数, 90分以上, 10, 100%" ``` #### ⚠️ 禁止使用 `add_chart` + `width/height` 不要使用 `ws.add_chart(chart, 'A3')` + `chart.width/height`，该方案的单位是 cm，极易算错导致重叠。**始终使用上述 TwoCellAnchor 网格系统。** --- # 最佳实践 ### 库的选择 - **pandas**：数据分析、批量操作、简单数据导出 - **openpyxl**：复杂格式、公式、Excel 特有功能 ### openpyxl 注意事项 - 单元格索引从 1 开始（row=1, column=1 对应 A1） - `data_only=True` 读取已计算的值；以此模式保存会永久丢失公式 - 大文件：读取用 `read_only=True`，写入用 `write_only=True` ### pandas 注意事项 - 指定数据类型：`pd.read_excel('file.xlsx', dtype={'id': str})` - 只读取需要的列：`pd.read_excel('file.xlsx', usecols=['A', 'C', 'E'])` - 日期处理：`pd.read_excel('file.xlsx', parse_dates=['date_column'])` ## 代码风格规范 **Python 脚本**：编写简洁代码，避免多余注释、冗长变量名和不必要的 print。 **Excel 文件本身**：为复杂公式或关键假设添加单元格注释，为硬编码值注明数据来源。

path: scripts/recalc.py
content: """
Excel Formula Recalculation Script
Recalculates all formulas in an Excel file using the pure-Python `formulas` library.
Falls back to static analysis when the formulas library cannot handle certain functions.
"""

import json
import math
import os
import re
import sys
import xml.etree.ElementTree as ET
import zipfile
from io import BytesIO
from pathlib import Path

from openpyxl import load_workbook
from openpyxl.worksheet.datavalidation import DataValidation as _DV

# WPS/Kdocs exports add a non-standard 'id' attribute to <dataValidation> nodes.
# openpyxl strictly rejects unknown kwargs, so we patch to tolerate them.
_dv_orig_init = _DV.__init__

def _dv_patched_init(self, *args, **kwargs):
    kwargs.pop('id', None)
    _dv_orig_init(self, *args, **kwargs)

_DV.__init__ = _dv_patched_init

EXCEL_ERRORS = ['#VALUE!', '#DIV/0!', '#REF!', '#NAME?', '#NULL!', '#NUM!', '#N/A']


# WPS-proprietary pseudo-formulas that are not real calculations (e.g. embedded images).
_WPS_PSEUDO_FORMULAS = ('=DISPIMG(', '=_XLFN.DISPIMG(')


def _count_formulas(filename: str) -> tuple[int, list[tuple[str, str, str]]]:
    """Return (count, [(sheet, coordinate, formula_text), ...])."""
    wb = load_workbook(filename, data_only=False)
    cells = []
    for sheet_name in wb.sheetnames:
        for row in wb[sheet_name].iter_rows():
            for cell in row:
                if cell.value and isinstance(cell.value, str) and cell.value.startswith('='):
                    upper = cell.value.upper()
                    if any(upper.startswith(p) for p in _WPS_PSEUDO_FORMULAS):
                        continue
                    cells.append((sheet_name, cell.coordinate, cell.value))
    wb.close()
    return len(cells), cells


def _parse_solution_key(key: str) -> str | None:
    """Extract 'Sheet!Cell' location from a formulas solution key.

    Keys look like: "'[file.xlsx]Sheet1'!B2" or "'[file.xlsx]Sheet1'!A1:A3"
    Returns "Sheet1!B2" or None if the key is a range (not a single cell).
    """
    m = re.search(r"\](.+?)'!([A-Z]+\d+)$", key)
    if m:
        return f'{m.group(1)}!{m.group(2)}'
    return None


def _register_namespaces(xml_bytes: bytes) -> None:
    """Register all XML namespace prefixes found in the document."""
    for event, data in ET.iterparse(BytesIO(xml_bytes), events=['start-ns']):
        prefix, uri = data
        try:
            ET.register_namespace(prefix, uri)
        except ValueError:
            pass


def _inject_cached_values(filepath: str, cache_map: dict[tuple[str, str], object]) -> None:
    """Write calculated values into xlsx formula cells as cached values.

    Modifies the xlsx file in-place by adding <v> elements alongside <f> elements
    in the sheet XML, so programs reading with data_only=True see the values.
    """
    if not cache_map:
        return

    NS = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
    RELS_NS = 'http://schemas.openxmlformats.org/package/2006/relationships'
    RELS_OFFICEREL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'

    with zipfile.ZipFile(filepath, 'r') as zin:
        file_list = zin.namelist()
        contents = {name: zin.read(name) for name in file_list}

    wb_path = 'xl/workbook.xml'
    rels_path = 'xl/_rels/workbook.xml.rels'
    if wb_path not in contents or rels_path not in contents:
        return

    _register_namespaces(contents[wb_path])
    wb_root = ET.fromstring(contents[wb_path])
    sheets_el = wb_root.find(f'{{{NS}}}sheets')
    if sheets_el is None:
        return
    sheet_rid = {}
    for s in sheets_el.findall(f'{{{NS}}}sheet'):
        name = s.get('name')
        rid = s.get(f'{{{RELS_NS}}}id') or s.get(f'{{{RELS_OFFICEREL}}}id')
        if name and rid:
            sheet_rid[name] = rid

    _register_namespaces(contents[rels_path])
    rels_root = ET.fromstring(contents[rels_path])
    rid_file = {}
    for rel in rels_root:
        rid = rel.get('Id')
        target = rel.get('Target')
        if rid and target:
            rid_file[rid] = f'xl/{target}' if not target.startswith('/') else target.lstrip('/')

    by_sheet: dict[str, dict[str, object]] = {}
    for (sheet_name, cell_ref), value in cache_map.items():
        by_sheet.setdefault(sheet_name, {})[cell_ref] = value

    name_upper_to_actual = {name.upper(): name for name in sheet_rid}

    modified_any = False
    for sheet_name, cell_values in by_sheet.items():
        actual_name = name_upper_to_actual.get(sheet_name.upper())
        if not actual_name:
            continue
        rid = sheet_rid[actual_name]
        xml_path = rid_file.get(rid)
        if not xml_path or xml_path not in contents:
            continue

        raw = contents[xml_path]
        _register_namespaces(raw)
        root = ET.fromstring(raw)
        sheet_data = root.find(f'{{{NS}}}sheetData')
        if sheet_data is None:
            continue

        modified = False
        for row_el in sheet_data.findall(f'{{{NS}}}row'):
            for cell_el in row_el.findall(f'{{{NS}}}c'):
                ref = cell_el.get('r')
                if ref not in cell_values:
                    continue
                f_el = cell_el.find(f'{{{NS}}}f')
                if f_el is None:
                    continue

                value = cell_values[ref]
                v_el = cell_el.find(f'{{{NS}}}v')
                if v_el is None:
                    v_el = ET.SubElement(cell_el, f'{{{NS}}}v')

                is_err = any(err in str(value) for err in EXCEL_ERRORS)
                if is_err:
                    cell_el.set('t', 'e')
                    v_el.text = str(value)
                elif isinstance(value, bool):
                    cell_el.set('t', 'b')
                    v_el.text = '1' if value else '0'
                elif isinstance(value, (int, float)):
                    if 't' in cell_el.attrib:
                        del cell_el.attrib['t']
                    v_el.text = str(float(value))
                elif isinstance(value, str):
                    cell_el.set('t', 'str')
                    v_el.text = value
                else:
                    v_el.text = str(value)

                modified = True

        if modified:
            buf = BytesIO()
            ET.ElementTree(root).write(buf, xml_declaration=True, encoding='UTF-8')
            contents[xml_path] = buf.getvalue()
            modified_any = True

    if modified_any:
        out = BytesIO()
        with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as zout:
            for name in file_list:
                zout.writestr(name, contents[name])
        with open(filepath, 'wb') as f:
            f.write(out.getvalue())


def _calculate_and_check(abs_path: str) -> tuple[str | None, int, dict]:
    """Calculate formulas, inject cached values, and detect errors.

    Returns (calc_error_msg_or_None, total_errors, error_summary).
    If calc_error_msg is not None, the calculation failed entirely.
    """
    try:
        import formulas
    except ImportError:
        return 'formulas library not installed (pip install formulas)', 0, {}

    # Build set of cells containing WPS pseudo-formulas (e.g. DISPIMG) to skip.
    pseudo_cells: set[str] = set()
    try:
        wb_tmp = load_workbook(abs_path, data_only=False)
        for sn in wb_tmp.sheetnames:
            for row in wb_tmp[sn].iter_rows():
                for c in row:
                    if c.value and isinstance(c.value, str):
                        upper = c.value.upper()
                        if any(upper.startswith(p) for p in _WPS_PSEUDO_FORMULAS):
                            pseudo_cells.add(f'{sn}!{c.coordinate}')
        wb_tmp.close()
    except Exception:
        pass

    try:
        os.environ['TQDM_DISABLE'] = '1'
        xl_model = formulas.ExcelModel().loads(abs_path).finish()
        solution = xl_model.calculate()
    except Exception as e:
        return str(e), 0, {}

    import numpy as np
    error_details: dict[str, list[str]] = {err: [] for err in EXCEL_ERRORS}
    total_errors = 0
    cache_map: dict[tuple[str, str], object] = {}

    for key, ranges_obj in solution.items():
        location = _parse_solution_key(key)
        if location is None:
            continue
        if location in pseudo_cells:
            continue

        sheet_name, cell_ref = location.split('!', 1)

        try:
            values = np.asarray(ranges_obj.value).flat
        except Exception:
            continue

        for val in values:
            val_str = str(val)
            is_error = False
            for err in EXCEL_ERRORS:
                if err in val_str:
                    error_details[err].append(location)
                    total_errors += 1
                    is_error = True
                    cache_map[(sheet_name, cell_ref)] = val_str
                    break

            if not is_error and isinstance(val, float):
                if math.isinf(val):
                    error_details['#DIV/0!'].append(location)
                    total_errors += 1
                    cache_map[(sheet_name, cell_ref)] = '#DIV/0!'
                elif math.isnan(val):
                    error_details['#VALUE!'].append(location)
                    total_errors += 1
                    cache_map[(sheet_name, cell_ref)] = '#VALUE!'
                else:
                    cache_map[(sheet_name, cell_ref)] = val
            elif not is_error:
                try:
                    native = val.item() if hasattr(val, 'item') else val
                except Exception:
                    native = val
                if isinstance(native, (int, float, str, bool)):
                    cache_map[(sheet_name, cell_ref)] = native

    try:
        _inject_cached_values(abs_path, cache_map)
    except Exception:
        pass

    error_summary = {}
    for err_type, locations in error_details.items():
        if locations:
            error_summary[err_type] = {
                'count': len(locations),
                'locations': locations[:20],
            }
    return None, total_errors, error_summary


def _static_analyze(formula_cells: list[tuple[str, str, str]],
                    sheet_names: list[str]) -> tuple[int, dict]:
    """Lightweight structural checks when full calculation is unavailable.

    Checks:
    - Referenced sheet names exist in the workbook
    - Parentheses are balanced
    """
    error_details: dict[str, list[str]] = {}
    total_errors = 0

    sheet_ref_re = re.compile(r"(?:'([^']+)'|([A-Za-z_]\w*))!")

    for sheet_name, coord, formula in formula_cells:
        location = f'{sheet_name}!{coord}'

        if formula.count('(') != formula.count(')'):
            error_details.setdefault('syntax_error', []).append(location)
            total_errors += 1
            continue

        for m in sheet_ref_re.finditer(formula):
            ref_sheet = m.group(1) or m.group(2)
            if ref_sheet not in sheet_names:
                error_details.setdefault('#REF!', []).append(location)
                total_errors += 1
                break

    error_summary = {}
    for err_type, locations in error_details.items():
        if locations:
            error_summary[err_type] = {
                'count': len(locations),
                'locations': locations[:20],
            }
    return total_errors, error_summary


def recalc(filename: str, timeout: int = 60) -> dict:
    """Recalculate all formulas and scan for errors.

    Args:
        filename: Absolute path to the xlsx file.
        timeout: Reserved for compatibility (not used by the pure-Python engine).

    Returns:
        dict with keys: status, total_errors, total_formulas, error_summary.
        On failure: dict with 'error' key.
    """
    if not Path(filename).exists():
        return {'error': f'File {filename} does not exist'}

    abs_path = str(Path(filename).absolute())

    try:
        formula_count, formula_cells = _count_formulas(abs_path)
    except Exception as e:
        return {'error': f'Failed to read workbook: {e}'}

    if formula_count == 0:
        return {
            'status': 'success',
            'total_errors': 0,
            'total_formulas': 0,
            'error_summary': {},
        }

    calc_error, total_errors, error_summary = _calculate_and_check(abs_path)

    if calc_error is not None:
        wb_tmp = load_workbook(abs_path, data_only=False)
        sheet_names = wb_tmp.sheetnames
        wb_tmp.close()

        total_errors, error_summary = _static_analyze(formula_cells, sheet_names)
        return {
            'status': 'success' if total_errors == 0 else 'errors_found',
            'total_errors': total_errors,
            'total_formulas': formula_count,
            'error_summary': error_summary,
            'warning': f'Full recalculation unavailable, static analysis only: {calc_error}',
        }

    return {
        'status': 'success' if total_errors == 0 else 'errors_found',
        'total_errors': total_errors,
        'total_formulas': formula_count,
        'error_summary': error_summary,
    }


def main():
    if len(sys.argv) < 2:
        print('Usage: python recalc.py <excel_file> [timeout_seconds]')
        sys.exit(1)

    filename = sys.argv[1]
    timeout = int(sys.argv[2]) if len(sys.argv) > 2 else 60
    print(json.dumps(recalc(filename, timeout), indent=2, ensure_ascii=False))


if __name__ == '__main__':
    main()


path: scripts/__init__.py
content: """
XLSX Skill - Excel 公式重算工具（纯 Python，基于 formulas 库）

使用方式：
    import sys
    sys.path.insert(0, "cooffice/skills/xlsx/scripts")
    from recalc import recalc

    result = recalc("report.xlsx", timeout=60)
    print(result)  # {'status': 'success', 'total_errors': 0, 'total_formulas': 42, ...}
"""

from .recalc import recalc

__all__ = [
    "recalc",
]


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)