_delivery_docx_polish.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593
  1. # -*- coding: utf-8 -*-
  2. """正式交付 Word:黑色正文、去重版本、静态目录、去空白页与免责/性能散落。"""
  3. from __future__ import annotations
  4. import re
  5. import shutil
  6. import sys
  7. import tempfile
  8. from pathlib import Path
  9. HERE = Path(__file__).resolve().parent
  10. if str(HERE) not in sys.path:
  11. sys.path.insert(0, str(HERE))
  12. from lxml import etree
  13. from docx import Document
  14. from docx.enum.text import WD_BREAK
  15. from docx.oxml import OxmlElement
  16. from docx.oxml.ns import qn
  17. from docx.shared import Pt, RGBColor
  18. from docx.table import Table
  19. from docx.text.paragraph import Paragraph
  20. from _add_req_func_codes import set_paragraph_text
  21. from _adjust_front_matter import remove_paragraph
  22. BLACK = RGBColor(0, 0, 0)
  23. HEADER_FILL = "D6E3F0"
  24. DARK_FILLS = {"1F3864", "1F4E79", "4472C4", "2F5496", "1F4E79"}
  25. DISCLAIMER_RE = re.compile(
  26. r"不包含移动端|不含移动端|不覆盖移动端|不含移动端专册|"
  27. r"本手册不包含手机|手机 App|手机App|"
  28. r"本总纲不覆盖|"
  29. r"不讲接口、表名和部署|遇到红色提示"
  30. )
  31. PERF_RE = re.compile(
  32. r"合同性能|页面响应遵循|不按秒级|秒级页面|秒级响应|"
  33. r"一般查询不超过|复杂看板不超过|"
  34. r"时延≤|传输\+清洗≤|1000\s*万条|"
  35. r"1000人同时在线|400人并发|"
  36. r"全年可用性|接口调用成功率|"
  37. r"性能:AI/ChatBI|性能目标(|不计入本系统性能|"
  38. r"部署与非功能|^6 非功能$|^非功能$|^性能要求"
  39. )
  40. DROP_HEAD_RE = re.compile(r"^(?:\d+(?:\.\d+)*\s*)?(部署与非功能|非功能|性能要求|性能与并发)\s*$")
  41. REF_HEAD_RE = re.compile(
  42. r"^(?:\d+(?:\.\d+)*\s+|第[一二三四五六七八九十\d]+[章节篇]\s+|"
  43. r"[((]?[一二三四五六七八九十]+[、..)]\s*)?参考资料\s*$"
  44. )
  45. HEAD_NAME_RE = re.compile(r"^(?:Heading|标题)\s*(\d+)$")
  46. HEAD_NUM_RE = re.compile(r"^(\d+(?:\.\d+)*)([.、.]\s*|\s+)(.+)$")
  47. FRONT_TITLES = {"版本记录", "更改记录", "目录"}
  48. FORMAL_KEYS = {
  49. "S0": ["V0.8", "V1.2"],
  50. "S1": ["V0.4", "V2.2", "V1.2"],
  51. "S2": ["V0.4", "V1.2"],
  52. "S3": ["V0.4", "V1.2"],
  53. "S4": ["V0.4", "V1.2"],
  54. "S5": ["V1.2", "V0.3"],
  55. "S6": ["V1.2", "V0.3"],
  56. "S7": ["V1.2", "V0.3"],
  57. "S8": ["V1.2", "V0.3"],
  58. "00-系统总纲": ["V0.4"],
  59. "S9": ["V0.4"],
  60. "运营诊断": ["V0.4"],
  61. "运营改善": ["V0.4"],
  62. "ChatBI": ["V0.4"],
  63. "系统集成": ["V0.4"],
  64. }
  65. def latest_formal_files(base: Path) -> list[Path]:
  66. out = []
  67. for folder, keys in FORMAL_KEYS.items():
  68. d = base / folder
  69. if not d.exists():
  70. continue
  71. for p in d.glob("*.docx"):
  72. if p.name.startswith("~$"):
  73. continue
  74. if any(k in p.name for k in keys):
  75. out.append(p)
  76. return out
  77. def open_via_temp(path: Path) -> Document:
  78. try:
  79. return Document(str(path))
  80. except Exception:
  81. tmp = Path(tempfile.gettempdir()) / "aidop_polish.docx"
  82. shutil.copy2(path, tmp)
  83. return Document(str(tmp))
  84. def save_via_temp(doc: Document, dest: Path) -> None:
  85. tmp = Path(tempfile.gettempdir()) / "aidop_unify_ab"
  86. tmp.mkdir(parents=True, exist_ok=True)
  87. out = tmp / f"polish_{dest.stem[:40]}.docx"
  88. doc.save(str(out))
  89. shutil.copy2(out, dest)
  90. def _force_run_black(run) -> None:
  91. rPr = run._element.get_or_add_rPr()
  92. color_el = rPr.find(qn("w:color"))
  93. if color_el is not None:
  94. rPr.remove(color_el)
  95. highlight = rPr.find(qn("w:highlight"))
  96. if highlight is not None:
  97. rPr.remove(highlight)
  98. run.font.color.rgb = BLACK
  99. def _iter_all_paragraphs(doc: Document):
  100. for p in doc.paragraphs:
  101. yield p
  102. for tbl in doc.tables:
  103. for row in tbl.rows:
  104. for cell in row.cells:
  105. for p in cell.paragraphs:
  106. yield p
  107. for section in doc.sections:
  108. for part in (section.header, section.footer):
  109. for p in part.paragraphs:
  110. yield p
  111. for tbl in part.tables:
  112. for row in tbl.rows:
  113. for cell in row.cells:
  114. for p in cell.paragraphs:
  115. yield p
  116. body = doc.element.body
  117. for txbx in body.iter(qn("w:txbxContent")):
  118. for p_el in txbx.iter(qn("w:p")):
  119. yield Paragraph(p_el, doc)
  120. def force_all_text_black(doc: Document) -> int:
  121. n = 0
  122. for p in _iter_all_paragraphs(doc):
  123. for run in p.runs:
  124. _force_run_black(run)
  125. n += 1
  126. for tbl in doc.tables:
  127. for row in tbl.rows:
  128. for cell in row.cells:
  129. tc_pr = cell._tc.get_or_add_tcPr()
  130. shd = tc_pr.find(qn("w:shd"))
  131. if shd is not None:
  132. fill = (shd.get(qn("w:fill")) or "").upper()
  133. if fill in DARK_FILLS:
  134. shd.set(qn("w:fill"), HEADER_FILL)
  135. return n
  136. def heading_level(p: Paragraph) -> int | None:
  137. name = (p.style.name if p.style else "") or ""
  138. m = HEAD_NAME_RE.match(name.strip())
  139. if m:
  140. return int(m.group(1))
  141. pPr = p._element.find(qn("w:pPr"))
  142. if pPr is not None:
  143. ol = pPr.find(qn("w:outlineLvl"))
  144. if ol is not None:
  145. val = ol.get(qn("w:val"))
  146. if val is not None and val.isdigit():
  147. return int(val) + 1
  148. return None
  149. def _xml(el) -> str:
  150. if hasattr(el, "xml"):
  151. return el.xml
  152. return etree.tostring(el, encoding="unicode")
  153. def _is_page_break_only(p: Paragraph) -> bool:
  154. xml = _xml(p._element)
  155. has_br = 'w:type="page"' in xml or "w:type='page'" in xml
  156. return has_br and not p.text.strip()
  157. def collect_headings(doc: Document) -> list[tuple[int, str]]:
  158. seen_toc = False
  159. items = []
  160. for p in doc.paragraphs:
  161. t = p.text.strip()
  162. if t == "目录":
  163. seen_toc = True
  164. continue
  165. if not seen_toc:
  166. continue
  167. if t in {"版本记录", "更改记录", "目录"}:
  168. continue
  169. lvl = heading_level(p)
  170. if lvl and lvl <= 3 and t:
  171. items.append((lvl, t))
  172. return items
  173. def _remove_toc_fields(doc: Document) -> None:
  174. body = doc.element.body
  175. for sdt in list(body.iter(qn("w:sdt"))):
  176. xml = _xml(sdt)
  177. if "Table of Contents" in xml or "TOC" in xml or "目录" in xml:
  178. parent = sdt.getparent()
  179. if parent is not None:
  180. parent.remove(sdt)
  181. for p in list(doc.paragraphs):
  182. xml = _xml(p._element)
  183. if "w:instrText" in xml and "TOC" in xml and not heading_level(p):
  184. if p.text.strip() in {"", "目录"}:
  185. if p.text.strip() == "目录":
  186. continue
  187. remove_paragraph(p)
  188. def parse_heading_num(text: str) -> tuple[tuple[int, ...], str, str] | None:
  189. m = HEAD_NUM_RE.match(text.strip())
  190. if not m:
  191. return None
  192. parts = tuple(int(x) for x in m.group(1).split("."))
  193. return parts, m.group(2), m.group(3)
  194. def renumber_heading_gaps(doc: Document) -> int:
  195. seen_toc = False
  196. parsed: list[tuple[Paragraph, tuple[int, ...], str, str]] = []
  197. for p in doc.paragraphs:
  198. t = p.text.strip()
  199. if t == "目录":
  200. seen_toc = True
  201. continue
  202. if not seen_toc or t in FRONT_TITLES:
  203. continue
  204. if not heading_level(p):
  205. continue
  206. got = parse_heading_num(t)
  207. if not got:
  208. continue
  209. num, sep, title = got
  210. parsed.append((p, num, sep, title))
  211. if not parsed:
  212. return 0
  213. from collections import defaultdict
  214. by_parent: dict[tuple[int, ...], list[int]] = defaultdict(list)
  215. for _p, num, _sep, _title in parsed:
  216. parent, last = num[:-1], num[-1]
  217. if last not in by_parent[parent]:
  218. by_parent[parent].append(last)
  219. child_map: dict[tuple[tuple[int, ...], int], int] = {}
  220. for parent, lasts in by_parent.items():
  221. ordered = sorted(set(lasts))
  222. for i, old in enumerate(ordered, 1):
  223. if old != i:
  224. child_map[(parent, old)] = i
  225. if not child_map:
  226. return 0
  227. def remap(num: tuple[int, ...]) -> tuple[int, ...]:
  228. out = []
  229. for i, part in enumerate(num):
  230. out.append(child_map.get((num[:i], part), part))
  231. return tuple(out)
  232. n = 0
  233. for p, num, sep, title in parsed:
  234. new = remap(num)
  235. if new == num:
  236. continue
  237. new_text = ".".join(str(x) for x in new) + sep + title
  238. set_paragraph_text(p, new_text)
  239. n += 1
  240. return n
  241. def demote_front_titles(doc: Document) -> None:
  242. for p in doc.paragraphs:
  243. if p.text.strip() in FRONT_TITLES and heading_level(p):
  244. try:
  245. p.style = doc.styles["Normal"]
  246. except (KeyError, ValueError):
  247. pass
  248. def _clear_after_toc_title(doc: Document, toc: Paragraph) -> None:
  249. el = toc._element.getnext()
  250. while el is not None:
  251. nxt = el.getnext()
  252. tag = el.tag.split("}")[-1]
  253. if tag == "p":
  254. p = Paragraph(el, doc)
  255. t = p.text.strip()
  256. lvl = heading_level(p)
  257. if lvl and t and t not in FRONT_TITLES:
  258. break
  259. if _is_page_break_only(p) or (not t) or (not lvl):
  260. el.getparent().remove(el)
  261. el = nxt
  262. continue
  263. break
  264. if tag == "sdt":
  265. el.getparent().remove(el)
  266. el = nxt
  267. continue
  268. break
  269. def insert_toc_field(doc: Document) -> int:
  270. toc = None
  271. for p in doc.paragraphs:
  272. if p.text.strip() == "目录":
  273. toc = p
  274. break
  275. if toc is None:
  276. return 0
  277. _remove_toc_fields(doc)
  278. _clear_after_toc_title(doc, toc)
  279. p_el = OxmlElement("w:p")
  280. for kind, payload in (
  281. ("begin", None),
  282. ("instr", ' TOC \\o "1-4" \\h \\z \\u '),
  283. ("separate", None),
  284. ("text", ""),
  285. ("end", None),
  286. ):
  287. r = OxmlElement("w:r")
  288. rPr = OxmlElement("w:rPr")
  289. color = OxmlElement("w:color")
  290. color.set(qn("w:val"), "000000")
  291. rPr.append(color)
  292. r.append(rPr)
  293. if kind == "instr":
  294. it = OxmlElement("w:instrText")
  295. it.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
  296. it.text = payload
  297. r.append(it)
  298. elif kind == "text":
  299. t_el = OxmlElement("w:t")
  300. t_el.text = payload or ""
  301. r.append(t_el)
  302. else:
  303. fc = OxmlElement("w:fldChar")
  304. fc.set(qn("w:fldCharType"), kind)
  305. r.append(fc)
  306. p_el.append(r)
  307. toc._element.addnext(p_el)
  308. body_head = None
  309. after = False
  310. for p in doc.paragraphs:
  311. if p.text.strip() == "目录":
  312. after = True
  313. continue
  314. if after and heading_level(p) and p.text.strip() not in FRONT_TITLES:
  315. body_head = p
  316. break
  317. if body_head is not None:
  318. xml = _xml(body_head._element)
  319. if 'w:type="page"' not in xml and "w:type='page'" not in xml:
  320. text = body_head.text
  321. body_head.text = ""
  322. br = body_head.add_run()
  323. br.add_break(WD_BREAK.PAGE)
  324. run = body_head.add_run(text)
  325. run.font.color.rgb = BLACK
  326. return 1
  327. def rebuild_static_toc(doc: Document) -> int:
  328. demote_front_titles(doc)
  329. return insert_toc_field(doc)
  330. def _version_col(table: Table) -> int:
  331. headers = [c.text.replace("\n", "").strip() for c in table.rows[0].cells]
  332. for i, h in enumerate(headers):
  333. if h in {"版本", "版本号"}:
  334. return i
  335. return 1 if len(headers) > 1 else 0
  336. def dedupe_version_table(doc: Document) -> int:
  337. n = 0
  338. for p in doc.paragraphs:
  339. if p.text.strip() not in {"版本记录", "更改记录"}:
  340. continue
  341. el = p._element.getnext()
  342. while el is not None:
  343. tag = el.tag.split("}")[-1]
  344. if tag == "tbl":
  345. table = Table(el, doc)
  346. col = _version_col(table)
  347. seen = set()
  348. for row in list(table.rows)[1:]:
  349. ver = row.cells[col].text.replace("\n", "").strip() if col < len(row.cells) else ""
  350. if not ver:
  351. continue
  352. if ver in seen:
  353. table._tbl.remove(row._tr)
  354. n += 1
  355. else:
  356. seen.add(ver)
  357. return n
  358. if tag == "p" and "".join(el.itertext()).strip():
  359. return 0
  360. el = el.getnext()
  361. return n
  362. def collapse_blank_pages(doc: Document) -> int:
  363. n = 0
  364. paras = list(doc.paragraphs)
  365. prev_break = False
  366. for p in paras:
  367. if _is_page_break_only(p):
  368. if prev_break:
  369. remove_paragraph(p)
  370. n += 1
  371. continue
  372. prev_break = True
  373. elif p.text.strip() or heading_level(p):
  374. prev_break = False
  375. paras = list(doc.paragraphs)
  376. while paras and _is_page_break_only(paras[-1]):
  377. remove_paragraph(paras[-1])
  378. n += 1
  379. paras = list(doc.paragraphs)
  380. return n
  381. def drop_disclaimer_and_perf(doc: Document, *, keep_perf: bool) -> int:
  382. n = 0
  383. dropping_section = False
  384. for p in list(doc.paragraphs):
  385. t = p.text.strip()
  386. if not t:
  387. continue
  388. lvl = heading_level(p)
  389. if lvl:
  390. if DROP_HEAD_RE.match(t) and not keep_perf:
  391. dropping_section = True
  392. remove_paragraph(p)
  393. n += 1
  394. continue
  395. dropping_section = False
  396. if dropping_section and not keep_perf:
  397. remove_paragraph(p)
  398. n += 1
  399. continue
  400. if DISCLAIMER_RE.search(t):
  401. remove_paragraph(p)
  402. n += 1
  403. continue
  404. if (not keep_perf) and PERF_RE.search(t):
  405. if t.startswith("请使用 Chrome"):
  406. set_paragraph_text(p, "请使用 Chrome 或 Edge 近两个正式版。")
  407. n += 1
  408. continue
  409. remove_paragraph(p)
  410. n += 1
  411. for table in doc.tables:
  412. for row in table.rows:
  413. for cell in row.cells:
  414. for p in cell.paragraphs:
  415. raw = p.text.strip()
  416. if not raw:
  417. continue
  418. if DISCLAIMER_RE.search(raw) or ((not keep_perf) and PERF_RE.search(raw)):
  419. if raw in {"文档编号", "版本", "密级", "编制单位", "编制日期"}:
  420. continue
  421. if re.match(r"^V\d", raw):
  422. continue
  423. set_paragraph_text(p, "")
  424. n += 1
  425. return n
  426. def is_sys_brd(path: Path) -> bool:
  427. return "业务需求描述-总纲" in path.name
  428. def drop_h1_sections(doc: Document, title_re: re.Pattern[str]) -> int:
  429. n = 0
  430. dropping = False
  431. body = doc.element.body
  432. for child in list(body.iterchildren()):
  433. tag = child.tag.split("}")[-1]
  434. if tag == "p":
  435. p = Paragraph(child, doc)
  436. t = p.text.strip()
  437. lvl = heading_level(p)
  438. if lvl == 1 and title_re.search(t):
  439. dropping = True
  440. body.remove(child)
  441. n += 1
  442. continue
  443. if dropping:
  444. if t in FRONT_TITLES:
  445. dropping = False
  446. continue
  447. if lvl == 1:
  448. dropping = False
  449. continue
  450. body.remove(child)
  451. n += 1
  452. elif tag == "tbl" and dropping:
  453. body.remove(child)
  454. n += 1
  455. return n
  456. SAAS_NFR_H1_RE = re.compile(r"SaaS\s*部署与运维|非功能设计")
  457. def drop_references_section(doc: Document) -> int:
  458. n = 0
  459. dropping = False
  460. drop_lvl = 1
  461. body = doc.element.body
  462. for child in list(body.iterchildren()):
  463. tag = child.tag.split("}")[-1]
  464. if tag == "p":
  465. p = Paragraph(child, doc)
  466. t = p.text.strip()
  467. lvl = heading_level(p)
  468. if REF_HEAD_RE.match(t) and lvl:
  469. dropping = True
  470. drop_lvl = lvl
  471. body.remove(child)
  472. n += 1
  473. continue
  474. if dropping:
  475. if t in {"目录", "版本记录"}:
  476. dropping = False
  477. continue
  478. if lvl and lvl <= drop_lvl:
  479. dropping = False
  480. continue
  481. body.remove(child)
  482. n += 1
  483. elif tag == "tbl" and dropping:
  484. body.remove(child)
  485. n += 1
  486. return n
  487. def polish_doc(doc: Document, path: Path) -> dict:
  488. keep_perf = is_sys_brd(path)
  489. dropped = drop_disclaimer_and_perf(doc, keep_perf=keep_perf)
  490. dropped += drop_references_section(doc)
  491. renum = renumber_heading_gaps(doc)
  492. demote_front_titles(doc)
  493. dupes = dedupe_version_table(doc)
  494. toc_n = insert_toc_field(doc)
  495. blanks = collapse_blank_pages(doc)
  496. colors = force_all_text_black(doc)
  497. return {
  498. "drop": dropped,
  499. "renum": renum,
  500. "dupe": dupes,
  501. "toc": toc_n,
  502. "blank": blanks,
  503. "runs": colors,
  504. }
  505. def polish_file(path: Path) -> str:
  506. doc = open_via_temp(path)
  507. stats = polish_doc(doc, path)
  508. save_via_temp(doc, path)
  509. return (
  510. f"OK {path.parent.name}/{path.name} "
  511. f"drop={stats['drop']} renum={stats['renum']} dupe={stats['dupe']} toc={stats['toc']} blank={stats['blank']}"
  512. )
  513. def main() -> None:
  514. from _unify_ab_delivery_vnext import BASE
  515. files = latest_formal_files(BASE)
  516. print("polish", len(files), "files", flush=True)
  517. for p in files:
  518. try:
  519. msg = polish_file(p)
  520. except Exception as exc:
  521. msg = f"FAIL {p.parent.name}/{p.name}: {exc}"
  522. print(msg, flush=True)
  523. if __name__ == "__main__":
  524. main()