audit_step6_xml.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. """用标准库 OOXML 审计大型 Step6 DOCX,避免 python-docx/lxml 原生崩溃。"""
  2. from __future__ import annotations
  3. import argparse
  4. import json
  5. import pickle
  6. import re
  7. import zipfile
  8. from collections import defaultdict
  9. from pathlib import Path
  10. from xml.etree import ElementTree as ET
  11. from _bootstrap import PROJECT_ROOT # noqa: F401
  12. from step3_outlining.scoring_structure import normalize_heading_text
  13. from step6_exporting.docx_builder import _detect_heading_level
  14. W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
  15. def _text(element) -> str:
  16. parts = []
  17. for node in element.iter():
  18. if node.tag in (W + "t", W + "instrText", W + "delText"):
  19. parts.append(node.text or "")
  20. elif node.tag == W + "tab":
  21. parts.append("\t")
  22. elif node.tag in (W + "br", W + "cr"):
  23. parts.append("\n")
  24. return "".join(parts)
  25. def _style_id(paragraph) -> str:
  26. p_pr = paragraph.find(W + "pPr")
  27. p_style = p_pr.find(W + "pStyle") if p_pr is not None else None
  28. return p_style.get(W + "val", "") if p_style is not None else ""
  29. def _style_levels(styles_root) -> dict[str, int]:
  30. result = {}
  31. for style in styles_root.findall(W + "style"):
  32. style_id = style.get(W + "styleId", "")
  33. name = style.find(W + "name")
  34. name_value = name.get(W + "val", "") if name is not None else ""
  35. combined = f"{style_id} {name_value}".lower().replace(" ", "")
  36. match = re.search(r"(?:heading|标题)([1-4])", combined)
  37. if match:
  38. result[style_id] = int(match.group(1))
  39. return result
  40. def _normalize(text: str) -> str:
  41. return re.sub(r"\s+", "", text or "")
  42. def audit(docx_path: Path, step2_path: Path, outline_path: Path) -> dict:
  43. with open(step2_path, "rb") as file:
  44. step2 = pickle.load(file)
  45. with open(outline_path, "rb") as file:
  46. outline = pickle.load(file)
  47. analysis = step2["analysis"]
  48. with zipfile.ZipFile(docx_path) as package:
  49. document_root = ET.fromstring(package.read("word/document.xml"))
  50. styles_root = ET.fromstring(package.read("word/styles.xml"))
  51. style_levels = _style_levels(styles_root)
  52. body = document_root.find(W + "body")
  53. body_children = list(body) if body is not None else []
  54. paragraphs = [node for node in body_children if node.tag == W + "p"]
  55. tables = [node for node in body_children if node.tag == W + "tbl"]
  56. paragraph_texts = [_text(node).strip() for node in paragraphs]
  57. headings = []
  58. h1 = []
  59. for paragraph, text in zip(paragraphs, paragraph_texts):
  60. if not text:
  61. continue
  62. level = style_levels.get(_style_id(paragraph), 0)
  63. if level == 1:
  64. h1.append(text)
  65. if level in (2, 3, 4) or (
  66. 0 < _detect_heading_level(text) <= 3 and len(text) <= 80
  67. ):
  68. headings.append(text)
  69. expected_titles = {}
  70. expected_levels = {}
  71. for chapter in outline.flatten():
  72. for criterion_id in chapter.related_criteria or []:
  73. if chapter.level <= 1:
  74. continue
  75. previous = expected_levels.get(criterion_id, 0)
  76. if chapter.level > previous:
  77. expected_levels[criterion_id] = chapter.level
  78. expected_titles[criterion_id] = [chapter.title]
  79. elif chapter.level == previous:
  80. expected_titles.setdefault(criterion_id, []).append(chapter.title)
  81. normalized_headings = {normalize_heading_text(value) for value in headings}
  82. missing_scoring = []
  83. for criterion in analysis.scoring_criteria:
  84. titles = expected_titles.get(criterion.id) or [
  85. criterion.name or criterion.description
  86. ]
  87. if not any(
  88. normalize_heading_text(title) in normalized_headings
  89. for title in titles if title
  90. ):
  91. missing_scoring.append(criterion.id)
  92. table_texts = []
  93. table_signatures = []
  94. index_text = ""
  95. index_rows = []
  96. for table in tables:
  97. cells = [_text(cell).strip() for cell in table.iter(W + "tc")]
  98. table_text = "\n".join(cells)
  99. table_texts.append(table_text)
  100. table_signatures.append("|".join(_normalize(cell) for cell in cells))
  101. if not index_text:
  102. first_row = table.find(W + "tr")
  103. header = _text(first_row) if first_row is not None else ""
  104. if any(key in header for key in ("主要内容概述", "与评标有关")):
  105. index_text = table_text
  106. index_rows = [
  107. [_text(cell).strip() for cell in row.iter(W + "tc")]
  108. for row in table.findall(W + "tr")
  109. ]
  110. full_text = "\n".join(paragraph_texts + table_texts)
  111. normalized_full_text = _normalize(full_text)
  112. missing_rejections = [
  113. item.id
  114. for item in analysis.rejection_items
  115. if _normalize(item.description) not in normalized_full_text
  116. ]
  117. duplicate_groups = defaultdict(list)
  118. for index, signature in enumerate(table_signatures):
  119. if signature:
  120. duplicate_groups[signature].append(index)
  121. duplicate_indexes = [
  122. indexes for indexes in duplicate_groups.values() if len(indexes) > 1
  123. ]
  124. chapter3_text = ""
  125. in_chapter3 = False
  126. chapter3_parts = []
  127. for child in body_children:
  128. if child.tag != W + "p":
  129. if in_chapter3:
  130. chapter3_parts.append(_text(child))
  131. continue
  132. text = _text(child).strip()
  133. level = style_levels.get(_style_id(child), 0)
  134. if level == 1:
  135. if "需求理解" in text:
  136. in_chapter3 = True
  137. continue
  138. if in_chapter3:
  139. break
  140. if in_chapter3:
  141. chapter3_parts.append(text)
  142. chapter3_text = "\n".join(chapter3_parts)
  143. demand_scoring = [
  144. criterion for criterion in analysis.scoring_criteria
  145. if any(
  146. key in " ".join((criterion.category, criterion.name, criterion.description))
  147. for key in ("需求理解", "重点难点")
  148. )
  149. ]
  150. return {
  151. "paragraphs": len(paragraphs),
  152. "tables": len(tables),
  153. "h1": h1,
  154. "missing_scoring_headings": missing_scoring,
  155. "missing_rejections": missing_rejections,
  156. "index_sc_tokens": sorted(set(re.findall(r"SC-\d+", index_text, re.I))),
  157. "index_scoring_name_hits": sum(
  158. 1 for item in analysis.scoring_criteria
  159. if _normalize(item.name) in _normalize(index_text)
  160. ),
  161. "index_missing_scoring": [
  162. {"id": item.id, "name": item.name}
  163. for item in analysis.scoring_criteria
  164. if _normalize(item.name) not in _normalize(index_text)
  165. ],
  166. "index_scoring_total": len(analysis.scoring_criteria),
  167. "index_rows": index_rows,
  168. "duplicate_table_index_groups": duplicate_indexes,
  169. "unresolved_placeholders": sorted(set(
  170. re.findall(r"%%[^%\n]+%%", full_text)
  171. )),
  172. "chapter3_characters": len(_normalize(chapter3_text)),
  173. "chapter3_relevant_scoring": {
  174. item.id: {
  175. "name": item.name,
  176. "name_found": _normalize(item.name) in _normalize(chapter3_text),
  177. "description_found": _normalize(item.description) in _normalize(chapter3_text),
  178. }
  179. for item in demand_scoring
  180. },
  181. }
  182. def main() -> None:
  183. parser = argparse.ArgumentParser()
  184. parser.add_argument("docx")
  185. parser.add_argument("--step2", required=True)
  186. parser.add_argument("--outline", required=True)
  187. args = parser.parse_args()
  188. result = audit(Path(args.docx), Path(args.step2), Path(args.outline))
  189. print(json.dumps(result, ensure_ascii=False, indent=2))
  190. if __name__ == "__main__":
  191. main()