format_applier.py 72 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842
  1. """
  2. DOCX 全面格式后处理模块(V3 新增)
  3. 在 LLM 标题层级分析完成后,对 DOCX 文档进行全面的格式规范化处理。
  4. 处理流程:
  5. 1. 应用 LLM 分析得出的标题层级(设置 Word Heading 样式)
  6. 2. 统一页面设置(页边距)
  7. 3. 统一字体/字号/颜色
  8. 4. 统一段落格式(行距、缩进、间距)
  9. 5. 设置页眉/页脚/页码
  10. 6. 插入自动目录(TOC)
  11. 7. 表格格式统一
  12. 8. 清理禁用格式
  13. 9. 占位符兜底清理
  14. 所有格式规范严格遵循用户定义的标书格式标准。
  15. """
  16. from __future__ import annotations
  17. import logging
  18. import re
  19. from typing import Dict, List, Optional, Set, Tuple
  20. from docx import Document as DocxDocument
  21. from docx.shared import Pt, Cm, Inches, Emu, RGBColor
  22. from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
  23. from docx.oxml.ns import qn
  24. from docx.oxml import OxmlElement
  25. from lxml import etree
  26. from step6_exporting.heading_analyzer import HeadingAnalysis, HeadingInfo
  27. logger = logging.getLogger(__name__)
  28. # ============================================================
  29. # 格式规范常量(严格按用户要求)
  30. # ============================================================
  31. # ---- 页面设置 ----
  32. MARGIN_TOP_CM = 2.54
  33. MARGIN_BOTTOM_CM = 2.54
  34. MARGIN_LEFT_CM = 2.5
  35. MARGIN_RIGHT_CM = 2.5
  36. # ---- 字号(pt)- 中文号数映射 ----
  37. SIZE_COVER_TITLE = 22 # 二号
  38. SIZE_H1 = 15 # 小三号(一级标题:一、二、三)
  39. SIZE_H2 = 14 # 四号(二级标题:(一)(二))
  40. SIZE_H3 = 12 # 小四号(三级标题:1. 2. 3.)
  41. SIZE_BODY = 12 # 小四号(正文)
  42. SIZE_TABLE_NOTE = 10.5 # 五号(表格、注释、备注)
  43. SIZE_PAGE_NUM = 10.5 # 五号(页码)
  44. SIZE_TOC_TITLE = 22 # 二号(目录标题)
  45. SIZE_TOC_L1 = 14 # 四号(目录一级)
  46. SIZE_TOC_L2 = 12 # 小四号(目录二级)
  47. # ---- 字体 ----
  48. FONT_BODY = "宋体"
  49. FONT_HEADING = "黑体"
  50. FONT_WESTERN = "Times New Roman"
  51. ALLOWED_FONTS = {FONT_BODY, FONT_HEADING, FONT_WESTERN}
  52. # ---- 行距 ----
  53. LINE_SPACING_BODY = 1.5
  54. # ---- 段落 ----
  55. BODY_INDENT_CHARS = 2 # 正文首行缩进 2 字符
  56. BODY_INDENT_PT = Pt(24) # 小四号 2 字符 ≈ 24pt
  57. # ---- Word 命名空间 ----
  58. WML_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
  59. def _debold_body_sentences(doc: DocxDocument) -> int:
  60. """正文(非标题)段落中的完整句子取消加粗。
  61. 模板正文本身常把完整句子设为加粗(如"我司有针对性制定方案…"、
  62. "1、以体系贯彻…"),导出保留模板原布局时原样带入。按规范正文
  63. 不加粗,只保留短标题/表名/附件标记/证书名称行的加粗。
  64. """
  65. fixed = 0
  66. for p in doc.paragraphs:
  67. pEl = p._element
  68. pPr = pEl.find(qn("w:pPr"))
  69. style = ""
  70. if pPr is not None:
  71. ps = pPr.find(qn("w:pStyle"))
  72. if ps is not None:
  73. style = (ps.get(qn("w:val")) or "").replace(" ", "").lower()
  74. if style.startswith("heading"):
  75. continue
  76. text = p.text.strip()
  77. if len(text) < 15:
  78. continue
  79. # 仅处理"完整句子"(含句子标点),短标签/标题不加粗处理不干预
  80. if not re.search(r"[,。;:,]", text):
  81. continue
  82. # 标题/表名/附件标记/证书名称行保留加粗
  83. if re.match(r"^(附:|附件|附注|附:)", text):
  84. continue
  85. if re.search(r"(表|单|函|声明|承诺|证书|制度)$", text):
  86. continue
  87. if re.match(r"^[((][一二三四五六七八九十百]+[))]", text):
  88. continue
  89. n = 0
  90. for r in p.runs:
  91. if r.bold:
  92. r.bold = False
  93. n += 1
  94. if n:
  95. fixed += 1
  96. if fixed:
  97. logger.info(f"正文句子去加粗: {fixed} 个段落")
  98. return fixed
  99. def _normalize_table_widths(doc: DocxDocument, content_width_dxa: int) -> int:
  100. """将文档中所有表格宽度统一缩放到页面可用宽度(横向填满,且不超页)。
  101. 处理两类问题:
  102. - 表格过窄(如从 PDF 提取的报价表只有 5000 dxa,未横向填充);
  103. - 表格过宽(如参考投书中的 20 列会议登记表宽达 14000 dxa,超出页面)。
  104. 规则:仅当表格总宽与可用宽度偏差超过容差(95%~105%)时按比例缩放
  105. gridCol 与每个单元格 tcW。
  106. """
  107. return _normalize_table_widths_for_tables(doc.tables, content_width_dxa)
  108. def _normalize_table_widths_for_tables(tables, content_width_dxa: int) -> int:
  109. """只归一化明确传入的表格,避免触碰模板原生表格。"""
  110. if content_width_dxa <= 0:
  111. return 0
  112. fixed = 0
  113. low = int(content_width_dxa * 0.95)
  114. high = int(content_width_dxa * 1.05)
  115. for tbl in tables:
  116. tbl_el = tbl._element
  117. grid = tbl_el.find(qn("w:tblGrid"))
  118. if grid is None:
  119. continue
  120. cols = grid.findall(qn("w:gridCol"))
  121. if not cols:
  122. continue
  123. widths = []
  124. for gc in cols:
  125. w = gc.get(qn("w:w"))
  126. try:
  127. widths.append(int(w or 0))
  128. except (TypeError, ValueError):
  129. widths.append(0)
  130. total = sum(widths)
  131. if total <= 0:
  132. continue
  133. # tblW 与 gridCol 不一致:模板表格常保留小的 tblW(如 5000 dxa)
  134. # 而 gridCol 已被填充到接近页宽。此时即使 gridTotal 在容差内,
  135. # 只要 tblW 明显偏小(< 90% 页宽),也必须把 tblW 校正为页宽,
  136. # 否则 Word 按 tblW 渲染导致表格不横向拓展。
  137. tblW_el = None
  138. tblPr = tbl_el.find(qn("w:tblPr"))
  139. if tblPr is not None:
  140. tblW_el = tblPr.find(qn("w:tblW"))
  141. tblw_val = None
  142. if tblW_el is not None:
  143. try:
  144. tblw_val = int(tblW_el.get(qn("w:w")) or 0)
  145. except (TypeError, ValueError):
  146. tblw_val = None
  147. tblw_too_small = (
  148. tblw_val is not None
  149. and tblw_val > 0
  150. and tblw_val < content_width_dxa * 0.9
  151. )
  152. if low <= total <= high and not tblw_too_small:
  153. continue
  154. ratio = content_width_dxa / total
  155. new_widths = [max(60, int(w * ratio)) for w in widths]
  156. # 修正取整误差:最后一列吸收差额
  157. diff = content_width_dxa - sum(new_widths)
  158. if new_widths:
  159. new_widths[-1] = max(60, new_widths[-1] + diff)
  160. # 更新 gridCol
  161. for gc, w in zip(cols, new_widths):
  162. gc.set(qn("w:w"), str(w))
  163. # 更新每个单元格 tcW
  164. for tr in tbl_el.findall(qn("w:tr")):
  165. for ci, tc in enumerate(tr.findall(qn("w:tc"))):
  166. if ci >= len(new_widths):
  167. break
  168. tcPr = tc.find(qn("w:tcPr"))
  169. if tcPr is None:
  170. tcPr = etree.SubElement(tc, qn("w:tcPr"))
  171. tcW = tcPr.find(qn("w:tcW"))
  172. if tcW is None:
  173. tcW = etree.SubElement(tcPr, qn("w:tcW"))
  174. tcW.set(qn("w:w"), str(new_widths[ci]))
  175. tcW.set(qn("w:type"), "dxa")
  176. # 更新 tblW(tblW=0 的表格也统一设为页宽)
  177. if tblPr is not None:
  178. tblW = tblPr.find(qn("w:tblW"))
  179. if tblW is None:
  180. tblW = etree.SubElement(tblPr, qn("w:tblW"))
  181. tblW.set(qn("w:w"), str(content_width_dxa))
  182. tblW.set(qn("w:type"), "dxa")
  183. fixed += 1
  184. logger.debug(
  185. f" 表格宽度归一化: {total} → {content_width_dxa} dxa "
  186. f"(列: {new_widths})"
  187. )
  188. return fixed
  189. _HEADER_JUDGE_PROMPT = """你是表格结构识别专家。判断下面表格的"第一行"是否为"列名/表头"(字段名),
  190. 还是表格的正文数据行。
  191. 输出严格 JSON:{"is_header": true 或 false, "reason": "一句话理由"}
  192. 规则:
  193. 1. 列名通常是短字段名(如"序号、名称、金额、日期、备注"),彼此是并列概念;
  194. 2. 若第一行是完整句子、长描述、具体数据(人名/数值/大段文字),则是正文;
  195. 3. 若第一行是多个短字段名,即使后续单元格为空,也算列名;
  196. 4. 只输出 JSON。"""
  197. def _llm_is_header_row(first_row_texts: list):
  198. """LLM 判断表格首行是否为列名;失败返回 None(沿用启发式)"""
  199. try:
  200. from llm_client import LLMClient
  201. llm = LLMClient()
  202. user = "\n".join(
  203. f"{i + 1}. {t[:120]}" for i, t in enumerate(first_row_texts)
  204. )
  205. result = llm.extract_json(
  206. system_prompt=_HEADER_JUDGE_PROMPT,
  207. user_prompt=f"表格第一行单元格内容:\n{user}",
  208. max_tokens=1024,
  209. )
  210. if isinstance(result, dict):
  211. val = result.get("is_header")
  212. if val is not None:
  213. return bool(val)
  214. except Exception as e:
  215. logger.debug(f"表首行列名 LLM 判断失败: {e}")
  216. return None
  217. def _merge_cell_paragraphs(cell) -> int:
  218. """合并单元格内的多个段落为一个段落(保留首段格式与全部文本)。
  219. 参考投书/PDF 提取的表格常在单元格内产生多个 <w:p>,Word 中显示为
  220. 强制换行(如“规定”“分值”分两段)。投标表格单元格通常应单段自动换行,
  221. 这里把后续段落文本直接并入首段(不加空格,保持与模板原文一致),
  222. 并删除多余段落。
  223. Returns:
  224. 合并删除的段落数
  225. """
  226. paragraphs = list(cell.paragraphs)
  227. if len(paragraphs) <= 1:
  228. return 0
  229. # 合并后总文本超长(如表格内嵌整段说明)时保留原多段结构
  230. total = sum(len(p.text or "") for p in paragraphs)
  231. if total > 200:
  232. return 0
  233. first = paragraphs[0]
  234. merged_text = ""
  235. removed = 0
  236. for extra in paragraphs[1:]:
  237. extra_text = extra.text or ""
  238. if extra_text.strip():
  239. merged_text += extra_text.strip()
  240. p_el = extra._element
  241. parent = p_el.getparent()
  242. if parent is not None:
  243. parent.remove(p_el)
  244. removed += 1
  245. if merged_text:
  246. runs = first.runs
  247. if runs:
  248. # add_text 会追加到 run 现有文本之后
  249. runs[-1].add_text(merged_text)
  250. else:
  251. first.add_run(merged_text)
  252. return removed
  253. def _set_cell_align_by_content(cell, cell_text: str) -> None:
  254. """按内容设置单元格级对齐(tcPr/w:jc):纯数字右对齐,文字左对齐。
  255. 部分模板/参考表格的居中位于单元格属性(w:tcPr/w:jc),段落级 jc
  256. 无法覆盖,必须显式设置/清除单元格级对齐。
  257. """
  258. if not cell_text:
  259. return
  260. is_numeric = bool(
  261. re.match(r'^[\d,.,。、%%¥¥\s]+$', cell_text)
  262. )
  263. align_val = "right" if is_numeric else "left"
  264. tcPr = cell._tc.get_or_add_tcPr()
  265. jc = tcPr.find(qn("w:jc"))
  266. if jc is None:
  267. jc = etree.SubElement(tcPr, qn("w:jc"))
  268. jc.set(qn("w:val"), align_val)
  269. def _ensure_heading_styles(doc: DocxDocument) -> None:
  270. """确保文档样式表存在 styleId=Heading1~Heading9 的样式(幂等)
  271. 模板的 heading 样式 styleId 可能是数字(如 styleId="2"),而
  272. FormatApplier 以 "Heading{level}"(如 "Heading1")作为 pStyle 引用。
  273. 样式不存在时 Word 无法识别标题层级(导航窗格/TOC 域全部失效),
  274. 段落会回退为 Normal 样式显示。
  275. 注意:python-docx 的 styles.add_style 查重按样式名(大小写不敏感),
  276. 模板已有 name="heading 1" 时 add_style('Heading 1') 会抛 ValueError。
  277. 因此直接操作 styles.xml:以模板同名 heading 样式为蓝本,复制出
  278. styleId=HeadingN / name="Heading N" 的定义(Word 内置样式名,
  279. TOC 域按此名收集标题)。
  280. """
  281. import copy
  282. from docx.oxml import OxmlElement
  283. styles_elem = doc.styles.element
  284. existing_ids = {
  285. s.get(qn("w:styleId")) for s in styles_elem.findall(qn("w:style"))
  286. }
  287. for level in range(1, 10):
  288. style_id = f"Heading{level}"
  289. if style_id in existing_ids:
  290. continue
  291. # 找模板中同层级的 heading 样式(name="heading N")作为蓝本
  292. template_style = None
  293. for s in styles_elem.findall(qn("w:style")):
  294. name_el = s.find(qn("w:name"))
  295. if name_el is not None and name_el.get(qn("w:val")) == f"heading {level}":
  296. template_style = s
  297. break
  298. if template_style is None:
  299. continue
  300. new_style = copy.deepcopy(template_style)
  301. new_style.set(qn("w:styleId"), style_id)
  302. name_el = new_style.find(qn("w:name"))
  303. if name_el is not None:
  304. name_el.set(qn("w:val"), f"Heading {level}")
  305. # 去掉基于/继承引用,避免指向模板数字 ID 样式或缺失样式
  306. for tag in ("w:basedOn", "w:next", "w:link"):
  307. el = new_style.find(qn(tag))
  308. if el is not None:
  309. new_style.remove(el)
  310. styles_elem.append(new_style)
  311. existing_ids.add(style_id)
  312. logger.debug(f"补充 Heading 样式: {style_id}")
  313. class FormatApplier:
  314. """DOCX 全面格式后处理器
  315. 用法:
  316. applier = FormatApplier()
  317. report = applier.apply(doc, heading_analysis, project_name="项目名称")
  318. """
  319. def __init__(self):
  320. self._fixed_count = 0
  321. self._warnings: List[str] = []
  322. self._errors: List[str] = []
  323. # ================================================================
  324. # 主入口
  325. # ================================================================
  326. def apply(
  327. self,
  328. doc: DocxDocument,
  329. heading_analysis: Optional[HeadingAnalysis] = None,
  330. project_name: str = "",
  331. part_label: str = "",
  332. company_name: str = "",
  333. protected_table_elements: Optional[set] = None,
  334. protected_paragraph_elements: Optional[set] = None,
  335. preserve_heading_structure: bool = False,
  336. ) -> Dict:
  337. """执行全面格式后处理
  338. Args:
  339. doc: 已组装的 DOCX 文档(原地修改)
  340. heading_analysis: LLM 标题层级分析结果
  341. project_name: 项目全称(用于页眉)
  342. part_label: 保留参数(当前不使用,页眉不加标类型标识)
  343. company_name: 公司名称(用于落款等)
  344. Returns:
  345. {"fixed": int, "warnings": [...], "errors": [...]}
  346. """
  347. self._fixed_count = 0
  348. self._warnings = []
  349. self._errors = []
  350. # ---- 确保 Heading 样式定义存在(模板可能只有数字 ID 的 heading 样式) ----
  351. _ensure_heading_styles(doc)
  352. # ---- 回退:LLM 分析失败时用正则检测标题 ----
  353. if heading_analysis is None or not heading_analysis.headings:
  354. logger.warning("LLM 标题分析不可用,启用正则回退方案检测标题")
  355. heading_analysis = _build_fallback_heading_analysis(doc)
  356. # ---- Phase 1: 标题层级应用 ----
  357. if preserve_heading_structure:
  358. logger.info("模板标题结构已锁定,跳过标题样式重分类")
  359. else:
  360. self._apply_heading_styles(doc, heading_analysis)
  361. # ---- Phase 2: 页面设置 ----
  362. self._fix_page_setup(doc)
  363. # ---- Phase 3: 字体/字号/颜色统一 ----
  364. self._fix_fonts_and_colors(
  365. doc, heading_analysis,
  366. protected_table_elements=protected_table_elements,
  367. protected_paragraph_elements=protected_paragraph_elements,
  368. )
  369. # ---- Phase 4: 段落格式统一 ----
  370. self._fix_paragraph_format(
  371. doc,
  372. heading_analysis,
  373. protected_paragraph_elements=protected_paragraph_elements,
  374. )
  375. # ---- Phase 5: 封面标题格式 ----
  376. self._fix_cover_page(doc, project_name)
  377. # ---- Phase 6: 页眉/页脚/页码 ----
  378. self._fix_headers_footers(doc, project_name, part_label)
  379. # ---- Phase 7: 插入自动目录 ----
  380. self._insert_toc(doc, project_name)
  381. # ---- Phase 8: 表格格式统一 ----
  382. self._fix_table_format(
  383. doc, protected_table_elements=protected_table_elements
  384. )
  385. # ---- Phase 9: 清理禁用格式 ----
  386. self._remove_forbidden_formatting(
  387. doc,
  388. protected_table_elements=protected_table_elements,
  389. protected_paragraph_elements=protected_paragraph_elements,
  390. )
  391. # ---- Phase 10: 占位符兜底清理 ----
  392. self._cleanup_placeholders(doc)
  393. logger.info(
  394. f"格式后处理完成: {self._fixed_count} 项修复, "
  395. f"{len(self._warnings)} 项警告, {len(self._errors)} 项错误"
  396. )
  397. return {
  398. "fixed": self._fixed_count,
  399. "warnings": self._warnings,
  400. "errors": self._errors,
  401. }
  402. # ================================================================
  403. # Phase 1: 标题层级样式应用
  404. # ================================================================
  405. def _apply_heading_styles(
  406. self,
  407. doc: DocxDocument,
  408. heading_analysis: Optional[HeadingAnalysis],
  409. ):
  410. """根据 LLM 标题分析结果设置 Word Heading 样式 + 显式格式
  411. 两步保证标题可见性:
  412. 1. 设置 Word 内置样式(Heading 1 ~ Heading 8)供 TOC 引用
  413. 2. 直接设置 run 级别的显式格式(字体/字号/加粗),
  414. 确保即使模板样式定义异常,标题也能在视觉上区别于正文
  415. 层级格式(严格按用户规范):
  416. Heading 1 (第X章): 二号 22pt 黑体加粗
  417. Heading 2 (一、): 小三号 15pt 黑体加粗
  418. Heading 3 ((一)): 四号 14pt 黑体加粗
  419. Heading 4 (1.): 小四号 12pt 宋体加粗
  420. """
  421. if not heading_analysis or not heading_analysis.headings:
  422. logger.warning("无标题层级分析结果,跳过标题样式设置")
  423. return
  424. # 显式格式映射(确保即使 Word 样式失效也能正确显示)
  425. _EXPLICIT_FORMAT = {
  426. 1: (Pt(22), FONT_HEADING, True), # 二号黑体加粗(章标题)
  427. 2: (Pt(15), FONT_HEADING, True), # 小三号黑体加粗
  428. 3: (Pt(14), FONT_HEADING, True), # 四号黑体加粗
  429. 4: (Pt(12), FONT_BODY, True), # 小四号宋体加粗
  430. 5: (Pt(12), FONT_BODY, True),
  431. 6: (Pt(12), FONT_BODY, False),
  432. 7: (Pt(10.5), FONT_BODY, False),
  433. 8: (Pt(10.5), FONT_BODY, False),
  434. }
  435. all_paras = list(doc.paragraphs)
  436. applied = 0
  437. for hinfo in heading_analysis.headings:
  438. if hinfo.para_index >= len(all_paras):
  439. continue
  440. if hinfo.level < 1:
  441. # 被确定性规则降为正文的段落(制度内嵌"第X章"/附件区等):
  442. # 清除其残留的 Heading 样式,恢复为正文
  443. para = all_paras[hinfo.para_index]
  444. try:
  445. pPr = para._element.find(qn("w:pPr"))
  446. if pPr is not None:
  447. pStyle = pPr.find(qn("w:pStyle"))
  448. if pStyle is not None and (
  449. pStyle.get(qn("w:val")) or ""
  450. ).startswith("Heading"):
  451. self._set_paragraph_style(para, "Normal")
  452. applied += 1
  453. except Exception:
  454. pass
  455. continue
  456. para = all_paras[hinfo.para_index]
  457. text = para.text.strip()
  458. # 防御:跳过空段落。LLM 越界索引或索引错位可能指向空行,
  459. # 空段落设为标题样式会形成"没有文字的一级标题"。
  460. if not text:
  461. continue
  462. # 防御:超长段落(>60字)按规范是正文而非标题(LLM 偶发
  463. # 把长正文误判为标题,这里做最终逻辑拦截)。投标书标题
  464. # 最长不超过 40 字,60 字上限不会误伤合法标题。
  465. if len(text) > 60:
  466. continue
  467. style_name = f"Heading{hinfo.level}"
  468. try:
  469. # 1) 设置 Word 样式(供 TOC 域引用)
  470. self._set_paragraph_style(para, style_name)
  471. # 2) 设置显式 run 格式(保证视觉可见)
  472. size, font, bold = _EXPLICIT_FORMAT.get(
  473. hinfo.level, (Pt(12), FONT_BODY, False)
  474. )
  475. for run in para.runs:
  476. if self._run_has_image(run):
  477. continue
  478. run.font.size = size
  479. run.font.name = font
  480. self._set_east_asian_font(run, font)
  481. run.bold = bold
  482. # 3) 取消缩进(标题独占一行)
  483. para.paragraph_format.first_line_indent = None
  484. # 4) 一级标题(第X章 章标题)段前分页:每个一级标题从新页首行开始
  485. if hinfo.level == 1:
  486. para.paragraph_format.page_break_before = True
  487. applied += 1
  488. except Exception as e:
  489. logger.debug(f"设置段落 p{hinfo.para_index} 样式失败: {e}")
  490. if applied > 0:
  491. self._fixed_count += applied
  492. logger.info(f"标题样式已应用: {applied} 个段落(含显式格式)")
  493. def _set_paragraph_style(self, para, style_name: str):
  494. """安全设置段落的 Word 样式(通过 XML)"""
  495. pPr = para._element.find(qn('w:pPr'))
  496. if pPr is None:
  497. pPr = OxmlElement('w:pPr')
  498. para._element.insert(0, pPr)
  499. pStyle = pPr.find(qn('w:pStyle'))
  500. if pStyle is None:
  501. pStyle = OxmlElement('w:pStyle')
  502. pPr.insert(0, pStyle)
  503. pStyle.set(qn('w:val'), style_name)
  504. # ================================================================
  505. # Phase 2: 页面设置
  506. # ================================================================
  507. def _fix_page_setup(self, doc: DocxDocument):
  508. """统一所有节的页面设置"""
  509. for section in doc.sections:
  510. section.top_margin = Cm(MARGIN_TOP_CM)
  511. section.bottom_margin = Cm(MARGIN_BOTTOM_CM)
  512. section.left_margin = Cm(MARGIN_LEFT_CM)
  513. section.right_margin = Cm(MARGIN_RIGHT_CM)
  514. self._fixed_count += 1
  515. logger.debug(
  516. f"页边距统一: 上{MARGIN_TOP_CM}/下{MARGIN_BOTTOM_CM}/"
  517. f"左{MARGIN_LEFT_CM}/右{MARGIN_RIGHT_CM}cm"
  518. )
  519. # ================================================================
  520. # Phase 3: 字体/字号/颜色统一
  521. # ================================================================
  522. def _fix_fonts_and_colors(
  523. self,
  524. doc: DocxDocument,
  525. heading_analysis: Optional[HeadingAnalysis],
  526. protected_table_elements: Optional[set] = None,
  527. protected_paragraph_elements: Optional[set] = None,
  528. ):
  529. """统一全文字体、字号、颜色
  530. - 正文:小四号宋体(12pt),西文 Times New Roman
  531. - 标题:按层级设置(小三/四号/小四黑体加粗)
  532. - 表格/注释:五号宋体(10.5pt)
  533. - 文字颜色:全文仅黑色
  534. - 所有数字/英文:Times New Roman(字号跟随中文)
  535. """
  536. heading_levels = {}
  537. if heading_analysis:
  538. heading_levels = heading_analysis._index
  539. # ---- 处理 body 级段落 ----
  540. # 识别签名/落款区的段落索引(这些区域从最后一个标题向后扫描)
  541. signature_start_idx = self._find_signature_block_start(doc, heading_levels)
  542. for para_idx, para in enumerate(doc.paragraphs):
  543. if para._element in (protected_paragraph_elements or set()):
  544. continue
  545. if not para.runs:
  546. continue
  547. heading_level = heading_levels.get(para_idx, 0)
  548. text = para.text or ""
  549. has_cjk = bool(re.search(r'[一-鿿㐀-䶿]', text))
  550. # 签名区段落:保留原格式,仅确保字号合理
  551. is_signature = (
  552. signature_start_idx > 0 and para_idx >= signature_start_idx
  553. )
  554. for run in para.runs:
  555. if self._run_has_image(run):
  556. continue
  557. # ---- 颜色:强制黑色(所有文本) ----
  558. if run.font.color and run.font.color.rgb:
  559. run.font.color.rgb = None
  560. # ---- 字号 ----
  561. if is_signature:
  562. # 签名区:保留原字号(通常是四号或小四)
  563. if run.font.size and run.font.size.pt > 18:
  564. run.font.size = Pt(SIZE_BODY)
  565. elif heading_level > 0:
  566. self._set_heading_font_size(run, heading_level)
  567. else:
  568. self._set_body_font(run, has_cjk)
  569. # ---- 标题加粗 ----
  570. if heading_level > 0:
  571. if not run.bold:
  572. run.bold = True
  573. # ---- 处理表格内文字 ----
  574. for table in doc.tables:
  575. if table._element in (protected_table_elements or set()):
  576. continue
  577. for row in table.rows:
  578. for cell in row.cells:
  579. for para in cell.paragraphs:
  580. for run in para.runs:
  581. if self._run_has_image(run):
  582. continue
  583. if run.font.color and run.font.color.rgb:
  584. run.font.color.rgb = None
  585. if run.font.size:
  586. run.font.size = Pt(SIZE_TABLE_NOTE)
  587. else:
  588. run.font.size = Pt(SIZE_TABLE_NOTE)
  589. # 中文→宋体,西文→TNR
  590. self._set_body_font(run, has_cjk=True)
  591. self._fixed_count += 1
  592. @staticmethod
  593. def _find_signature_block_start(doc, heading_levels: dict) -> int:
  594. """检测文档中签名/落款区域的起始段落索引
  595. 从文档末尾向前扫描,找到最后一个标题段落后即为签名区。
  596. 如果全文都是正文(无标题),返回文档末尾。
  597. """
  598. if not heading_levels:
  599. return len(list(doc.paragraphs))
  600. # 找到最后一个标题段落
  601. last_heading_idx = max(heading_levels.keys()) if heading_levels else -1
  602. if last_heading_idx < 0:
  603. return len(list(doc.paragraphs))
  604. # 签名区起始:最后一个标题之后 3 段(跳过可能的空白段)
  605. all_paras = list(doc.paragraphs)
  606. sig_start = min(last_heading_idx + 3, len(all_paras))
  607. # 如果该区域之后的段落数不超过全文的 5%,认为是签名区
  608. remaining = len(all_paras) - sig_start
  609. if remaining < max(3, len(all_paras) * 0.05):
  610. return sig_start
  611. # 否则只在有明确签名关键词时才标记
  612. sig_keywords = ["投标人:", "法定代表人:", "日期:", "盖章", "签字"]
  613. for idx in range(sig_start, len(all_paras)):
  614. text = all_paras[idx].text.strip()
  615. if any(kw in text for kw in sig_keywords):
  616. return idx
  617. return len(all_paras) # 无签名区
  618. def _set_heading_font_size(self, run, heading_level: int):
  619. """按标题层级设置字号和字体
  620. Heading 层级映射(与用户规范严格对齐):
  621. Heading 1 → "第X章" 章标题 → 二号 22pt 黑体加粗
  622. Heading 2 → "一、二、三" → 小三号 15pt 黑体加粗
  623. Heading 3 → "(一)(二)" → 四号 14pt 黑体加粗
  624. Heading 4 → "1. 2. 3." → 小四号 12pt 宋体加粗
  625. Heading 5 → "(1) (2)" → 小四号 12pt 宋体加粗
  626. Heading 6-8 → 五号 10.5pt 宋体
  627. """
  628. size_map = {
  629. 1: SIZE_COVER_TITLE, # 二号 22pt — 章标题
  630. 2: SIZE_H1, # 小三号 15pt
  631. 3: SIZE_H2, # 四号 14pt
  632. 4: SIZE_H3, # 小四号 12pt
  633. 5: SIZE_H3, # 小四号 12pt
  634. 6: SIZE_TABLE_NOTE, # 五号 10.5pt
  635. 7: SIZE_TABLE_NOTE,
  636. 8: SIZE_TABLE_NOTE,
  637. }
  638. size = size_map.get(heading_level, SIZE_H1)
  639. if run.font.size:
  640. run.font.size = Pt(size)
  641. else:
  642. run.font.size = Pt(size)
  643. # 标题字体:Heading 1-3 黑体,Heading 4+ 宋体
  644. if heading_level <= 3:
  645. run.font.name = FONT_HEADING
  646. self._set_east_asian_font(run, FONT_HEADING)
  647. else:
  648. run.font.name = FONT_BODY
  649. self._set_east_asian_font(run, FONT_BODY)
  650. def _set_body_font(self, run, has_cjk: bool = True):
  651. """设置正文字体"""
  652. if not run.font.size:
  653. run.font.size = Pt(SIZE_BODY)
  654. if has_cjk:
  655. run.font.name = FONT_BODY
  656. self._set_east_asian_font(run, FONT_BODY)
  657. else:
  658. run.font.name = FONT_WESTERN
  659. # ================================================================
  660. # Phase 4: 段落格式统一
  661. # ================================================================
  662. def _fix_paragraph_format(
  663. self,
  664. doc: DocxDocument,
  665. heading_analysis: Optional[HeadingAnalysis],
  666. protected_paragraph_elements: Optional[set] = None,
  667. ):
  668. """统一段落格式
  669. - 正文:首行缩进 2 字符,1.5 倍行距,段前段后 0
  670. - 标题:不缩进,独占一行
  671. - 特殊段落(签名区、表格说明、居中段落):保持原格式,不强制缩进
  672. """
  673. heading_levels = {}
  674. if heading_analysis:
  675. heading_levels = heading_analysis._index
  676. # 需要跳过缩进的段落特征
  677. _SKIP_INDENT_KEYWORDS = [
  678. "投标人:", "法定代表人:", "授权代表:", "日期:",
  679. "被授权人:", "签字", "盖章", "(盖章)", "(签名)",
  680. "投标人名称:", "法定代表人或授权代表:",
  681. ]
  682. for para_idx, para in enumerate(doc.paragraphs):
  683. if para._element in (protected_paragraph_elements or set()):
  684. continue
  685. text = para.text.strip()
  686. if not text:
  687. continue
  688. heading_level = heading_levels.get(para_idx, 0)
  689. pf = para.paragraph_format
  690. # ---- 行距统一 ----
  691. if pf.line_spacing != LINE_SPACING_BODY:
  692. pf.line_spacing = LINE_SPACING_BODY
  693. # ---- 段前段后统一为 0(严格按规范) ----
  694. pf.space_before = Pt(0)
  695. pf.space_after = Pt(0)
  696. # ---- 缩进 ----
  697. if heading_level > 0:
  698. # 标题不缩进
  699. if pf.first_line_indent is not None:
  700. pf.first_line_indent = None
  701. elif para.alignment == WD_ALIGN_PARAGRAPH.CENTER:
  702. # 居中段落(封面、分区标题等)不缩进
  703. if pf.first_line_indent is not None:
  704. pf.first_line_indent = None
  705. elif any(kw in text for kw in _SKIP_INDENT_KEYWORDS):
  706. # 签名/落款区不缩进
  707. if pf.first_line_indent is not None:
  708. pf.first_line_indent = None
  709. else:
  710. # 正文首行缩进 2 字符
  711. current = pf.first_line_indent
  712. if current is None:
  713. pf.first_line_indent = BODY_INDENT_PT
  714. elif hasattr(current, 'pt') and abs(current.pt - 24) > 3:
  715. pf.first_line_indent = BODY_INDENT_PT
  716. self._fixed_count += 1
  717. # ================================================================
  718. # Phase 5: 封面标题格式
  719. # ================================================================
  720. def _fix_cover_page(self, doc: DocxDocument, project_name: str):
  721. """检查和修正封面格式:二号黑体加粗居中"""
  722. # 查找文档前几段中最像封面标题的段落
  723. for para in list(doc.paragraphs)[:15]:
  724. text = para.text.strip()
  725. if not text or len(text) < 3:
  726. continue
  727. if (para.alignment == WD_ALIGN_PARAGRAPH.CENTER
  728. and ("投标" in text or project_name in text)):
  729. for run in para.runs:
  730. run.font.size = Pt(SIZE_COVER_TITLE)
  731. run.font.name = FONT_HEADING
  732. self._set_east_asian_font(run, FONT_HEADING)
  733. if not run.bold:
  734. run.bold = True
  735. # ================================================================
  736. # Phase 6: 页眉/页脚/页码
  737. # ================================================================
  738. def _fix_headers_footers(
  739. self,
  740. doc: DocxDocument,
  741. project_name: str,
  742. part_label: str,
  743. ):
  744. """设置页眉页脚页码
  745. 页眉:按模板原样保留(项目全称等),不添加商务标/技术标字样
  746. 页脚:居中页码(五号宋体),仅页码无其他信息
  747. 封面、目录不编页码
  748. """
  749. # ---- 通过 XML part 级别处理页眉 ----
  750. header_parts, footer_parts = self._collect_hf_parts(doc)
  751. for hf_part in header_parts:
  752. try:
  753. self._setup_header_part(hf_part, project_name, part_label)
  754. except Exception as e:
  755. logger.debug(f"页眉 part 设置失败: {e}")
  756. for hf_part in footer_parts:
  757. try:
  758. self._setup_footer_part(hf_part)
  759. except Exception as e:
  760. logger.debug(f"页脚 part 设置失败: {e}")
  761. # ---- 通过 python-docx API 补充 ----
  762. for section in doc.sections:
  763. # 页眉
  764. header = section.header
  765. if header:
  766. header_text = "".join(p.text for p in header.paragraphs).strip()
  767. if not header_text and project_name and header.paragraphs:
  768. main_para = header.paragraphs[0]
  769. # 清除旧内容
  770. for run in main_para.runs:
  771. if not self._run_has_image(run):
  772. run.text = ""
  773. # 左侧项目名
  774. run_left = main_para.add_run(project_name)
  775. run_left.font.size = Pt(SIZE_TABLE_NOTE)
  776. run_left.font.name = FONT_BODY
  777. self._set_east_asian_font(run_left, FONT_BODY)
  778. # 右侧标类型(用制表符对齐)
  779. if part_label:
  780. main_para.add_run("\t\t")
  781. run_right = main_para.add_run(part_label)
  782. run_right.font.size = Pt(SIZE_TABLE_NOTE)
  783. run_right.font.name = FONT_BODY
  784. self._set_east_asian_font(run_right, FONT_BODY)
  785. self._fixed_count += 1
  786. logger.debug("页眉页脚页码已设置")
  787. def _collect_hf_parts(self, doc: DocxDocument) -> Tuple[list, list]:
  788. """收集所有 header/footer XML parts"""
  789. try:
  790. from docx.opc.constants import RELATIONSHIP_TYPE as RT
  791. HEADER_TYPE = RT.HEADER
  792. FOOTER_TYPE = RT.FOOTER
  793. except ImportError:
  794. HEADER_TYPE = (
  795. "http://schemas.openxmlformats.org/"
  796. "officeDocument/2006/relationships/header"
  797. )
  798. FOOTER_TYPE = (
  799. "http://schemas.openxmlformats.org/"
  800. "officeDocument/2006/relationships/footer"
  801. )
  802. headers, footers = [], []
  803. for rel in doc.part.rels.values():
  804. reltype = rel.reltype if isinstance(rel.reltype, str) else str(rel.reltype)
  805. if reltype == HEADER_TYPE:
  806. headers.append(rel.target_part)
  807. elif reltype == FOOTER_TYPE:
  808. footers.append(rel.target_part)
  809. return headers, footers
  810. @staticmethod
  811. def _header_part_has_text(hf_part) -> bool:
  812. """页眉 part 是否已有文本内容(含文本框/绘图内文字)"""
  813. w_ns = WML_NS
  814. root = hf_part._element
  815. return any(
  816. (t.text or "").strip()
  817. for t in root.iter(f"{{{w_ns}}}t")
  818. )
  819. def _setup_header_part(self, hf_part, project_name: str, part_label: str):
  820. """设置单个 header part 的内容
  821. 保留已有图片/绘图对象,仅更新或追加文本内容。
  822. 避免破坏模板中的公司 Logo 等图片资源。
  823. """
  824. # 模板页眉已有文字内容(含文本框)时保留模板原样,
  825. # 不再追加"项目名称 + 标类型",避免与模板自带页眉重复
  826. if self._header_part_has_text(hf_part):
  827. logger.debug("页眉已有文本内容,保留模板原样")
  828. return
  829. root = hf_part._element
  830. w_ns = WML_NS
  831. # 查找第一个段落
  832. paras = root.findall(f'{{{w_ns}}}p')
  833. if not paras:
  834. return
  835. para = paras[0]
  836. # ---- 检查段落中是否包含图片/绘图 ----
  837. drawing_runs = [] # 含图片的 run 索引
  838. text_runs = [] # 纯文本 run 索引
  839. for child in list(para):
  840. tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
  841. if tag == 'pPr':
  842. continue
  843. if tag == 'r':
  844. # 检查 run 内是否包含 drawing/pict
  845. has_image = False
  846. for sub in child:
  847. sub_tag = sub.tag.split('}')[-1] if '}' in sub.tag else sub.tag
  848. if sub_tag in ('drawing', 'pict'):
  849. has_image = True
  850. break
  851. if has_image:
  852. drawing_runs.append(child)
  853. else:
  854. text_runs.append(child)
  855. elif tag == 'drawing' or tag == 'pict':
  856. drawing_runs.append(child)
  857. else:
  858. text_runs.append(child)
  859. has_images = len(drawing_runs) > 0
  860. if has_images:
  861. # ---- 保留图片,只替换文本 run 的内容 ----
  862. # 清除现有文本 run 的文本
  863. for run_elem in text_runs:
  864. for t_elem in run_elem.findall(f'{{{w_ns}}}t'):
  865. t_elem.text = ""
  866. # 在最后一个图片 run 之后插入项目名称文本
  867. if project_name:
  868. last_drawing = drawing_runs[-1]
  869. parent = para
  870. # 找到最后一个 drawing run 在段落中的位置
  871. para_children = list(parent)
  872. try:
  873. insert_idx = para_children.index(last_drawing) + 1
  874. except ValueError:
  875. insert_idx = len(para_children)
  876. # 创建文本 run
  877. text_run = etree.Element(f'{{{w_ns}}}r')
  878. rPr = etree.SubElement(text_run, f'{{{w_ns}}}rPr')
  879. sz = etree.SubElement(rPr, f'{{{w_ns}}}sz')
  880. sz.set(f'{{{w_ns}}}val', str(int(SIZE_TABLE_NOTE * 2)))
  881. rFonts = etree.SubElement(rPr, f'{{{w_ns}}}rFonts')
  882. rFonts.set(f'{{{w_ns}}}eastAsia', FONT_BODY)
  883. rFonts.set(f'{{{w_ns}}}ascii', FONT_WESTERN)
  884. rFonts.set(f'{{{w_ns}}}hAnsi', FONT_WESTERN)
  885. t = etree.SubElement(text_run, f'{{{w_ns}}}t')
  886. t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
  887. t.text = f" {project_name}"
  888. parent.insert(insert_idx, text_run)
  889. if part_label:
  890. # 添加制表符 + 标类型
  891. tab_run = etree.SubElement(parent, f'{{{w_ns}}}r')
  892. etree.SubElement(tab_run, f'{{{w_ns}}}tab')
  893. tab_run2 = etree.SubElement(parent, f'{{{w_ns}}}r')
  894. etree.SubElement(tab_run2, f'{{{w_ns}}}tab')
  895. part_run = etree.Element(f'{{{w_ns}}}r')
  896. part_rPr = etree.SubElement(part_run, f'{{{w_ns}}}rPr')
  897. part_sz = etree.SubElement(part_rPr, f'{{{w_ns}}}sz')
  898. part_sz.set(f'{{{w_ns}}}val', str(int(SIZE_TABLE_NOTE * 2)))
  899. part_rFonts = etree.SubElement(part_rPr, f'{{{w_ns}}}rFonts')
  900. part_rFonts.set(f'{{{w_ns}}}eastAsia', FONT_BODY)
  901. part_rFonts.set(f'{{{w_ns}}}ascii', FONT_WESTERN)
  902. part_rFonts.set(f'{{{w_ns}}}hAnsi', FONT_WESTERN)
  903. part_t = etree.SubElement(part_run, f'{{{w_ns}}}t')
  904. part_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
  905. part_t.text = part_label
  906. parent.append(part_run)
  907. return
  908. # ---- 无图片:标准页眉设置(清除旧文本,保留 pPr) ----
  909. for child in list(para):
  910. tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
  911. if tag != 'pPr':
  912. para.remove(child)
  913. if project_name:
  914. self._add_text_run(para, project_name, SIZE_TABLE_NOTE, FONT_BODY, w_ns)
  915. if part_label:
  916. # 添加制表符
  917. tab_run = etree.SubElement(para, f'{{{w_ns}}}r')
  918. tab_elem = etree.SubElement(tab_run, f'{{{w_ns}}}tab')
  919. tab_run2 = etree.SubElement(para, f'{{{w_ns}}}r')
  920. tab_elem2 = etree.SubElement(tab_run2, f'{{{w_ns}}}tab')
  921. self._add_text_run(para, part_label, SIZE_TABLE_NOTE, FONT_BODY, w_ns)
  922. def _setup_footer_part(self, hf_part):
  923. """设置单个 footer part:居中页码"""
  924. root = hf_part._element
  925. w_ns = WML_NS
  926. # 清理旧的 PAGE 域代码
  927. self._remove_page_fields(root, w_ns)
  928. # 查找第一个段落
  929. paras = root.findall(f'{{{w_ns}}}p')
  930. if not paras:
  931. return
  932. para = paras[0]
  933. # 清除旧内容(保留 pPr)
  934. for child in list(para):
  935. tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
  936. if tag != 'pPr':
  937. para.remove(child)
  938. # 确保居中
  939. pPr = para.find(f'{{{w_ns}}}pPr')
  940. if pPr is None:
  941. pPr = etree.SubElement(para, f'{{{w_ns}}}pPr')
  942. para.insert(0, pPr)
  943. jc = pPr.find(f'{{{w_ns}}}jc')
  944. if jc is None:
  945. jc = etree.SubElement(pPr, f'{{{w_ns}}}jc')
  946. jc.set(f'{{{w_ns}}}val', 'center')
  947. # 创建 PAGE 域代码
  948. # run 1: fldChar begin
  949. run1 = etree.SubElement(para, f'{{{w_ns}}}r')
  950. rPr1 = etree.SubElement(run1, f'{{{w_ns}}}rPr')
  951. sz1 = etree.SubElement(rPr1, f'{{{w_ns}}}sz')
  952. sz1.set(f'{{{w_ns}}}val', str(int(SIZE_PAGE_NUM * 2)))
  953. rFonts1 = etree.SubElement(rPr1, f'{{{w_ns}}}rFonts')
  954. rFonts1.set(f'{{{w_ns}}}ascii', FONT_WESTERN)
  955. rFonts1.set(f'{{{w_ns}}}hAnsi', FONT_WESTERN)
  956. fld_begin = etree.SubElement(run1, f'{{{w_ns}}}fldChar')
  957. fld_begin.set(f'{{{w_ns}}}fldCharType', 'begin')
  958. # run 2: instrText
  959. run2 = etree.SubElement(para, f'{{{w_ns}}}r')
  960. instr = etree.SubElement(run2, f'{{{w_ns}}}instrText')
  961. instr.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
  962. instr.text = ' PAGE '
  963. # run 3: fldChar separate(Word 域标准结构:begin→instr→separate→结果→end,
  964. # 缺 separate 时部分查看器不显示域结果)
  965. run3 = etree.SubElement(para, f'{{{w_ns}}}r')
  966. fld_sep = etree.SubElement(run3, f'{{{w_ns}}}fldChar')
  967. fld_sep.set(f'{{{w_ns}}}fldCharType', 'separate')
  968. # run 4: 域结果占位(Word 打开时自动刷新为实际页码)
  969. run4 = etree.SubElement(para, f'{{{w_ns}}}r')
  970. rPr4 = etree.SubElement(run4, f'{{{w_ns}}}rPr')
  971. sz4 = etree.SubElement(rPr4, f'{{{w_ns}}}sz')
  972. sz4.set(f'{{{w_ns}}}val', str(int(SIZE_PAGE_NUM * 2)))
  973. rFonts4 = etree.SubElement(rPr4, f'{{{w_ns}}}rFonts')
  974. rFonts4.set(f'{{{w_ns}}}ascii', FONT_WESTERN)
  975. rFonts4.set(f'{{{w_ns}}}hAnsi', FONT_WESTERN)
  976. t4 = etree.SubElement(run4, f'{{{w_ns}}}t')
  977. t4.text = '1'
  978. # run 5: fldChar end
  979. run5 = etree.SubElement(para, f'{{{w_ns}}}r')
  980. fld_end = etree.SubElement(run5, f'{{{w_ns}}}fldChar')
  981. fld_end.set(f'{{{w_ns}}}fldCharType', 'end')
  982. @staticmethod
  983. def _remove_page_fields(root, w_ns: str):
  984. """移除已有的所有 PAGE 域代码"""
  985. runs_to_remove = []
  986. for run_elem in root.iter(f'{{{w_ns}}}r'):
  987. has_fldchar = False
  988. has_page = False
  989. for child in run_elem:
  990. tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
  991. if tag == 'fldChar':
  992. has_fldchar = True
  993. if tag == 'instrText' and child.text and 'PAGE' in child.text.upper():
  994. has_page = True
  995. if has_fldchar or has_page:
  996. runs_to_remove.append(run_elem)
  997. for run_elem in runs_to_remove:
  998. parent = run_elem.getparent()
  999. if parent is not None:
  1000. parent.remove(run_elem)
  1001. def _add_text_run(self, para, text: str, size_pt: float, font_name: str, w_ns: str):
  1002. """向 XML 段落添加一个文本 run"""
  1003. run = etree.SubElement(para, f'{{{w_ns}}}r')
  1004. rPr = etree.SubElement(run, f'{{{w_ns}}}rPr')
  1005. sz = etree.SubElement(rPr, f'{{{w_ns}}}sz')
  1006. sz.set(f'{{{w_ns}}}val', str(int(size_pt * 2)))
  1007. rFonts = etree.SubElement(rPr, f'{{{w_ns}}}rFonts')
  1008. rFonts.set(f'{{{w_ns}}}eastAsia', font_name)
  1009. rFonts.set(f'{{{w_ns}}}ascii', FONT_WESTERN)
  1010. rFonts.set(f'{{{w_ns}}}hAnsi', FONT_WESTERN)
  1011. t = etree.SubElement(run, f'{{{w_ns}}}t')
  1012. t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
  1013. t.text = text
  1014. # ================================================================
  1015. # Phase 7: 自动目录(TOC)
  1016. # ================================================================
  1017. def _insert_toc(self, doc: DocxDocument, project_name: str):
  1018. """在文档开头插入 Word 自动目录
  1019. 目录在封面之后,正文之前。
  1020. 使用 Word TOC 域代码实现自动生成。
  1021. V3.1 修复:模板中的静态"目录"文字页无法自动更新。
  1022. 现在会先找到第一个真实章节,移除该位置之前的所有静态目录内容,
  1023. 然后在同一位置插入 TOC 域代码。
  1024. """
  1025. body = doc.element.body
  1026. w_ns = WML_NS
  1027. # ---- 检查是否已有 TOC 域代码 ----
  1028. existing_toc_field = self._find_existing_toc(body, w_ns)
  1029. if existing_toc_field:
  1030. logger.info(
  1031. "模板已有 TOC 域代码,跳过重复插入"
  1032. "(请在 Word 中右键目录 → 更新域以刷新)"
  1033. )
  1034. return
  1035. # ---- 找到正文起点的位置:第一个分区标记(商务部分/技术部分)或第一个"第X章" ----
  1036. # 架构规范:封面 → 目录(TOC) → 商务部分标题 → 商务章节 → 技术部分标题 → 技术章节。
  1037. # 分区标题("商务部分")位于其下章节之前,TOC 必须插在分区标题之前,
  1038. # 否则目录会出现在"商务部分"之后,破坏整体架构顺序。
  1039. insert_before = None
  1040. chapter_re = re.compile(r"^第\s*[一二三四五六七八九十\d]+\s*章")
  1041. part_markers = {"商务部分", "技术部分", "附件", "附录"}
  1042. for para in doc.paragraphs:
  1043. text = para.text.strip()
  1044. if text in part_markers or chapter_re.match(text):
  1045. insert_before = para._element
  1046. break
  1047. if insert_before is None:
  1048. logger.warning("未找到章节起始位置,跳过目录插入")
  1049. return
  1050. # ---- 移除第一个章节之前的静态目录内容 ----
  1051. # 在 insert_before 之前的所有段落中,找到"目录"标题及后续的目录行,
  1052. # 全部移除(它们无法自动更新,且会干扰 TOC 域代码的位置)
  1053. _remove_static_toc_content(doc, insert_before)
  1054. # ---- 插入分页符 ----
  1055. page_break_para = OxmlElement('w:p')
  1056. page_break_run = OxmlElement('w:r')
  1057. page_break = OxmlElement('w:br')
  1058. page_break.set(qn('w:type'), 'page')
  1059. page_break_run.append(page_break)
  1060. page_break_para.append(page_break_run)
  1061. insert_before.addprevious(page_break_para)
  1062. # ---- 目录标题 ----
  1063. toc_title_para = OxmlElement('w:p')
  1064. toc_title_pPr = OxmlElement('w:pPr')
  1065. toc_title_jc = OxmlElement('w:jc')
  1066. toc_title_jc.set(qn('w:val'), 'center')
  1067. toc_title_pPr.append(toc_title_jc)
  1068. toc_title_para.append(toc_title_pPr)
  1069. toc_title_run = OxmlElement('w:r')
  1070. toc_title_rPr = OxmlElement('w:rPr')
  1071. toc_title_sz = OxmlElement('w:sz')
  1072. toc_title_sz.set(qn('w:val'), str(int(SIZE_TOC_TITLE * 2)))
  1073. toc_title_rPr.append(toc_title_sz)
  1074. toc_title_b = OxmlElement('w:b')
  1075. toc_title_rPr.append(toc_title_b)
  1076. toc_title_rFonts = OxmlElement('w:rFonts')
  1077. toc_title_rFonts.set(qn('w:eastAsia'), FONT_HEADING)
  1078. toc_title_rFonts.set(qn('w:ascii'), FONT_WESTERN)
  1079. toc_title_rFonts.set(qn('w:hAnsi'), FONT_WESTERN)
  1080. toc_title_rPr.append(toc_title_rFonts)
  1081. toc_title_run.append(toc_title_rPr)
  1082. toc_title_t = OxmlElement('w:t')
  1083. toc_title_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
  1084. toc_title_t.text = '目 录'
  1085. toc_title_run.append(toc_title_t)
  1086. toc_title_para.append(toc_title_run)
  1087. insert_before.addprevious(toc_title_para)
  1088. # ---- TOC 域代码 ----
  1089. toc_para = OxmlElement('w:p')
  1090. # TOC field: begin
  1091. toc_run1 = OxmlElement('w:r')
  1092. toc_fld_begin = OxmlElement('w:fldChar')
  1093. toc_fld_begin.set(qn('w:fldCharType'), 'begin')
  1094. toc_run1.append(toc_fld_begin)
  1095. toc_para.append(toc_run1)
  1096. # TOC field: instrText
  1097. toc_run2 = OxmlElement('w:r')
  1098. toc_instr = OxmlElement('w:instrText')
  1099. toc_instr.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
  1100. # TOC 指令:根据 Heading 1-4 生成目录(用户规范:目录最多展示 4 级),
  1101. # 显示页码右对齐
  1102. toc_instr.text = (
  1103. ' TOC \\o "1-4" \\h \\z \\u '
  1104. '\\t "Heading 1,1,Heading 2,2,Heading 3,3,Heading 4,4" '
  1105. )
  1106. toc_run2.append(toc_instr)
  1107. toc_para.append(toc_run2)
  1108. # TOC field: separator
  1109. toc_run3 = OxmlElement('w:r')
  1110. toc_fld_sep = OxmlElement('w:fldChar')
  1111. toc_fld_sep.set(qn('w:fldCharType'), 'separate')
  1112. toc_run3.append(toc_fld_sep)
  1113. toc_para.append(toc_run3)
  1114. # TOC placeholder text
  1115. toc_run4 = OxmlElement('w:r')
  1116. toc_run5 = OxmlElement('w:r')
  1117. toc_placeholder = OxmlElement('w:t')
  1118. toc_placeholder.text = '(请在 Word 中右键目录 → 更新域 以生成目录)'
  1119. toc_run4.append(toc_placeholder)
  1120. toc_para.append(toc_run4)
  1121. # TOC field: end
  1122. toc_fld_end = OxmlElement('w:fldChar')
  1123. toc_fld_end.set(qn('w:fldCharType'), 'end')
  1124. toc_run5.append(toc_fld_end)
  1125. toc_para.append(toc_run5)
  1126. insert_before.addprevious(toc_para)
  1127. # ---- 目录后插入分页符 ----
  1128. page_break_para2 = OxmlElement('w:p')
  1129. page_break_run2 = OxmlElement('w:r')
  1130. page_break2 = OxmlElement('w:br')
  1131. page_break2.set(qn('w:type'), 'page')
  1132. page_break_run2.append(page_break2)
  1133. page_break_para2.append(page_break_run2)
  1134. insert_before.addprevious(page_break_para2)
  1135. self._fixed_count += 1
  1136. logger.info("自动目录(TOC)已插入")
  1137. @staticmethod
  1138. def _find_existing_toc(body, w_ns: str) -> bool:
  1139. """检测文档 body 中是否已有 TOC 域代码"""
  1140. for para_elem in body.iter(f'{{{w_ns}}}p'):
  1141. instr_texts = []
  1142. for instr in para_elem.iter(f'{{{w_ns}}}instrText'):
  1143. if instr.text:
  1144. instr_texts.append(instr.text)
  1145. combined = " ".join(instr_texts)
  1146. if "TOC" in combined:
  1147. return True
  1148. return False
  1149. # ================================================================
  1150. # Phase 8: 表格格式统一
  1151. # ================================================================
  1152. def _fix_table_format(
  1153. self,
  1154. doc: DocxDocument,
  1155. protected_table_elements: Optional[set] = None,
  1156. ):
  1157. """表格格式统一
  1158. - 表格内文字:五号宋体(10.5pt)
  1159. - 数字右对齐、文字左对齐
  1160. - 表头自动检测并加粗(支持多行表头)
  1161. - 边框样式保持一致
  1162. """
  1163. for table in doc.tables:
  1164. if table._element in (protected_table_elements or set()):
  1165. continue
  1166. if not table.rows:
  1167. continue
  1168. # ---- 检测真实表头行数 ----
  1169. # 策略:连续加粗的行视为表头(支持多行表头)
  1170. header_row_count = 0
  1171. for row_idx, row in enumerate(table.rows):
  1172. # 检查该行是否有加粗且非空的单元格
  1173. bold_cells = 0
  1174. non_empty_cells = 0
  1175. for cell in row.cells:
  1176. cell_text = cell.text.strip()
  1177. if cell_text:
  1178. non_empty_cells += 1
  1179. for para in cell.paragraphs:
  1180. for run in para.runs:
  1181. if run.bold and cell_text:
  1182. bold_cells += 1
  1183. break
  1184. # 如果该行超过一半的非空单元格是加粗的 → 表头行
  1185. if non_empty_cells > 0 and bold_cells >= non_empty_cells * 0.4:
  1186. header_row_count = row_idx + 1
  1187. else:
  1188. break # 遇到非表头行,停止
  1189. # ---- LLM 复核:首行是否真的是列名 ----
  1190. # 只有首行存在较长正文式单元格(>20 字)时才调用 LLM 判断,
  1191. # 避免对每张表都产生一次调用;判断为正文则不再当表头加粗。
  1192. llm_denied_header = False
  1193. if header_row_count > 0 and table.rows:
  1194. first_row_texts = [
  1195. c.text.strip() for c in table.rows[0].cells
  1196. ]
  1197. if any(len(t) > 20 for t in first_row_texts):
  1198. judged = _llm_is_header_row(first_row_texts)
  1199. if judged is False:
  1200. header_row_count = 0
  1201. llm_denied_header = True
  1202. logger.info(
  1203. f" LLM 判定表首行非列名,按正文样式处理: "
  1204. f"{' | '.join(t[:14] for t in first_row_texts[:3])}"
  1205. )
  1206. # 如果未检测到表头(无加粗),默认第一行为表头
  1207. if header_row_count == 0 and not llm_denied_header:
  1208. header_row_count = 1
  1209. for row_idx, row in enumerate(table.rows):
  1210. is_header = row_idx < header_row_count
  1211. for cell in row.cells:
  1212. cell_text = cell.text.strip()
  1213. # ---- 多段落单元格合并:单元格内多个 <w:p> 合并为一段 ----
  1214. # 参考投书/PDF 提取的表格常在单元格内产生多个段落(如
  1215. # “规定”+“分值”被拆成两段),Word 中显示为换行。这里把
  1216. # 后续段落的文本并入第一段,保留第一段格式。
  1217. _merge_cell_paragraphs(cell)
  1218. for para in cell.paragraphs:
  1219. for run in para.runs:
  1220. if self._run_has_image(run):
  1221. continue
  1222. # 字号:表头 10.5pt 加粗,数据行 10.5pt
  1223. if run.font.size and abs(run.font.size.pt - SIZE_TABLE_NOTE) > 0.5:
  1224. run.font.size = Pt(SIZE_TABLE_NOTE)
  1225. elif not run.font.size:
  1226. run.font.size = Pt(SIZE_TABLE_NOTE)
  1227. # 字体:宋体
  1228. run.font.name = FONT_BODY
  1229. self._set_east_asian_font(run, FONT_BODY)
  1230. # 表头加粗
  1231. if is_header and not run.bold:
  1232. run.bold = True
  1233. # 对齐:纯数字 → 右对齐;其他 → 左对齐
  1234. if cell_text and re.match(r'^[\d,.,。、%%¥¥\s]+$', cell_text):
  1235. para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
  1236. else:
  1237. para.alignment = WD_ALIGN_PARAGRAPH.LEFT
  1238. # 单元格级对齐(tcPr/w:jc)同步:有些表格的居中是在
  1239. # 单元格属性上而非段落属性,必须一并清除/覆盖。
  1240. _set_cell_align_by_content(cell, cell_text)
  1241. # 正文句子去加粗(模板正文常自带加粗,按规范正文不加粗)
  1242. try:
  1243. fixed_bold = _debold_body_sentences(doc)
  1244. if fixed_bold > 0:
  1245. self._fixed_count += fixed_bold
  1246. except Exception as e:
  1247. logger.debug(f"正文句子去加粗失败: {e}")
  1248. # 表格宽度统一:横向填满页面可用宽度,且不超出页面
  1249. try:
  1250. section = doc.sections[0]
  1251. content_width = int(
  1252. (
  1253. section.page_width
  1254. - section.left_margin
  1255. - section.right_margin
  1256. )
  1257. / 635 # EMU → dxa
  1258. )
  1259. generated_tables = [
  1260. table for table in doc.tables
  1261. if table._element not in (protected_table_elements or set())
  1262. ]
  1263. fixed_width = _normalize_table_widths_for_tables(
  1264. generated_tables, content_width
  1265. )
  1266. if fixed_width > 0:
  1267. self._fixed_count += fixed_width
  1268. logger.info(
  1269. f"表格宽度归一化: {fixed_width} 个表格调整到页面宽度"
  1270. )
  1271. except Exception as e:
  1272. logger.debug(f"表格宽度归一化失败: {e}")
  1273. self._fixed_count += 1
  1274. # ================================================================
  1275. # Phase 9: 禁用格式清理
  1276. # ================================================================
  1277. def _remove_forbidden_formatting(
  1278. self,
  1279. doc: DocxDocument,
  1280. protected_table_elements: Optional[set] = None,
  1281. protected_paragraph_elements: Optional[set] = None,
  1282. ):
  1283. """移除禁用格式
  1284. 禁用:艺术字体、倾斜、下划线、着重号、阴影文字
  1285. 注意:下划线必须保留——模板/招标文件中的关键字段(公司名称、
  1286. 金额、日期、签章区等)使用下划线标记,便于人工检查与手写填充;
  1287. && 占位符会在占位符清理阶段转换为下划线。
  1288. """
  1289. for para in doc.paragraphs:
  1290. if para._element in (protected_paragraph_elements or set()):
  1291. continue
  1292. for run in para.runs:
  1293. if self._run_has_image(run):
  1294. continue
  1295. # 斜体
  1296. if run.font.italic:
  1297. run.font.italic = False
  1298. # 阴影
  1299. try:
  1300. rPr = run._element.find(qn('w:rPr'))
  1301. if rPr is not None:
  1302. for tag in ('w:shadow', 'w:emboss', 'w:imprint', 'w:dstrike'):
  1303. elem = rPr.find(qn(tag))
  1304. if elem is not None:
  1305. rPr.remove(elem)
  1306. except Exception:
  1307. pass
  1308. # 表格内同样处理
  1309. for table in doc.tables:
  1310. if table._element in (protected_table_elements or set()):
  1311. continue
  1312. for row in table.rows:
  1313. for cell in row.cells:
  1314. for para in cell.paragraphs:
  1315. for run in para.runs:
  1316. if run.font.italic:
  1317. run.font.italic = False
  1318. self._fixed_count += 1
  1319. # ================================================================
  1320. # Phase 10: 占位符兜底清理
  1321. # ================================================================
  1322. def _cleanup_placeholders(self, doc: DocxDocument):
  1323. """清理残留占位符:&& 替换为下划线,报告未解析的 %%...%%"""
  1324. placeholder_re = re.compile(r"%%(.+?)%%")
  1325. and_fixed = 0
  1326. unresolved = []
  1327. def _process_paragraphs(paragraphs, location: str = ""):
  1328. nonlocal and_fixed
  1329. for para in paragraphs:
  1330. for run in para.runs:
  1331. if self._run_has_image(run):
  1332. continue
  1333. # && → _
  1334. if "&&" in (run.text or ""):
  1335. run.text = run.text.replace("&&", "_")
  1336. and_fixed += 1
  1337. # 收集 %%...%%
  1338. for match in placeholder_re.finditer(run.text or ""):
  1339. key = match.group(1).strip()
  1340. if key not in unresolved:
  1341. unresolved.append(f"[{location}] %%{key}%%")
  1342. _process_paragraphs(doc.paragraphs, "正文")
  1343. for table_idx, table in enumerate(doc.tables):
  1344. for row in table.rows:
  1345. for cell in row.cells:
  1346. _process_paragraphs(cell.paragraphs, f"表格{table_idx}")
  1347. for section in doc.sections:
  1348. if section.header:
  1349. _process_paragraphs(section.header.paragraphs, "页眉")
  1350. if section.footer:
  1351. _process_paragraphs(section.footer.paragraphs, "页脚")
  1352. if and_fixed > 0:
  1353. self._fixed_count += and_fixed
  1354. if unresolved:
  1355. self._warnings.append(
  1356. f"未解析占位符 ({len(unresolved)} 处): "
  1357. + ", ".join(u[-50:] for u in unresolved[:5])
  1358. )
  1359. # ================================================================
  1360. # 工具方法
  1361. # ================================================================
  1362. @staticmethod
  1363. def _run_has_image(run) -> bool:
  1364. """检测 run 中是否包含图片"""
  1365. try:
  1366. for child in run._element:
  1367. tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
  1368. if tag in ('drawing', 'pict'):
  1369. return True
  1370. except Exception:
  1371. pass
  1372. return False
  1373. @staticmethod
  1374. def _get_east_asian_font(run) -> Optional[str]:
  1375. """获取东亚字体"""
  1376. try:
  1377. rPr = run._element.find(qn('w:rPr'))
  1378. if rPr is not None:
  1379. rFonts = rPr.find(qn('w:rFonts'))
  1380. if rFonts is not None:
  1381. return rFonts.get(qn('w:eastAsia'))
  1382. except Exception:
  1383. pass
  1384. return None
  1385. @staticmethod
  1386. def _set_east_asian_font(run, font_name: str):
  1387. """设置东亚字体"""
  1388. try:
  1389. rPr = run._element.find(qn('w:rPr'))
  1390. if rPr is None:
  1391. rPr = OxmlElement('w:rPr')
  1392. run._element.insert(0, rPr)
  1393. rFonts = rPr.find(qn('w:rFonts'))
  1394. if rFonts is None:
  1395. rFonts = OxmlElement('w:rFonts')
  1396. rPr.insert(0, rFonts)
  1397. rFonts.set(qn('w:eastAsia'), font_name)
  1398. except Exception:
  1399. pass
  1400. # ============================================================
  1401. # 模块级回退函数
  1402. # ============================================================
  1403. def _build_fallback_heading_analysis(doc: DocxDocument) -> "HeadingAnalysis":
  1404. """正则回退方案:当 LLM 标题分析不可用时,用正则检测全文档标题
  1405. 检测规则(与用户规范严格对齐):
  1406. Heading 1: "第X章 ..." → 章标题
  1407. Heading 2: "一、二、三、..." → 一级节标题
  1408. Heading 3: "(一)(二)(三)..." → 二级节标题
  1409. Heading 4: "1. 2. 3. ..." → 三级节标题
  1410. Heading 5: "(1) (2) (3) ..." → 四级节标题
  1411. Heading 6: "1) 2) 3) ..." 或 "a. b. c. ..." → 五级/六级
  1412. 排除规则:
  1413. - 过长文本(>80字)视为正文而非标题
  1414. - 标点结尾视为正文
  1415. - 章节内已通过 _detect_heading_level 回退
  1416. """
  1417. from step6_exporting.heading_analyzer import HeadingAnalysis, HeadingInfo
  1418. all_paras = list(doc.paragraphs)
  1419. headings = []
  1420. # 各级标题正则(按优先级从高到低)
  1421. _PATTERNS = [
  1422. # (Word Heading level, regex, max_length)
  1423. (1, re.compile(r"^第\s*[一二三四五六七八九十\d]+\s*章[::\s、]*(.*)"), 80),
  1424. (2, re.compile(r"^[一二三四五六七八九十]+[、,]\s*\S"), 80),
  1425. (3, re.compile(r"^[((][一二三四五六七八九十]+[))]\s*\S"), 60),
  1426. (4, re.compile(r"^\d+[\.、]\s*\S"), 55),
  1427. ]
  1428. # 分区标记
  1429. part_markers = {"商务部分", "技术部分", "附件", "附录"}
  1430. for para_idx, para in enumerate(all_paras):
  1431. text = para.text.strip()
  1432. if not text:
  1433. continue
  1434. # 字段标签行("xxx:"结尾)或完整句子结尾(句号/逗号/分号等)不是标题;
  1435. # 字段名:值 的数据行也不是标题
  1436. if text.endswith((":", ":")):
  1437. continue
  1438. if re.search(r"[。;,、!?]\s*$", text):
  1439. continue
  1440. if re.match(r"^[^::]*[::]\s*\S", text):
  1441. continue
  1442. # 检查分区标记
  1443. if text in part_markers:
  1444. headings.append(HeadingInfo(
  1445. para_index=para_idx, level=1,
  1446. title=text, full_text=text,
  1447. detection_method="fallback_part", confidence=0.9,
  1448. ))
  1449. continue
  1450. # 按优先级匹配标题模式
  1451. for level, pattern, max_len in _PATTERNS:
  1452. m = pattern.match(text)
  1453. if not m:
  1454. continue
  1455. # 提取标题内容
  1456. if level == 1:
  1457. title = m.group(1).strip() or text
  1458. else:
  1459. prefix_end = m.end()
  1460. title = text[prefix_end:].strip()
  1461. # 排除规则
  1462. if len(text) > max_len:
  1463. continue
  1464. if len(title) < 2:
  1465. continue
  1466. if title and title[-1] in ",。;:、!?,…;:!?":
  1467. continue
  1468. if ":" in title or ":" in title:
  1469. continue # 含冒号的通常是数据列举(如"1、单位名称:xxx"),不是标题
  1470. headings.append(HeadingInfo(
  1471. para_index=para_idx,
  1472. level=level,
  1473. title=title,
  1474. full_text=text,
  1475. detection_method="fallback_regex",
  1476. confidence=0.7,
  1477. ))
  1478. break # 匹配到最高优先级模式后不再继续
  1479. analysis = HeadingAnalysis(
  1480. headings=headings,
  1481. total_paragraphs=len(all_paras),
  1482. heading_count=len(headings),
  1483. )
  1484. logger.info(
  1485. f"正则回退标题检测完成: {analysis.heading_count} 个标题 / "
  1486. f"{analysis.total_paragraphs} 个段落"
  1487. )
  1488. # 统计各级标题
  1489. level_counts: Dict[int, int] = {}
  1490. for h in headings:
  1491. if h.level > 0:
  1492. level_counts[h.level] = level_counts.get(h.level, 0) + 1
  1493. for lv in sorted(level_counts):
  1494. logger.info(f" Heading {lv}: {level_counts[lv]} 个")
  1495. return analysis
  1496. def _remove_static_toc_content(doc: DocxDocument, first_chapter_element) -> int:
  1497. """移除第一个真实章节之前的所有静态目录内容
  1498. 静态目录页("目录"标题 + "第一章 xxx...1" 等内容行)
  1499. 无法自动更新,必须被 TOC 域代码替代。
  1500. 策略:
  1501. 1. 在 first_chapter_element 之前找到"目录"/"目 录"段落
  1502. 2. 将"目录"标题及其之后、first_chapter_element 之前的所有
  1503. 目录内容行(匹配章节行或短行)一并移除
  1504. 3. 在 first_chapter_element 之前插入分页符(为 TOC 留页面)
  1505. Args:
  1506. doc: DOCX 文档
  1507. first_chapter_element: 第一个真实章节的 XML 元素
  1508. Returns:
  1509. 移除的段落数量
  1510. """
  1511. body = doc.element.body
  1512. body_children = list(body)
  1513. # 找到 first_chapter_element 在 body 中的位置
  1514. try:
  1515. chapter_pos = body_children.index(first_chapter_element)
  1516. except ValueError:
  1517. return 0
  1518. # 在 chapter 之前查找"目录"标题
  1519. toc_title_pos = -1
  1520. for i in range(chapter_pos - 1, -1, -1):
  1521. child = body_children[i]
  1522. if child.tag.endswith('}p'):
  1523. text = _extract_paragraph_element_text(child).strip()
  1524. if text in ('目录', '目 录'):
  1525. toc_title_pos = i
  1526. break
  1527. if toc_title_pos < 0:
  1528. return 0 # 没有静态目录,无需清理
  1529. # 移除"目录"标题及其后的静态目录内容行;
  1530. # 遇到非目录内容(如索引表章节标题、委托书正文)立即停止,避免误删真实内容。
  1531. removed = 0
  1532. for i in range(toc_title_pos, chapter_pos):
  1533. child = body_children[i]
  1534. if not child.tag.endswith('}p'):
  1535. break # 目录页不含表格,遇到表格说明已到真实内容区
  1536. # 分节符段落(pPr 内含 w:sectPr)不能删:删除会合并节,
  1537. # 并使该节的页眉/页脚引用丢失(页眉页码消失)。
  1538. pPr = child.find(qn('w:pPr'))
  1539. if pPr is not None and pPr.find(qn('w:sectPr')) is not None:
  1540. continue
  1541. text = _extract_paragraph_element_text(child).strip()
  1542. if i > toc_title_pos and not _is_static_toc_line(text):
  1543. break # 非目录内容(索引表标题等)→ 保留,后续段落也不再删
  1544. body.remove(child)
  1545. removed += 1
  1546. if removed > 0:
  1547. logger.info(
  1548. f"静态目录内容已移除: {removed} 个段落 "
  1549. f"(位置 {toc_title_pos}→{chapter_pos - 1})"
  1550. )
  1551. return removed
  1552. def _extract_paragraph_element_text(para_elem) -> str:
  1553. """从 XML 段落元素中提取纯文本"""
  1554. texts = []
  1555. for node in para_elem.iter():
  1556. tag = node.tag.split('}')[-1] if '}' in node.tag else node.tag
  1557. if tag == 't' and node.text:
  1558. texts.append(node.text)
  1559. return ''.join(texts)
  1560. def _is_static_toc_line(text: str) -> bool:
  1561. """判断段落文本是否像静态目录内容行
  1562. 静态目录行特征(任一命中即视为目录内容):
  1563. - 以"第X章"开头(目录章节行)
  1564. - 含点线(…、……、....)
  1565. - 以页码结尾且行较短
  1566. - 短行且含"目录"(目录页说明/提示行)
  1567. 非目录行(如索引表章节标题、授权委托书正文)返回 False,
  1568. 用于 _remove_static_toc_content 的截断判断,防止误删真实内容。
  1569. """
  1570. t = text.strip()
  1571. if not t:
  1572. return True # 空行属于目录页排版
  1573. if re.match(r"^第\s*[一二三四五六七八九十百\d]+\s*章", t):
  1574. return True
  1575. if "…" in t or "……" in t or "...." in t:
  1576. return True
  1577. if re.search(r"\d+\s*页?$", t) and len(t) <= 60:
  1578. return True
  1579. if len(t) <= 30 and "目录" in t:
  1580. return True
  1581. return False
  1582. __all__ = ["FormatApplier"]