239 lines
10 KiB
Python
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()
|