Files
hermes-skills/skills/legal/contract-portfolio-analysis/scripts/edit-redmarked-xlsx.py
T

239 lines
10 KiB
Python

#!/usr/bin/env python3
"""
edit-redmarked-xlsx.py — 安全编辑「已标红(WPS规范化)」的 xlsx,红色 run 一个字不碰。
背景(2026-06-18 世茂实证):
WPS 另存后的好 xlsx 用 sharedStrings.xml + 富文本红 <r> run 存储「某条标红」。
对它 openpyxl.load_workbook→改→save 会把整表打回 inlineStr、红色 run 全部归零、
sharedStrings.xml 消失,Excel 重新报「需要修复」——成果一键作废。
正解:在 sharedStrings.xml 的 XML 层做外科手术,只改目标 <t> 文字,红 <r> run 原样保留。
本脚本提供:
1) --verify 只读·五查(本地验不了 Excel,这是能自动做的最强保证)
2) 可 import 的工具函数 surgical_edit_si() / delete_run_and_renumber() / repack()
—— 已知正确的实现,未来 session 直接 import 或照抄,别重新踩坑。
⚠️ 红色 = rgb="FFFF0000"。重打包必须 [Content_Types].xml 在 zip 第一项。
⚠️ 改前先把 WPS 好版本另存 _bak_ 备份;改完五查全过再发 Maggie 用 Excel 终判。
用法:
# 五查(改后必跑)
python3 edit-redmarked-xlsx.py --verify 改后.xlsx --baseline WPS好基线.xlsx
# 列出每个格子用的 sharedString 索引(定位「要改哪个格→改第几条 si」)
python3 edit-redmarked-xlsx.py --map 文件.xlsx [--sheet sheet1.xml]
# 打印某条 si 的 run 结构(看哪些 <r> 是红色、文字开头)
python3 edit-redmarked-xlsx.py --show-si 文件.xlsx 47
"""
import sys, os, re, zipfile, shutil, argparse, tempfile
from lxml import etree
NS = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
RED = "FFFF0000"
# ---------- 只读探针 ----------
def red_run_count(xlsx):
"""数红色富文本 run 数(精确匹配 rgb="FFFF0000")。
⚠️ 2026-06-22 教训:务必用精确 rgb="FFFF0000" 匹配,绝不能用
`'FF0000' in etree.tostring(rpr)` 子串匹配——黑色 FF000000 里也含 'F0000'
会把黑字 run 误判成红字,导致 K列长单元格被误报"整格泛红"
"""
z = zipfile.ZipFile(xlsx)
if "xl/sharedStrings.xml" not in z.namelist():
# 退化成 inlineStr 了——红色多半已丢,去 sheet 里数
total = 0
for n in z.namelist():
if re.match(r"xl/worksheets/sheet\d+\.xml", n):
total += len(re.findall(rf'rgb="{RED}"', z.read(n).decode()))
return total, False # False = 没有 sharedStrings(危险信号)
ss = z.read("xl/sharedStrings.xml").decode()
return len(re.findall(rf'rgb="{RED}"', ss)), True
def cell_si_map(xlsx, sheet="xl/worksheets/sheet1.xml"):
"""返回 {单元格坐标: sharedString索引},用于把「改哪个格」翻成「改第几条 si」。"""
z = zipfile.ZipFile(xlsx)
sx = z.read(sheet).decode()
out = {}
for m in re.finditer(r'<c r="([A-Z]+\d+)"[^>]*\bt="s"[^>]*>\s*<v>(\d+)</v>', sx):
out[m.group(1)] = int(m.group(2))
return out
def show_si(xlsx, idx):
"""打印第 idx 条 si 的 run 结构(红/黑 + 文字开头),定位要改/要删哪个 run。"""
z = zipfile.ZipFile(xlsx)
root = etree.fromstring(z.read("xl/sharedStrings.xml"))
si = root.findall(f"{NS}si")[idx]
print(f"=== si[{idx}] ===")
for i, ch in enumerate(si):
tag = ch.tag.replace(NS, "")
if tag == "r":
rpr = ch.find(f"{NS}rPr")
t = ch.find(f"{NS}t")
is_red = rpr is not None and RED in etree.tostring(rpr, encoding="unicode")
txt = (t.text or "")[:55] if t is not None else ""
print(f" [{i}] {'🔴红' if is_red else ''} | {txt!r}")
elif tag == "t":
print(f" [{i}] 纯t | {(ch.text or '')[:55]!r}")
def verify(xlsx, baseline=None, expect_red=None):
"""五查:sharedStrings在 / 红色数 / zip+XML完整 / (可选)与基线同构。返回 True/False。"""
ok = True
z = zipfile.ZipFile(xlsx)
names = z.namelist()
# ① sharedStrings 仍在
has_ss = "xl/sharedStrings.xml" in names
print(f"① sharedStrings.xml: {'✅在' if has_ss else '❌丢失(openpyxl毁了富文本!)'}")
ok &= has_ss
# ② 红色 run 数
n_red, _ = red_run_count(xlsx)
tail = f"(预期 {expect_red}" if expect_red is not None else ""
match = (expect_red is None) or (n_red == expect_red)
print(f"② 红色run数: {n_red}{tail} {'' if match else ''}")
ok &= match
# ③ zip 完整 + 所有 XML 部件可解析
bad = z.testzip()
parts_ok, parts_bad = 0, []
for n in names:
if n.endswith(".xml") or n.endswith(".rels"):
try:
etree.fromstring(z.read(n)); parts_ok += 1
except Exception as e:
parts_bad.append((n, str(e)[:50]))
print(f"③ zip完整={'' if bad is None else ''+str(bad)}; "
f"XML部件 {parts_ok}个OK"
+ (f"{parts_bad}" if parts_bad else ""))
ok &= (bad is None) and not parts_bad
# ④ Content_Types 必须第一项(严格解析器要求)
first = names[0] if names else ""
ct_first = first == "[Content_Types].xml"
print(f"④ [Content_Types].xml 在首位: {'' if ct_first else '⚠️ 实为 '+first}")
# 不计入硬失败(Excel/WPS 宽容),仅告警
if not ct_first:
print(" ↳ 严格解析器(LibreOffice)会报 source file could not be loaded;建议重打包置首。")
# ⑤ 与基线同构(部件清单)
if baseline:
zb = set(zipfile.ZipFile(baseline).namelist())
zo = set(names)
only_mine = {x for x in zo - zb if not x.endswith("/")}
only_base = {x for x in zb - zo if not x.endswith("/")}
iso = not only_mine and not only_base
print(f"⑤ 与基线部件同构: {'' if iso else ''} "
+ (f"我多:{only_mine} 基线多:{only_base}" if not iso else "(差异仅空目录条目可接受)"))
ok &= iso
print(f"\n{'✅ 五查通过——可发 Maggie 用 Excel 终判' if ok else '❌ 有项未过——先修再发'}")
return ok
# ---------- 编辑工具(import 用;已验证正确,照抄别重写) ----------
def surgical_edit_si(work_dir, edits: dict):
"""
在解压目录 work_dir 的 xl/sharedStrings.xml 上,按 {si索引: 新纯文本} 改纯文本格。
仅适用于「纯文本 si」(无红 run)。含红 run 的格用下面 delete_run_and_renumber 或手写。
"""
ssp = os.path.join(work_dir, "xl", "sharedStrings.xml")
tree = etree.parse(ssp)
sis = tree.getroot().findall(f"{NS}si")
for idx, new_text in edits.items():
si = sis[idx]
for c in list(si):
si.remove(c)
t = etree.SubElement(si, f"{NS}t")
t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
t.text = new_text
tree.write(ssp, xml_declaration=True, encoding="UTF-8", standalone=True)
def delete_run_and_renumber(work_dir, si_idx, match_prefix, renum: dict):
"""
含红 run 的格:删掉文字以 match_prefix 开头的那个 <r>(连同其红色),
再按 renum {旧前缀: 新前缀} 顺移后续编号。红 run 之外的一律不动。
例:删 "9. " 那条红,renum={"10. ":"9. ","11. ":"10. ","12. ":"11. "}
"""
ssp = os.path.join(work_dir, "xl", "sharedStrings.xml")
tree = etree.parse(ssp)
si = tree.getroot().findall(f"{NS}si")[si_idx]
target = None
for r in si.findall(f"{NS}r"):
t = r.find(f"{NS}t")
if t is not None and t.text and t.text.startswith(match_prefix):
target = r; break
if target is None:
raise ValueError(f"si[{si_idx}] 没找到以 {match_prefix!r} 开头的 run")
si.remove(target)
for r in si.findall(f"{NS}r"):
t = r.find(f"{NS}t")
if t is not None and t.text:
for old, new in renum.items():
if t.text.startswith(old):
t.text = new + t.text[len(old):]; break
tree.write(ssp, xml_declaration=True, encoding="UTF-8", standalone=True)
def repack(work_dir, out_xlsx):
"""规范重打包:[Content_Types].xml 第一,_rels/ 次之,其余原序。ZIP_DEFLATED。"""
if os.path.exists(out_xlsx):
os.remove(out_xlsx)
files = []
for folder, _, fs in os.walk(work_dir):
for fn in fs:
full = os.path.join(folder, fn)
arc = os.path.relpath(full, work_dir).replace(os.sep, "/")
files.append((arc, full))
def key(it):
a = it[0]
if a == "[Content_Types].xml": return (0, a)
if a.startswith("_rels/"): return (1, a)
return (2, a)
files.sort(key=key)
with zipfile.ZipFile(out_xlsx, "w", zipfile.ZIP_DEFLATED) as zf:
for arc, full in files:
zf.write(full, arc)
return out_xlsx
def extract(xlsx):
"""解压到新临时目录,返回目录路径。"""
d = tempfile.mkdtemp(prefix="redxlsx_")
with zipfile.ZipFile(xlsx) as z:
z.extractall(d)
return d
# ---------- CLI ----------
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--verify", metavar="XLSX", help="五查(只读)")
ap.add_argument("--baseline", metavar="GOOD", help="WPS好基线,用于同构对比")
ap.add_argument("--expect-red", type=int, help="改后预期红色run数(删1条红=基线-1)")
ap.add_argument("--map", metavar="XLSX", help="列出单元格→sharedString索引")
ap.add_argument("--sheet", default="xl/worksheets/sheet1.xml")
ap.add_argument("--show-si", nargs=2, metavar=("XLSX", "IDX"), help="打印某条 si 的 run 结构")
args = ap.parse_args()
if args.verify:
sys.exit(0 if verify(args.verify, args.baseline, args.expect_red) else 1)
if args.map:
for coord, idx in sorted(cell_si_map(args.map, args.sheet).items(),
key=lambda kv: (kv[0][0], int(re.sub(r"\D", "", kv[0])))):
print(f" {coord:>5} -> si[{idx}]")
return
if args.show_si:
show_si(args.show_si[0], int(args.show_si[1]))
return
ap.print_help()
if __name__ == "__main__":
main()