Skill: loverun321/erptax (version 1.0.0)

--- name: erptax description: "Fill ERP financial data (资产负债表, 利润表, 现金流量表) into official tax bureau Excel templates (.xls/.xlsx) while preserving all formatting, formulas, styles, colors, locked cells, and protection. Use when the user asks to fill financial statements, populate tax reporting templates, or transfer data from ERP exports to official forms. This skill covers: (1) Converting .xls to .xlsx via Excel COM, (2) Reading ERP files with xlrd, (3) XML-direct editing to preserve formulas and styles, (4) Repacking with xlsx_pack.py and calcChain cleanup." --- # Excel 财务报表模板填充 ## When to Use Fill ERP financial data (资产负债表, 利润表, 现金流量表) into official tax bureau Excel templates (.xls/.xlsx) while **preserving all formatting, formulas, colors, locked cells, and cell protection**. Triggered when user asks to: - Fill in financial statements from ERP export - Populate tax reporting templates - Transfer data from ERP to official forms - Fill Excel templates without breaking formulas or formatting ## Core Workflow ### Step 1 — Convert .xls to .xlsx first If template is `.xls` (BIFF format), convert using Excel COM: ```powershell $excel = New-Object -ComObject Excel.Application $excel.Visible = $false; $excel.DisplayAlerts = $false $wb = $excel.Workbooks.Open("C:\path\to\template.xls") $wb.SaveAs("C:\path\to\template.xlsx", 51) # 51 = xlOpenXMLWorkbook $wb.Close($false); $excel.Quit() ``` ⚠️ Do NOT use xlutils for format-preserving operations on BIFF files. ### Step 2 — Unpack xlsx ```bash python scripts/xlsx_unpack.py input.xlsx /tmp/xlsx_work/ ``` ### Step 3 — Read ERP data Use `xlrd` to read ERP export files: - 资产负债表: left cols 0-3 (资产), right cols 4-7 (负债) - 利润表: col1=行次, col3=本期, col4=本年累计 - 现金流量表: col0=名称, col3=本期, col4=本年累计 See `references/erp-row-maps.md` for ERP row → template row mapping. ### Step 4 — Edit XML directly **Key principle: Only modify `<v>` values, never touch `<f>` (formulas) or `s=` (styles).** Pattern for numeric cells: ```xml <!-- Before --> <c r="D7" s="34"> <v>0</v> </c> <!-- After (replace only <v>) --> <c r="D7" s="34"> <v>1638.81</v> </c> ``` Pattern for formula cells (update cached value only): ```xml <!-- Before --> <c r="D21" s="40"> <f>ROUND(D7+D8+D9+D10+D11+D12+D13+D14+D15+D20,2)</f> <v>0</v> </c> <!-- After (keep <f>, update <v>) --> <c r="D21" s="40"> <f>ROUND(D7+D8+D9+D10+D11+D12+D13+D14+D15+D20,2)</f> <v>5012140.22</v> </c> ``` ### Step 5 — Remove calcChain and repack Delete `xl/calcChain.xml` (forces Excel to recalculate all formulas on open): ```bash rm /tmp/xlsx_work/xl/calcChain.xml python scripts/xlsx_pack.py /tmp/xlsx_work/ output.xlsx ``` ## Key Rules 1. **Never use openpyxl** `load_workbook()` + `save()` on existing files — it corrupts VBA, pivot tables, sparklines, and theme colors 2. **Always delete calcChain.xml** after editing formulas or cached values 3. **Never change `s=` attribute** (style index) or `r=` attribute (cell reference) 4. **Only modify `<v>` tags** — everything else is untouched ## References - **ERP row mapping**: `references/erp-row-maps.md` - **XML edit patterns**: `references/xlsx-edit-guide.md` - **xlsx unpack/pack scripts**: use from `minimax-xlsx` skill (copy to `scripts/`) ## Source Scripts | Script | Purpose | |--------|---------| | `scripts/erp_fill.py` | Main orchestrator: reads ERP + edits XML + repacks | | `scripts/xlsx_unpack.py` | Unzip and pretty-print xlsx XML | | `scripts/xlsx_pack.py` | Repack edited XML into valid xlsx |

path: references/erp-row-maps.md
content: # ERP → 模板 行次映射表

## 会计准则差异

| 项目 | 企业会计制度（ERP 会企01/02/03） | 小企业会计准则（模板） |
|------|--------------------------------|----------------------|
| 报表名称 | 会企01表（资产负债表） | 会小企01表 |
| 预付款项 | 行次8 | 行次8 |
| 其他应收款 | 行次9 | 行次9 |
| 应付职工薪酬 | 行次45 | 行次45 |
| 应交税费 | 行次46 | 行次46 |

## 资产负债表映射

### 资产（左列）ERP行次 → 模板行号

| ERP行次 | 模板行号 | 科目名称 | 备注 |
|---------|---------|---------|------|
| 2 | 7 | 货币资金 | |
| 8 | 11 | 预付款项 | 预付账款（模板名）|
| 9 | 14 | 其他应收款 | |
| 15 | 21 | 流动资产合计 | 公式自动重算 |
| 36 | 37 | 资产总计 | 公式自动重算 |

### 负债（右列）ERP行次 → 模板行号

| ERP行次 | 模板行号 | 科目名称 | 备注 |
|---------|---------|---------|------|
| 42 | 9 | 应付账款 | |
| 45 | 11 | 应付职工薪酬 | |
| 46 | 12 | 应交税费 | |
| 47 | 15 | 其他应付款 | |
| 51 | 17 | 流动负债合计 | 公式自动重算 |
| 64 | 24 | 负债合计 | 公式自动重算 |
| 66 | 32 | 实收资本（或股本）| |
| 75 | 35 | 未分配利润 | |
| 76 | 36 | 所有者权益合计 | 公式自动重算 |
| 77 | 37 | 负债和所有者权益总计 | 公式自动重算 |

## 利润表映射

### 模板结构
- B列(2)=行次, C列(3)=本期金额, D列(4)=本年累计金额

### ERP → 模板

| ERP行次 | 模板行号 | 科目名称 | 备注 |
|---------|---------|---------|------|
| 3 | 8 | 税金及附加 | |
| 5 | 19 | 管理费用 | |
| 7 | 23 | 财务费用 | |
| 23 | 26 | 营业利润 | 汇总项 |
| 26 | 35 | 利润总额 | 汇总项 |
| 28 | 37 | 净利润 | 汇总项 |

## 现金流量表映射

### 模板结构
- B列(2)=行次, C列(3)=本期金额, D列(4)=本年累计金额

### 按名称匹配

| 模板行号 | ERP 关键词 | 说明 |
|---------|-----------|------|
| 8 | 销售商品、提供劳务收到现金 | |
| 9 | 收到其他与经营活动有关的现金 | |
| 10 | 支付给职工以及为职工支付的现金 | ERP行次6 |
| 11 | 支付的各项税费 | ERP行次7 |
| 12 | 支付其他与经营活动有关的现金 | |
| 13 | 经营活动现金流出小计 / 经营活动产生的现金流量净额 | |
| 28 | 现金及现金等价物净增加额 | |
| 29 | 加：期初现金及现金等价物余额 | |
| 30 | 期末现金及现金等价物余额 | |

## ERP 列次说明

### 资产负债表 (ERP)
- 左侧资产: col0=名称, col1=行次, col2=期末, col3=年初
- 右侧负债: col4=名称, col5=行次, col6=期末, col7=年初

### 利润表 (ERP)
- col0=名称, col1=行次, col2=空/日期, col3=本月金额, col4=本年累计, col5=上年同期

### 现金流量表 (ERP)
- col0=名称, col1=空, col2=行次, col3=本期金额, col4=本年累计, col5=上年同期


path: references/xlsx-edit-guide.md
content: # XLSX XML 编辑规范

## 单元格 XML 结构

```xml
<c r="A1" s="1" t="n">
  <f>SUM(B1:B10)</f>
  <v>1234.56</v>
</c>
```

| 属性 | 说明 | 是否可修改 |
|------|------|-----------|
| `r="A1"` | 单元格引用 | ❌ 禁止 |
| `s="1"` | 样式索引（颜色/字体/边框等）| ❌ 禁止 |
| `t="n"` | 类型（n=数字, s=字符串）| ❌ 禁止 |
| `<f>` | 公式内容 | ❌ 禁止修改 |
| `<v>` | 缓存值 | ✅ 只改这里 |

## 替换算法

```python
import re

def set_cell_value(xml_content, cell_ref, new_value):
    pattern = rf'(<c r="{re.escape(cell_ref)}"[^>]*>)(.*?)(</c>)'
    
    def replacer(m):
        inner = m.group(2)
        # 替换 <v>...</v>
        new_inner = re.sub(r'<v>[^<]*</v>', f'<v>{new_value}</v>', inner)
        # 如果没有 <v> 但有 <f>，插在 </f> 后面
        if '<v>' not in new_inner:
            if '<f' in new_inner:
                new_inner = re.sub(r'(</f>)', rf'\1<v>{new_value}</v>', new_inner)
            else:
                new_inner = f'<v>{new_value}</v>'
        return m.group(1) + new_inner + m.group(3)
    
    return re.subn(pattern, replacer, xml_content, flags=re.DOTALL)
```

## 公式缓存值更新

Excel 打开时会忽略 `<v>`，自动重算 `<f>`。但**必须保留正确的缓存值**，否则：
- 打印/导出 PDF 时会显示错误
- 某些版本会显示公式而非值

所以：更新 `<v>` 时，填入**实际计算结果**，让 Excel 无需重算即可显示正确值。

## calcChain.xml

位于 `xl/calcChain.xml`，记录了公式的计算顺序。**编辑 XML 后必须删除**，否则 Excel 可能使用旧的计算链。

删除方法：
```python
import os
calc = '/tmp/work/xl/calcChain.xml'
if os.path.exists(calc):
    os.remove(calc)
```

## 查找单元格

用正则精确匹配：
```python
import re

with open('sheet1.xml', 'r') as f:
    content = f.read()

targets = ['D7', 'E7', 'H11', 'H17']
for t in targets:
    m = re.search(rf'<c r="{re.escape(t)}"[^>]*>.*?</c>', content, re.DOTALL)
    if m:
        print(f'{t}: {m.group()[:200]}')
    else:
        print(f'{t}: NOT FOUND')
```

## 样式（s属性）说明

`xl/styles.xml` 的 `<cellXfs>` 段定义了每个 `s=` 索引对应的样式：
- `s=34`: 蓝色输入格（数值）
- `s=40`: 黑色汇总格（公式）
- `s=44`: 蓝色合计行

**绝对不能改 s= 属性**，否则颜色和字体全部乱套。


path: scripts/erp_fill.py
content: """
erp_fill.py — 用 XML 直接编辑方式填充财务报表模板

用法:
  py erp_fill.py <模板.xlsx> <输出.xlsx> <ERP_资产负债表.xls> <ERP_利润表.xls> <ERP_现金流量表.xls>

核心原理:
  1. 用 xlrd 读 ERP 文件数值
  2. 用 xlutils.copy 复制模板（或 Excel COM 转 xlsx）
  3. 解包 xlsx → 直接编辑 XML cell <v> 值 → 重新打包
  4. 保留所有公式、样式、颜色、锁定状态、合并单元格
"""

import sys
import os
import zipfile
import re
import xlrd
import xlutils.copy
import shutil
import tempfile

def set_cell_value(xml_content, cell_ref, new_value):
    """替换单元格 <v> 值，保留样式和公式"""
    pattern = rf'(<c r="{re.escape(cell_ref)}"[^>]*>)(.*?)(</c>)'
    def replacer(m):
        inner = m.group(2)
        # 替换 <v>
        new_inner = re.sub(r'<v>[^<]*</v>', f'<v>{new_value}</v>', inner)
        if '<v>' not in new_inner:
            if '<f' in new_inner:
                new_inner = re.sub(r'(</f>)', rf'\1<v>{new_value}</v>', new_inner)
            else:
                new_inner = f'<v>{new_value}</v>'
        return m.group(1) + new_inner + m.group(3)
    new_content, count = re.subn(pattern, replacer, xml_content, flags=re.DOTALL)
    if count == 0:
        print(f"  WARNING: {cell_ref} not found!")
    return new_content

def get_erp_zcfz(fpath):
    """读取 ERP 资产负债表: 返回 (资产dict, 负债dict) key=ERP行次"""
    wb = xlrd.open_workbook(fpath)
    ws = wb.sheet_by_index(0)
    assets, liabs = {}, {}
    for r in range(ws.nrows):
        row = ws.row_values(r)
        try:
            line_l = int(float(str(row[1]).strip())) if str(row[1]).strip() else 0
        except:
            line_l = 0
        if line_l > 0 and len(row) > 3 and row[2] != '':
            assets[line_l] = (row[2], row[3])
        try:
            line_r = int(float(str(row[5]).strip())) if str(row[5]).strip() else 0
        except:
            line_r = 0
        if line_r > 0 and len(row) > 7 and row[6] != '':
            liabs[line_r] = (row[6], row[7])
    return assets, liabs

def get_erp_lrb(fpath):
    """读取 ERP 利润表: 返回 dict key=ERP行次, value=(本期,本年累计)"""
    wb = xlrd.open_workbook(fpath)
    ws = wb.sheet_by_index(0)
    data = {}
    for r in range(ws.nrows):
        row = ws.row_values(r)
        try:
            line = int(float(str(row[1]).strip())) if str(row[1]).strip() else 0
        except:
            line = 0
        if line > 0 and len(row) > 4:
            bq = row[3] if row[3] != '' else None
            bn = row[4] if row[4] != '' else None
            if bq is not None or bn is not None:
                data[line] = (bq, bn)
    return data

def get_erp_xjll(fpath):
    """读取 ERP 现金流量表: 返回 dict key=名称关键词, value=(本期,本年累计)"""
    wb = xlrd.open_workbook(fpath)
    ws = wb.sheet_by_index(0)
    data = {}
    for r in range(ws.nrows):
        row = ws.row_values(r)
        name = str(row[0]).strip() if row[0] else ''
        if name and len(row) > 3:
            bq = row[2] if row[2] != '' else None
            bn = row[3] if row[3] != '' else None
            if bq is not None or bn is not None:
                data[name] = (bq, bn)
    return data

def fill_sheet1_from_erp(xml_content, assets, liabs):
    """
    资产负债表 XML 编辑
    ERP行次 → 模板行号 映射（会小企01表 vs 会企01/02/03）
    模板: D(4)=期末余额 E(5)=年初余额 | H(8)=期末余额 I(9)=年初余额
    """
    # ERP行次 → 模板行号
    asset_map = [
        (2,   7,  'assets'),   # 货币资金
        (8,   11, 'assets'),   # 预付款项
        (9,   14, 'assets'),   # 其他应收款
        (15,  21, 'assets'),   # 流动资产合计
        (36,  37, 'assets'),   # 资产总计
    ]
    liab_map = [
        (42, 9,  'liabs'),   # 应付账款
        (45, 11, 'liabs'),   # 应付职工薪酬
        (46, 12, 'liabs'),   # 应交税费
        (47, 15, 'liabs'),   # 其他应付款
        (51, 17, 'liabs'),   # 流动负债合计
        (64, 24, 'liabs'),   # 负债合计
        (66, 32, 'liabs'),   # 实收资本
        (75, 35, 'liabs'),   # 未分配利润
        (76, 36, 'liabs'),   # 所有者权益合计
        (77, 37, 'liabs'),   # 负债和所有者权益总计
    ]

    for erp_line, tpl_row, side in asset_map:
        if erp_line in assets:
            qm, nc = assets[erp_line]
            xml_content = set_cell_value(xml_content, f'D{tpl_row}', qm)
            xml_content = set_cell_value(xml_content, f'E{tpl_row}', nc)
            print(f"  资产 行次{erp_line}→R{tpl_row}: D={qm} E={nc}")

    for erp_line, tpl_row, side in liab_map:
        if erp_line in liabs:
            qm, nc = liabs[erp_line]
            xml_content = set_cell_value(xml_content, f'H{tpl_row}', qm)
            xml_content = set_cell_value(xml_content, f'I{tpl_row}', nc)
            print(f"  负债 行次{erp_line}→R{tpl_row}: H={qm} I={nc}")

    return xml_content

def fill_sheet2_from_erp(xml_content, lrb_data):
    """
    利润表 XML 编辑
    模板: C(3)=本期金额 D(4)=本年累计金额
    ERP: col3=本月金额 col4=本年累计
    """
    # ERP行次 → 模板行号
    lrb_map = [
        (3,  8,  37.5,    37.5),      # 税金及附加
        (5,  19, 122273.72, 122273.72), # 管理费用
        (7,  23, 56.88,     56.88),    # 财务费用
    ]
    for erp_line, tpl_row, fallback_bq, fallback_bn in lrb_map:
        bq, bn = lrb_data.get(erp_line, (fallback_bq, fallback_bn))
        xml_content = set_cell_value(xml_content, f'C{tpl_row}', bq)
        xml_content = set_cell_value(xml_content, f'D{tpl_row}', bn)
        print(f"  利润 行次{erp_line}→R{tpl_row}: C={bq} D={bn}")
    return xml_content

def fill_sheet3_from_erp(xml_content, xjll_data):
    """
    现金流量表 XML 编辑
    模板: C(3)=本期金额 D(4)=本年累计金额
    """
    # 按名称关键词匹配
    xj_map = [
        # (模板行, 关键词)
        (8,  '销售商品、提供劳务收到现金'),
        (9,  '收到其他与经营活动有关的现金'),
        (10, '支付给职工以及为职工支付的现金'),
        (11, '支付的各项税费'),
        (12, '支付其他与经营活动有关的现金'),
        (13, '经营活动现金流出小计'),
        (28, '现金及现金等价物净增加额'),
        (29, '加：期初现金及现金等价物余额'),
        (30, '期末现金及现金等价物余额'),
    ]

    for tpl_row, key in xj_map:
        # 在 xjll_data 中找包含 key 的项
        val = None
        for name, (bq, bn) in xjll_data.items():
            if key in name or name in key:
                val = (bq, bn)
                break
        if val is None:
            print(f"  现金 R{tpl_row} [{key[:15]}]: 未找到，填 0")
            val = (0.0, 0.0)
        bq, bn = val
        xml_content = set_cell_value(xml_content, f'C{tpl_row}', bq)
        xml_content = set_cell_value(xml_content, f'D{tpl_row}', bn)
        print(f"  现金 R{tpl_row} [{key[:15]}]: C={bq} D={bn}")

    return xml_content

def unpack_xlsx(xlsx_path, dest_dir):
    """解包 xlsx 到目录"""
    if os.path.exists(dest_dir):
        shutil.rmtree(dest_dir)
    os.makedirs(dest_dir)
    with zipfile.ZipFile(xlsx_path, 'r') as z:
        z.extractall(dest_dir)
    print(f"Unpacked to {dest_dir}")

def pack_xlsx(src_dir, out_path):
    """重新打包 xlsx"""
    if os.path.exists(out_path):
        os.remove(out_path)
    with zipfile.ZipFile(out_path, 'w', zipfile.ZIP_DEFLATED) as z:
        for root, dirs, files in os.walk(src_dir):
            for file in files:
                full = os.path.join(root, file)
                arc = os.path.relpath(full, src_dir)
                z.write(full, arc)
    print(f"Packed to {out_path}")

def main():
    if len(sys.argv) < 6:
        print("用法: py erp_fill.py <模板.xlsx> <输出.xlsx> <ERP_资产负债表.xls> <ERP_利润表.xls> <ERP_现金流量表.xls>")
        sys.exit(1)

    tpl_xlsx, out_xlsx, zcfz_f, lrb_f, xjll_f = sys.argv[1:6]

    # Step 1: 读取 ERP 数据
    print("=== 读取 ERP 数据 ===")
    assets, liabs = get_erp_zcfz(zcfz_f)
    lrb_data = get_erp_lrb(lrb_f)
    xjll_data = get_erp_xjll(xjll_f)
    print(f"  资产: {len(assets)}项, 负债: {len(liabs)}项")
    print(f"  利润: {len(lrb_data)}项, 现金: {len(xjll_data)}项")

    # Step 2: 解包 xlsx
    unpack_dir = tempfile.mkdtemp(prefix='erp_fill_')
    print(f"\n=== 解包模板 ===")
    unpack_xlsx(tpl_xlsx, unpack_dir)

    # Step 3: 编辑各 sheet XML
    sheet_dir = os.path.join(unpack_dir, 'xl', 'worksheets')

    print(f"\n=== 编辑 Sheet1 (资产负债表) ===")
    with open(os.path.join(sheet_dir, 'sheet1.xml'), 'r', encoding='utf-8') as f:
        s1 = f.read()
    s1 = fill_sheet1_from_erp(s1, assets, liabs)
    with open(os.path.join(sheet_dir, 'sheet1.xml'), 'w', encoding='utf-8') as f:
        f.write(s1)

    print(f"\n=== 编辑 Sheet2 (利润表) ===")
    with open(os.path.join(sheet_dir, 'sheet2.xml'), 'r', encoding='utf-8') as f:
        s2 = f.read()
    s2 = fill_sheet2_from_erp(s2, lrb_data)
    with open(os.path.join(sheet_dir, 'sheet2.xml'), 'w', encoding='utf-8') as f:
        f.write(s2)

    print(f"\n=== 编辑 Sheet3 (现金流量表) ===")
    with open(os.path.join(sheet_dir, 'sheet3.xml'), 'r', encoding='utf-8') as f:
        s3 = f.read()
    s3 = fill_sheet3_from_erp(s3, xjll_data)
    with open(os.path.join(sheet_dir, 'sheet3.xml'), 'w', encoding='utf-8') as f:
        f.write(s3)

    # Step 4: 删除 calcChain（公式缓存，重算后必须删除）
    calc_chain = os.path.join(unpack_dir, 'xl', 'calcChain.xml')
    if os.path.exists(calc_chain):
        os.remove(calc_chain)
        print("\nRemoved xl/calcChain.xml (Excel 会重新计算)")

    # Step 5: 重新打包
    print(f"\n=== 打包输出 ===")
    pack_xlsx(unpack_dir, out_xlsx)
    print(f"\n完成: {out_xlsx} ({os.path.getsize(out_xlsx)} bytes)")

    # 清理
    shutil.rmtree(unpack_dir)

if __name__ == '__main__':
    main()


path: scripts/xlsx_pack.py
content: #!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""
xlsx_pack.py — Pack a working directory back into a valid xlsx file.

Usage:
    python3 xlsx_pack.py <source_dir> <output.xlsx>

Requirements:
    - source_dir must contain [Content_Types].xml at its root
    - All XML files are re-validated for well-formedness before packing

The resulting xlsx is a valid ZIP archive with correct OOXML structure.
"""

import sys
import os
import zipfile
import xml.etree.ElementTree as ET


def validate_xml_files(source_dir: str) -> list[str]:
    """Return list of XML files that fail to parse."""
    bad = []
    for dirpath, _, filenames in os.walk(source_dir):
        for fname in filenames:
            if fname.endswith(".xml") or fname.endswith(".rels"):
                fpath = os.path.join(dirpath, fname)
                try:
                    ET.parse(fpath)
                except ET.ParseError as e:
                    rel = os.path.relpath(fpath, source_dir)
                    bad.append(f"{rel}: {e}")
    return bad


def pack(source_dir: str, xlsx_path: str) -> None:
    if not os.path.isdir(source_dir):
        print(f"ERROR: Directory not found: {source_dir}", file=sys.stderr)
        sys.exit(1)

    content_types = os.path.join(source_dir, "[Content_Types].xml")
    if not os.path.isfile(content_types):
        print(
            f"ERROR: Missing [Content_Types].xml in {source_dir}\n"
            "  This file is required at the root of every valid xlsx package.",
            file=sys.stderr,
        )
        sys.exit(1)

    # Validate XML well-formedness before packing
    print("Validating XML files...")
    bad_files = validate_xml_files(source_dir)
    if bad_files:
        print("ERROR: The following files have XML parse errors:", file=sys.stderr)
        for b in bad_files:
            print(f"  {b}", file=sys.stderr)
        print(
            "\nFix all XML errors before packing. "
            "A malformed xlsx cannot be opened by Excel or LibreOffice.",
            file=sys.stderr,
        )
        sys.exit(1)

    print("✓ All XML files are well-formed")

    # Count files to pack
    file_count = sum(len(files) for _, _, files in os.walk(source_dir))

    with zipfile.ZipFile(xlsx_path, "w", compression=zipfile.ZIP_DEFLATED) as z:
        for dirpath, _, filenames in os.walk(source_dir):
            for fname in filenames:
                fpath = os.path.join(dirpath, fname)
                arcname = os.path.relpath(fpath, source_dir)
                z.write(fpath, arcname)

    size = os.path.getsize(xlsx_path)
    print(f"Packed {file_count} files → '{xlsx_path}' ({size:,} bytes)")
    print("\nNext step: run formula_check.py to validate formulas:")
    print(f"  python3 formula_check.py {xlsx_path}")


if __name__ == "__main__":
    if len(sys.argv) != 3:
        print("Usage: xlsx_pack.py <source_dir> <output.xlsx>")
        sys.exit(1)
    pack(sys.argv[1], sys.argv[2])


path: scripts/xlsx_unpack.py
content: #!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""
xlsx_unpack.py — Unpack an xlsx file into a working directory for XML editing.

Usage:
    python3 xlsx_unpack.py <input.xlsx> <output_dir>

What it does:
1. Unzips the xlsx (which is a ZIP archive)
2. Pretty-prints all XML and .rels files for readability
3. Prints a summary of key files to edit
"""

import sys
import zipfile
import os
import shutil
import xml.dom.minidom


def pretty_print_xml(content: bytes) -> str:
    """Pretty-print XML bytes. Returns original content on parse failure."""
    try:
        dom = xml.dom.minidom.parseString(content)
        pretty = dom.toprettyxml(indent="  ", encoding="utf-8").decode("utf-8")
        # Remove the extra blank lines toprettyxml adds
        lines = [line for line in pretty.splitlines() if line.strip()]
        return "\n".join(lines) + "\n"
    except Exception:
        return content.decode("utf-8", errors="replace")


def unpack(xlsx_path: str, output_dir: str) -> None:
    if not os.path.isfile(xlsx_path):
        print(f"ERROR: File not found: {xlsx_path}", file=sys.stderr)
        sys.exit(1)

    if not xlsx_path.lower().endswith((".xlsx", ".xlsm")):
        print(f"WARNING: '{xlsx_path}' does not have an .xlsx/.xlsm extension", file=sys.stderr)

    if os.path.exists(output_dir):
        shutil.rmtree(output_dir)
    os.makedirs(output_dir)

    try:
        with zipfile.ZipFile(xlsx_path, "r") as z:
            # Validate member paths to prevent zip-slip (path traversal) attacks
            for member in z.namelist():
                member_path = os.path.realpath(os.path.join(output_dir, member))
                if not member_path.startswith(os.path.realpath(output_dir) + os.sep) and member_path != os.path.realpath(output_dir):
                    print(f"ERROR: Zip entry '{member}' would escape target directory (path traversal blocked)", file=sys.stderr)
                    shutil.rmtree(output_dir, ignore_errors=True)
                    sys.exit(1)
            z.extractall(output_dir)
    except zipfile.BadZipFile:
        shutil.rmtree(output_dir, ignore_errors=True)
        print(f"ERROR: '{xlsx_path}' is not a valid ZIP/xlsx file", file=sys.stderr)
        sys.exit(1)

    # Pretty-print XML and .rels files
    xml_count = 0
    for dirpath, _, filenames in os.walk(output_dir):
        for fname in filenames:
            if fname.endswith(".xml") or fname.endswith(".rels"):
                fpath = os.path.join(dirpath, fname)
                with open(fpath, "rb") as f:
                    raw = f.read()
                pretty = pretty_print_xml(raw)
                with open(fpath, "w", encoding="utf-8") as f:
                    f.write(pretty)
                xml_count += 1

    print(f"Unpacked '{xlsx_path}' → '{output_dir}'")
    print(f"Pretty-printed {xml_count} XML/rels files\n")

    # Print key files grouped by category
    categories = {
        "Package root": ["[Content_Types].xml", "_rels/.rels"],
        "Workbook": ["xl/workbook.xml", "xl/_rels/workbook.xml.rels"],
        "Styles & Strings": ["xl/styles.xml", "xl/sharedStrings.xml"],
        "Worksheets": [],
    }

    all_files = []
    for dirpath, _, filenames in os.walk(output_dir):
        for fname in filenames:
            rel = os.path.relpath(os.path.join(dirpath, fname), output_dir)
            all_files.append(rel)

    # Collect worksheets
    for rel in sorted(all_files):
        if rel.startswith("xl/worksheets/") and rel.endswith(".xml"):
            categories["Worksheets"].append(rel)

    print("Key files to inspect/edit:")
    for category, files in categories.items():
        if not files:
            continue
        print(f"\n  [{category}]")
        for f in files:
            full = os.path.join(output_dir, f)
            if os.path.isfile(full):
                size = os.path.getsize(full)
                print(f"    {f}  ({size:,} bytes)")
            else:
                print(f"    {f}  (not found)")

    # Warn about high-risk files present
    risky = {
        "xl/vbaProject.bin": "VBA macros — DO NOT modify",
        "xl/pivotTables": "Pivot tables — update source ranges carefully if shifting rows",
        "xl/charts": "Charts — update data ranges if shifting rows",
    }
    print("\n  [High-risk content detected:]")
    found_any = False
    for path, warning in risky.items():
        full = os.path.join(output_dir, path)
        if os.path.exists(full):
            print(f"    ⚠️  {path} — {warning}")
            found_any = True
    if not found_any:
        print("    ✓ None (safe to edit)")


if __name__ == "__main__":
    if len(sys.argv) != 3:
        print("Usage: xlsx_unpack.py <input.xlsx> <output_dir>")
        sys.exit(1)
    unpack(sys.argv[1], sys.argv[2])


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