#!/usr/bin/env python3 """ edit-redmarked-xlsx.py — 安全编辑「已标红(WPS规范化)」的 xlsx,红色 run 一个字不碰。 背景(2026-06-18 世茂实证): WPS 另存后的好 xlsx 用 sharedStrings.xml + 富文本红 run 存储「某条标红」。 对它 openpyxl.load_workbook→改→save 会把整表打回 inlineStr、红色 run 全部归零、 sharedStrings.xml 消失,Excel 重新报「需要修复」——成果一键作废。 正解:在 sharedStrings.xml 的 XML 层做外科手术,只改目标 文字,红 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 结构(看哪些 是红色、文字开头) 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']*\bt="s"[^>]*>\s*(\d+)', 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 开头的那个 (连同其红色), 再按 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()