| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842 |
- """
- DOCX 全面格式后处理模块(V3 新增)
- 在 LLM 标题层级分析完成后,对 DOCX 文档进行全面的格式规范化处理。
- 处理流程:
- 1. 应用 LLM 分析得出的标题层级(设置 Word Heading 样式)
- 2. 统一页面设置(页边距)
- 3. 统一字体/字号/颜色
- 4. 统一段落格式(行距、缩进、间距)
- 5. 设置页眉/页脚/页码
- 6. 插入自动目录(TOC)
- 7. 表格格式统一
- 8. 清理禁用格式
- 9. 占位符兜底清理
- 所有格式规范严格遵循用户定义的标书格式标准。
- """
- from __future__ import annotations
- import logging
- import re
- from typing import Dict, List, Optional, Set, Tuple
- from docx import Document as DocxDocument
- from docx.shared import Pt, Cm, Inches, Emu, RGBColor
- from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
- from docx.oxml.ns import qn
- from docx.oxml import OxmlElement
- from lxml import etree
- from step6_exporting.heading_analyzer import HeadingAnalysis, HeadingInfo
- logger = logging.getLogger(__name__)
- # ============================================================
- # 格式规范常量(严格按用户要求)
- # ============================================================
- # ---- 页面设置 ----
- MARGIN_TOP_CM = 2.54
- MARGIN_BOTTOM_CM = 2.54
- MARGIN_LEFT_CM = 2.5
- MARGIN_RIGHT_CM = 2.5
- # ---- 字号(pt)- 中文号数映射 ----
- SIZE_COVER_TITLE = 22 # 二号
- SIZE_H1 = 15 # 小三号(一级标题:一、二、三)
- SIZE_H2 = 14 # 四号(二级标题:(一)(二))
- SIZE_H3 = 12 # 小四号(三级标题:1. 2. 3.)
- SIZE_BODY = 12 # 小四号(正文)
- SIZE_TABLE_NOTE = 10.5 # 五号(表格、注释、备注)
- SIZE_PAGE_NUM = 10.5 # 五号(页码)
- SIZE_TOC_TITLE = 22 # 二号(目录标题)
- SIZE_TOC_L1 = 14 # 四号(目录一级)
- SIZE_TOC_L2 = 12 # 小四号(目录二级)
- # ---- 字体 ----
- FONT_BODY = "宋体"
- FONT_HEADING = "黑体"
- FONT_WESTERN = "Times New Roman"
- ALLOWED_FONTS = {FONT_BODY, FONT_HEADING, FONT_WESTERN}
- # ---- 行距 ----
- LINE_SPACING_BODY = 1.5
- # ---- 段落 ----
- BODY_INDENT_CHARS = 2 # 正文首行缩进 2 字符
- BODY_INDENT_PT = Pt(24) # 小四号 2 字符 ≈ 24pt
- # ---- Word 命名空间 ----
- WML_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
- def _debold_body_sentences(doc: DocxDocument) -> int:
- """正文(非标题)段落中的完整句子取消加粗。
- 模板正文本身常把完整句子设为加粗(如"我司有针对性制定方案…"、
- "1、以体系贯彻…"),导出保留模板原布局时原样带入。按规范正文
- 不加粗,只保留短标题/表名/附件标记/证书名称行的加粗。
- """
- fixed = 0
- for p in doc.paragraphs:
- pEl = p._element
- pPr = pEl.find(qn("w:pPr"))
- style = ""
- if pPr is not None:
- ps = pPr.find(qn("w:pStyle"))
- if ps is not None:
- style = (ps.get(qn("w:val")) or "").replace(" ", "").lower()
- if style.startswith("heading"):
- continue
- text = p.text.strip()
- if len(text) < 15:
- continue
- # 仅处理"完整句子"(含句子标点),短标签/标题不加粗处理不干预
- if not re.search(r"[,。;:,]", text):
- continue
- # 标题/表名/附件标记/证书名称行保留加粗
- if re.match(r"^(附:|附件|附注|附:)", text):
- continue
- if re.search(r"(表|单|函|声明|承诺|证书|制度)$", text):
- continue
- if re.match(r"^[((][一二三四五六七八九十百]+[))]", text):
- continue
- n = 0
- for r in p.runs:
- if r.bold:
- r.bold = False
- n += 1
- if n:
- fixed += 1
- if fixed:
- logger.info(f"正文句子去加粗: {fixed} 个段落")
- return fixed
- def _normalize_table_widths(doc: DocxDocument, content_width_dxa: int) -> int:
- """将文档中所有表格宽度统一缩放到页面可用宽度(横向填满,且不超页)。
- 处理两类问题:
- - 表格过窄(如从 PDF 提取的报价表只有 5000 dxa,未横向填充);
- - 表格过宽(如参考投书中的 20 列会议登记表宽达 14000 dxa,超出页面)。
- 规则:仅当表格总宽与可用宽度偏差超过容差(95%~105%)时按比例缩放
- gridCol 与每个单元格 tcW。
- """
- return _normalize_table_widths_for_tables(doc.tables, content_width_dxa)
- def _normalize_table_widths_for_tables(tables, content_width_dxa: int) -> int:
- """只归一化明确传入的表格,避免触碰模板原生表格。"""
- if content_width_dxa <= 0:
- return 0
- fixed = 0
- low = int(content_width_dxa * 0.95)
- high = int(content_width_dxa * 1.05)
- for tbl in tables:
- tbl_el = tbl._element
- grid = tbl_el.find(qn("w:tblGrid"))
- if grid is None:
- continue
- cols = grid.findall(qn("w:gridCol"))
- if not cols:
- continue
- widths = []
- for gc in cols:
- w = gc.get(qn("w:w"))
- try:
- widths.append(int(w or 0))
- except (TypeError, ValueError):
- widths.append(0)
- total = sum(widths)
- if total <= 0:
- continue
- # tblW 与 gridCol 不一致:模板表格常保留小的 tblW(如 5000 dxa)
- # 而 gridCol 已被填充到接近页宽。此时即使 gridTotal 在容差内,
- # 只要 tblW 明显偏小(< 90% 页宽),也必须把 tblW 校正为页宽,
- # 否则 Word 按 tblW 渲染导致表格不横向拓展。
- tblW_el = None
- tblPr = tbl_el.find(qn("w:tblPr"))
- if tblPr is not None:
- tblW_el = tblPr.find(qn("w:tblW"))
- tblw_val = None
- if tblW_el is not None:
- try:
- tblw_val = int(tblW_el.get(qn("w:w")) or 0)
- except (TypeError, ValueError):
- tblw_val = None
- tblw_too_small = (
- tblw_val is not None
- and tblw_val > 0
- and tblw_val < content_width_dxa * 0.9
- )
- if low <= total <= high and not tblw_too_small:
- continue
- ratio = content_width_dxa / total
- new_widths = [max(60, int(w * ratio)) for w in widths]
- # 修正取整误差:最后一列吸收差额
- diff = content_width_dxa - sum(new_widths)
- if new_widths:
- new_widths[-1] = max(60, new_widths[-1] + diff)
- # 更新 gridCol
- for gc, w in zip(cols, new_widths):
- gc.set(qn("w:w"), str(w))
- # 更新每个单元格 tcW
- for tr in tbl_el.findall(qn("w:tr")):
- for ci, tc in enumerate(tr.findall(qn("w:tc"))):
- if ci >= len(new_widths):
- break
- tcPr = tc.find(qn("w:tcPr"))
- if tcPr is None:
- tcPr = etree.SubElement(tc, qn("w:tcPr"))
- tcW = tcPr.find(qn("w:tcW"))
- if tcW is None:
- tcW = etree.SubElement(tcPr, qn("w:tcW"))
- tcW.set(qn("w:w"), str(new_widths[ci]))
- tcW.set(qn("w:type"), "dxa")
- # 更新 tblW(tblW=0 的表格也统一设为页宽)
- if tblPr is not None:
- tblW = tblPr.find(qn("w:tblW"))
- if tblW is None:
- tblW = etree.SubElement(tblPr, qn("w:tblW"))
- tblW.set(qn("w:w"), str(content_width_dxa))
- tblW.set(qn("w:type"), "dxa")
- fixed += 1
- logger.debug(
- f" 表格宽度归一化: {total} → {content_width_dxa} dxa "
- f"(列: {new_widths})"
- )
- return fixed
- _HEADER_JUDGE_PROMPT = """你是表格结构识别专家。判断下面表格的"第一行"是否为"列名/表头"(字段名),
- 还是表格的正文数据行。
- 输出严格 JSON:{"is_header": true 或 false, "reason": "一句话理由"}
- 规则:
- 1. 列名通常是短字段名(如"序号、名称、金额、日期、备注"),彼此是并列概念;
- 2. 若第一行是完整句子、长描述、具体数据(人名/数值/大段文字),则是正文;
- 3. 若第一行是多个短字段名,即使后续单元格为空,也算列名;
- 4. 只输出 JSON。"""
- def _llm_is_header_row(first_row_texts: list):
- """LLM 判断表格首行是否为列名;失败返回 None(沿用启发式)"""
- try:
- from llm_client import LLMClient
- llm = LLMClient()
- user = "\n".join(
- f"{i + 1}. {t[:120]}" for i, t in enumerate(first_row_texts)
- )
- result = llm.extract_json(
- system_prompt=_HEADER_JUDGE_PROMPT,
- user_prompt=f"表格第一行单元格内容:\n{user}",
- max_tokens=1024,
- )
- if isinstance(result, dict):
- val = result.get("is_header")
- if val is not None:
- return bool(val)
- except Exception as e:
- logger.debug(f"表首行列名 LLM 判断失败: {e}")
- return None
- def _merge_cell_paragraphs(cell) -> int:
- """合并单元格内的多个段落为一个段落(保留首段格式与全部文本)。
- 参考投书/PDF 提取的表格常在单元格内产生多个 <w:p>,Word 中显示为
- 强制换行(如“规定”“分值”分两段)。投标表格单元格通常应单段自动换行,
- 这里把后续段落文本直接并入首段(不加空格,保持与模板原文一致),
- 并删除多余段落。
- Returns:
- 合并删除的段落数
- """
- paragraphs = list(cell.paragraphs)
- if len(paragraphs) <= 1:
- return 0
- # 合并后总文本超长(如表格内嵌整段说明)时保留原多段结构
- total = sum(len(p.text or "") for p in paragraphs)
- if total > 200:
- return 0
- first = paragraphs[0]
- merged_text = ""
- removed = 0
- for extra in paragraphs[1:]:
- extra_text = extra.text or ""
- if extra_text.strip():
- merged_text += extra_text.strip()
- p_el = extra._element
- parent = p_el.getparent()
- if parent is not None:
- parent.remove(p_el)
- removed += 1
- if merged_text:
- runs = first.runs
- if runs:
- # add_text 会追加到 run 现有文本之后
- runs[-1].add_text(merged_text)
- else:
- first.add_run(merged_text)
- return removed
- def _set_cell_align_by_content(cell, cell_text: str) -> None:
- """按内容设置单元格级对齐(tcPr/w:jc):纯数字右对齐,文字左对齐。
- 部分模板/参考表格的居中位于单元格属性(w:tcPr/w:jc),段落级 jc
- 无法覆盖,必须显式设置/清除单元格级对齐。
- """
- if not cell_text:
- return
- is_numeric = bool(
- re.match(r'^[\d,.,。、%%¥¥\s]+$', cell_text)
- )
- align_val = "right" if is_numeric else "left"
- tcPr = cell._tc.get_or_add_tcPr()
- jc = tcPr.find(qn("w:jc"))
- if jc is None:
- jc = etree.SubElement(tcPr, qn("w:jc"))
- jc.set(qn("w:val"), align_val)
- def _ensure_heading_styles(doc: DocxDocument) -> None:
- """确保文档样式表存在 styleId=Heading1~Heading9 的样式(幂等)
- 模板的 heading 样式 styleId 可能是数字(如 styleId="2"),而
- FormatApplier 以 "Heading{level}"(如 "Heading1")作为 pStyle 引用。
- 样式不存在时 Word 无法识别标题层级(导航窗格/TOC 域全部失效),
- 段落会回退为 Normal 样式显示。
- 注意:python-docx 的 styles.add_style 查重按样式名(大小写不敏感),
- 模板已有 name="heading 1" 时 add_style('Heading 1') 会抛 ValueError。
- 因此直接操作 styles.xml:以模板同名 heading 样式为蓝本,复制出
- styleId=HeadingN / name="Heading N" 的定义(Word 内置样式名,
- TOC 域按此名收集标题)。
- """
- import copy
- from docx.oxml import OxmlElement
- styles_elem = doc.styles.element
- existing_ids = {
- s.get(qn("w:styleId")) for s in styles_elem.findall(qn("w:style"))
- }
- for level in range(1, 10):
- style_id = f"Heading{level}"
- if style_id in existing_ids:
- continue
- # 找模板中同层级的 heading 样式(name="heading N")作为蓝本
- template_style = None
- for s in styles_elem.findall(qn("w:style")):
- name_el = s.find(qn("w:name"))
- if name_el is not None and name_el.get(qn("w:val")) == f"heading {level}":
- template_style = s
- break
- if template_style is None:
- continue
- new_style = copy.deepcopy(template_style)
- new_style.set(qn("w:styleId"), style_id)
- name_el = new_style.find(qn("w:name"))
- if name_el is not None:
- name_el.set(qn("w:val"), f"Heading {level}")
- # 去掉基于/继承引用,避免指向模板数字 ID 样式或缺失样式
- for tag in ("w:basedOn", "w:next", "w:link"):
- el = new_style.find(qn(tag))
- if el is not None:
- new_style.remove(el)
- styles_elem.append(new_style)
- existing_ids.add(style_id)
- logger.debug(f"补充 Heading 样式: {style_id}")
- class FormatApplier:
- """DOCX 全面格式后处理器
- 用法:
- applier = FormatApplier()
- report = applier.apply(doc, heading_analysis, project_name="项目名称")
- """
- def __init__(self):
- self._fixed_count = 0
- self._warnings: List[str] = []
- self._errors: List[str] = []
- # ================================================================
- # 主入口
- # ================================================================
- def apply(
- self,
- doc: DocxDocument,
- heading_analysis: Optional[HeadingAnalysis] = None,
- project_name: str = "",
- part_label: str = "",
- company_name: str = "",
- protected_table_elements: Optional[set] = None,
- protected_paragraph_elements: Optional[set] = None,
- preserve_heading_structure: bool = False,
- ) -> Dict:
- """执行全面格式后处理
- Args:
- doc: 已组装的 DOCX 文档(原地修改)
- heading_analysis: LLM 标题层级分析结果
- project_name: 项目全称(用于页眉)
- part_label: 保留参数(当前不使用,页眉不加标类型标识)
- company_name: 公司名称(用于落款等)
- Returns:
- {"fixed": int, "warnings": [...], "errors": [...]}
- """
- self._fixed_count = 0
- self._warnings = []
- self._errors = []
- # ---- 确保 Heading 样式定义存在(模板可能只有数字 ID 的 heading 样式) ----
- _ensure_heading_styles(doc)
- # ---- 回退:LLM 分析失败时用正则检测标题 ----
- if heading_analysis is None or not heading_analysis.headings:
- logger.warning("LLM 标题分析不可用,启用正则回退方案检测标题")
- heading_analysis = _build_fallback_heading_analysis(doc)
- # ---- Phase 1: 标题层级应用 ----
- if preserve_heading_structure:
- logger.info("模板标题结构已锁定,跳过标题样式重分类")
- else:
- self._apply_heading_styles(doc, heading_analysis)
- # ---- Phase 2: 页面设置 ----
- self._fix_page_setup(doc)
- # ---- Phase 3: 字体/字号/颜色统一 ----
- self._fix_fonts_and_colors(
- doc, heading_analysis,
- protected_table_elements=protected_table_elements,
- protected_paragraph_elements=protected_paragraph_elements,
- )
- # ---- Phase 4: 段落格式统一 ----
- self._fix_paragraph_format(
- doc,
- heading_analysis,
- protected_paragraph_elements=protected_paragraph_elements,
- )
- # ---- Phase 5: 封面标题格式 ----
- self._fix_cover_page(doc, project_name)
- # ---- Phase 6: 页眉/页脚/页码 ----
- self._fix_headers_footers(doc, project_name, part_label)
- # ---- Phase 7: 插入自动目录 ----
- self._insert_toc(doc, project_name)
- # ---- Phase 8: 表格格式统一 ----
- self._fix_table_format(
- doc, protected_table_elements=protected_table_elements
- )
- # ---- Phase 9: 清理禁用格式 ----
- self._remove_forbidden_formatting(
- doc,
- protected_table_elements=protected_table_elements,
- protected_paragraph_elements=protected_paragraph_elements,
- )
- # ---- Phase 10: 占位符兜底清理 ----
- self._cleanup_placeholders(doc)
- logger.info(
- f"格式后处理完成: {self._fixed_count} 项修复, "
- f"{len(self._warnings)} 项警告, {len(self._errors)} 项错误"
- )
- return {
- "fixed": self._fixed_count,
- "warnings": self._warnings,
- "errors": self._errors,
- }
- # ================================================================
- # Phase 1: 标题层级样式应用
- # ================================================================
- def _apply_heading_styles(
- self,
- doc: DocxDocument,
- heading_analysis: Optional[HeadingAnalysis],
- ):
- """根据 LLM 标题分析结果设置 Word Heading 样式 + 显式格式
- 两步保证标题可见性:
- 1. 设置 Word 内置样式(Heading 1 ~ Heading 8)供 TOC 引用
- 2. 直接设置 run 级别的显式格式(字体/字号/加粗),
- 确保即使模板样式定义异常,标题也能在视觉上区别于正文
- 层级格式(严格按用户规范):
- Heading 1 (第X章): 二号 22pt 黑体加粗
- Heading 2 (一、): 小三号 15pt 黑体加粗
- Heading 3 ((一)): 四号 14pt 黑体加粗
- Heading 4 (1.): 小四号 12pt 宋体加粗
- """
- if not heading_analysis or not heading_analysis.headings:
- logger.warning("无标题层级分析结果,跳过标题样式设置")
- return
- # 显式格式映射(确保即使 Word 样式失效也能正确显示)
- _EXPLICIT_FORMAT = {
- 1: (Pt(22), FONT_HEADING, True), # 二号黑体加粗(章标题)
- 2: (Pt(15), FONT_HEADING, True), # 小三号黑体加粗
- 3: (Pt(14), FONT_HEADING, True), # 四号黑体加粗
- 4: (Pt(12), FONT_BODY, True), # 小四号宋体加粗
- 5: (Pt(12), FONT_BODY, True),
- 6: (Pt(12), FONT_BODY, False),
- 7: (Pt(10.5), FONT_BODY, False),
- 8: (Pt(10.5), FONT_BODY, False),
- }
- all_paras = list(doc.paragraphs)
- applied = 0
- for hinfo in heading_analysis.headings:
- if hinfo.para_index >= len(all_paras):
- continue
- if hinfo.level < 1:
- # 被确定性规则降为正文的段落(制度内嵌"第X章"/附件区等):
- # 清除其残留的 Heading 样式,恢复为正文
- para = all_paras[hinfo.para_index]
- try:
- pPr = para._element.find(qn("w:pPr"))
- if pPr is not None:
- pStyle = pPr.find(qn("w:pStyle"))
- if pStyle is not None and (
- pStyle.get(qn("w:val")) or ""
- ).startswith("Heading"):
- self._set_paragraph_style(para, "Normal")
- applied += 1
- except Exception:
- pass
- continue
- para = all_paras[hinfo.para_index]
- text = para.text.strip()
- # 防御:跳过空段落。LLM 越界索引或索引错位可能指向空行,
- # 空段落设为标题样式会形成"没有文字的一级标题"。
- if not text:
- continue
- # 防御:超长段落(>60字)按规范是正文而非标题(LLM 偶发
- # 把长正文误判为标题,这里做最终逻辑拦截)。投标书标题
- # 最长不超过 40 字,60 字上限不会误伤合法标题。
- if len(text) > 60:
- continue
- style_name = f"Heading{hinfo.level}"
- try:
- # 1) 设置 Word 样式(供 TOC 域引用)
- self._set_paragraph_style(para, style_name)
- # 2) 设置显式 run 格式(保证视觉可见)
- size, font, bold = _EXPLICIT_FORMAT.get(
- hinfo.level, (Pt(12), FONT_BODY, False)
- )
- for run in para.runs:
- if self._run_has_image(run):
- continue
- run.font.size = size
- run.font.name = font
- self._set_east_asian_font(run, font)
- run.bold = bold
- # 3) 取消缩进(标题独占一行)
- para.paragraph_format.first_line_indent = None
- # 4) 一级标题(第X章 章标题)段前分页:每个一级标题从新页首行开始
- if hinfo.level == 1:
- para.paragraph_format.page_break_before = True
- applied += 1
- except Exception as e:
- logger.debug(f"设置段落 p{hinfo.para_index} 样式失败: {e}")
- if applied > 0:
- self._fixed_count += applied
- logger.info(f"标题样式已应用: {applied} 个段落(含显式格式)")
- def _set_paragraph_style(self, para, style_name: str):
- """安全设置段落的 Word 样式(通过 XML)"""
- pPr = para._element.find(qn('w:pPr'))
- if pPr is None:
- pPr = OxmlElement('w:pPr')
- para._element.insert(0, pPr)
- pStyle = pPr.find(qn('w:pStyle'))
- if pStyle is None:
- pStyle = OxmlElement('w:pStyle')
- pPr.insert(0, pStyle)
- pStyle.set(qn('w:val'), style_name)
- # ================================================================
- # Phase 2: 页面设置
- # ================================================================
- def _fix_page_setup(self, doc: DocxDocument):
- """统一所有节的页面设置"""
- for section in doc.sections:
- section.top_margin = Cm(MARGIN_TOP_CM)
- section.bottom_margin = Cm(MARGIN_BOTTOM_CM)
- section.left_margin = Cm(MARGIN_LEFT_CM)
- section.right_margin = Cm(MARGIN_RIGHT_CM)
- self._fixed_count += 1
- logger.debug(
- f"页边距统一: 上{MARGIN_TOP_CM}/下{MARGIN_BOTTOM_CM}/"
- f"左{MARGIN_LEFT_CM}/右{MARGIN_RIGHT_CM}cm"
- )
- # ================================================================
- # Phase 3: 字体/字号/颜色统一
- # ================================================================
- def _fix_fonts_and_colors(
- self,
- doc: DocxDocument,
- heading_analysis: Optional[HeadingAnalysis],
- protected_table_elements: Optional[set] = None,
- protected_paragraph_elements: Optional[set] = None,
- ):
- """统一全文字体、字号、颜色
- - 正文:小四号宋体(12pt),西文 Times New Roman
- - 标题:按层级设置(小三/四号/小四黑体加粗)
- - 表格/注释:五号宋体(10.5pt)
- - 文字颜色:全文仅黑色
- - 所有数字/英文:Times New Roman(字号跟随中文)
- """
- heading_levels = {}
- if heading_analysis:
- heading_levels = heading_analysis._index
- # ---- 处理 body 级段落 ----
- # 识别签名/落款区的段落索引(这些区域从最后一个标题向后扫描)
- signature_start_idx = self._find_signature_block_start(doc, heading_levels)
- for para_idx, para in enumerate(doc.paragraphs):
- if para._element in (protected_paragraph_elements or set()):
- continue
- if not para.runs:
- continue
- heading_level = heading_levels.get(para_idx, 0)
- text = para.text or ""
- has_cjk = bool(re.search(r'[一-鿿㐀-䶿]', text))
- # 签名区段落:保留原格式,仅确保字号合理
- is_signature = (
- signature_start_idx > 0 and para_idx >= signature_start_idx
- )
- for run in para.runs:
- if self._run_has_image(run):
- continue
- # ---- 颜色:强制黑色(所有文本) ----
- if run.font.color and run.font.color.rgb:
- run.font.color.rgb = None
- # ---- 字号 ----
- if is_signature:
- # 签名区:保留原字号(通常是四号或小四)
- if run.font.size and run.font.size.pt > 18:
- run.font.size = Pt(SIZE_BODY)
- elif heading_level > 0:
- self._set_heading_font_size(run, heading_level)
- else:
- self._set_body_font(run, has_cjk)
- # ---- 标题加粗 ----
- if heading_level > 0:
- if not run.bold:
- run.bold = True
- # ---- 处理表格内文字 ----
- for table in doc.tables:
- if table._element in (protected_table_elements or set()):
- continue
- for row in table.rows:
- for cell in row.cells:
- for para in cell.paragraphs:
- for run in para.runs:
- if self._run_has_image(run):
- continue
- if run.font.color and run.font.color.rgb:
- run.font.color.rgb = None
- if run.font.size:
- run.font.size = Pt(SIZE_TABLE_NOTE)
- else:
- run.font.size = Pt(SIZE_TABLE_NOTE)
- # 中文→宋体,西文→TNR
- self._set_body_font(run, has_cjk=True)
- self._fixed_count += 1
- @staticmethod
- def _find_signature_block_start(doc, heading_levels: dict) -> int:
- """检测文档中签名/落款区域的起始段落索引
- 从文档末尾向前扫描,找到最后一个标题段落后即为签名区。
- 如果全文都是正文(无标题),返回文档末尾。
- """
- if not heading_levels:
- return len(list(doc.paragraphs))
- # 找到最后一个标题段落
- last_heading_idx = max(heading_levels.keys()) if heading_levels else -1
- if last_heading_idx < 0:
- return len(list(doc.paragraphs))
- # 签名区起始:最后一个标题之后 3 段(跳过可能的空白段)
- all_paras = list(doc.paragraphs)
- sig_start = min(last_heading_idx + 3, len(all_paras))
- # 如果该区域之后的段落数不超过全文的 5%,认为是签名区
- remaining = len(all_paras) - sig_start
- if remaining < max(3, len(all_paras) * 0.05):
- return sig_start
- # 否则只在有明确签名关键词时才标记
- sig_keywords = ["投标人:", "法定代表人:", "日期:", "盖章", "签字"]
- for idx in range(sig_start, len(all_paras)):
- text = all_paras[idx].text.strip()
- if any(kw in text for kw in sig_keywords):
- return idx
- return len(all_paras) # 无签名区
- def _set_heading_font_size(self, run, heading_level: int):
- """按标题层级设置字号和字体
- Heading 层级映射(与用户规范严格对齐):
- Heading 1 → "第X章" 章标题 → 二号 22pt 黑体加粗
- Heading 2 → "一、二、三" → 小三号 15pt 黑体加粗
- Heading 3 → "(一)(二)" → 四号 14pt 黑体加粗
- Heading 4 → "1. 2. 3." → 小四号 12pt 宋体加粗
- Heading 5 → "(1) (2)" → 小四号 12pt 宋体加粗
- Heading 6-8 → 五号 10.5pt 宋体
- """
- size_map = {
- 1: SIZE_COVER_TITLE, # 二号 22pt — 章标题
- 2: SIZE_H1, # 小三号 15pt
- 3: SIZE_H2, # 四号 14pt
- 4: SIZE_H3, # 小四号 12pt
- 5: SIZE_H3, # 小四号 12pt
- 6: SIZE_TABLE_NOTE, # 五号 10.5pt
- 7: SIZE_TABLE_NOTE,
- 8: SIZE_TABLE_NOTE,
- }
- size = size_map.get(heading_level, SIZE_H1)
- if run.font.size:
- run.font.size = Pt(size)
- else:
- run.font.size = Pt(size)
- # 标题字体:Heading 1-3 黑体,Heading 4+ 宋体
- if heading_level <= 3:
- run.font.name = FONT_HEADING
- self._set_east_asian_font(run, FONT_HEADING)
- else:
- run.font.name = FONT_BODY
- self._set_east_asian_font(run, FONT_BODY)
- def _set_body_font(self, run, has_cjk: bool = True):
- """设置正文字体"""
- if not run.font.size:
- run.font.size = Pt(SIZE_BODY)
- if has_cjk:
- run.font.name = FONT_BODY
- self._set_east_asian_font(run, FONT_BODY)
- else:
- run.font.name = FONT_WESTERN
- # ================================================================
- # Phase 4: 段落格式统一
- # ================================================================
- def _fix_paragraph_format(
- self,
- doc: DocxDocument,
- heading_analysis: Optional[HeadingAnalysis],
- protected_paragraph_elements: Optional[set] = None,
- ):
- """统一段落格式
- - 正文:首行缩进 2 字符,1.5 倍行距,段前段后 0
- - 标题:不缩进,独占一行
- - 特殊段落(签名区、表格说明、居中段落):保持原格式,不强制缩进
- """
- heading_levels = {}
- if heading_analysis:
- heading_levels = heading_analysis._index
- # 需要跳过缩进的段落特征
- _SKIP_INDENT_KEYWORDS = [
- "投标人:", "法定代表人:", "授权代表:", "日期:",
- "被授权人:", "签字", "盖章", "(盖章)", "(签名)",
- "投标人名称:", "法定代表人或授权代表:",
- ]
- for para_idx, para in enumerate(doc.paragraphs):
- if para._element in (protected_paragraph_elements or set()):
- continue
- text = para.text.strip()
- if not text:
- continue
- heading_level = heading_levels.get(para_idx, 0)
- pf = para.paragraph_format
- # ---- 行距统一 ----
- if pf.line_spacing != LINE_SPACING_BODY:
- pf.line_spacing = LINE_SPACING_BODY
- # ---- 段前段后统一为 0(严格按规范) ----
- pf.space_before = Pt(0)
- pf.space_after = Pt(0)
- # ---- 缩进 ----
- if heading_level > 0:
- # 标题不缩进
- if pf.first_line_indent is not None:
- pf.first_line_indent = None
- elif para.alignment == WD_ALIGN_PARAGRAPH.CENTER:
- # 居中段落(封面、分区标题等)不缩进
- if pf.first_line_indent is not None:
- pf.first_line_indent = None
- elif any(kw in text for kw in _SKIP_INDENT_KEYWORDS):
- # 签名/落款区不缩进
- if pf.first_line_indent is not None:
- pf.first_line_indent = None
- else:
- # 正文首行缩进 2 字符
- current = pf.first_line_indent
- if current is None:
- pf.first_line_indent = BODY_INDENT_PT
- elif hasattr(current, 'pt') and abs(current.pt - 24) > 3:
- pf.first_line_indent = BODY_INDENT_PT
- self._fixed_count += 1
- # ================================================================
- # Phase 5: 封面标题格式
- # ================================================================
- def _fix_cover_page(self, doc: DocxDocument, project_name: str):
- """检查和修正封面格式:二号黑体加粗居中"""
- # 查找文档前几段中最像封面标题的段落
- for para in list(doc.paragraphs)[:15]:
- text = para.text.strip()
- if not text or len(text) < 3:
- continue
- if (para.alignment == WD_ALIGN_PARAGRAPH.CENTER
- and ("投标" in text or project_name in text)):
- for run in para.runs:
- run.font.size = Pt(SIZE_COVER_TITLE)
- run.font.name = FONT_HEADING
- self._set_east_asian_font(run, FONT_HEADING)
- if not run.bold:
- run.bold = True
- # ================================================================
- # Phase 6: 页眉/页脚/页码
- # ================================================================
- def _fix_headers_footers(
- self,
- doc: DocxDocument,
- project_name: str,
- part_label: str,
- ):
- """设置页眉页脚页码
- 页眉:按模板原样保留(项目全称等),不添加商务标/技术标字样
- 页脚:居中页码(五号宋体),仅页码无其他信息
- 封面、目录不编页码
- """
- # ---- 通过 XML part 级别处理页眉 ----
- header_parts, footer_parts = self._collect_hf_parts(doc)
- for hf_part in header_parts:
- try:
- self._setup_header_part(hf_part, project_name, part_label)
- except Exception as e:
- logger.debug(f"页眉 part 设置失败: {e}")
- for hf_part in footer_parts:
- try:
- self._setup_footer_part(hf_part)
- except Exception as e:
- logger.debug(f"页脚 part 设置失败: {e}")
- # ---- 通过 python-docx API 补充 ----
- for section in doc.sections:
- # 页眉
- header = section.header
- if header:
- header_text = "".join(p.text for p in header.paragraphs).strip()
- if not header_text and project_name and header.paragraphs:
- main_para = header.paragraphs[0]
- # 清除旧内容
- for run in main_para.runs:
- if not self._run_has_image(run):
- run.text = ""
- # 左侧项目名
- run_left = main_para.add_run(project_name)
- run_left.font.size = Pt(SIZE_TABLE_NOTE)
- run_left.font.name = FONT_BODY
- self._set_east_asian_font(run_left, FONT_BODY)
- # 右侧标类型(用制表符对齐)
- if part_label:
- main_para.add_run("\t\t")
- run_right = main_para.add_run(part_label)
- run_right.font.size = Pt(SIZE_TABLE_NOTE)
- run_right.font.name = FONT_BODY
- self._set_east_asian_font(run_right, FONT_BODY)
- self._fixed_count += 1
- logger.debug("页眉页脚页码已设置")
- def _collect_hf_parts(self, doc: DocxDocument) -> Tuple[list, list]:
- """收集所有 header/footer XML parts"""
- try:
- from docx.opc.constants import RELATIONSHIP_TYPE as RT
- HEADER_TYPE = RT.HEADER
- FOOTER_TYPE = RT.FOOTER
- except ImportError:
- HEADER_TYPE = (
- "http://schemas.openxmlformats.org/"
- "officeDocument/2006/relationships/header"
- )
- FOOTER_TYPE = (
- "http://schemas.openxmlformats.org/"
- "officeDocument/2006/relationships/footer"
- )
- headers, footers = [], []
- for rel in doc.part.rels.values():
- reltype = rel.reltype if isinstance(rel.reltype, str) else str(rel.reltype)
- if reltype == HEADER_TYPE:
- headers.append(rel.target_part)
- elif reltype == FOOTER_TYPE:
- footers.append(rel.target_part)
- return headers, footers
- @staticmethod
- def _header_part_has_text(hf_part) -> bool:
- """页眉 part 是否已有文本内容(含文本框/绘图内文字)"""
- w_ns = WML_NS
- root = hf_part._element
- return any(
- (t.text or "").strip()
- for t in root.iter(f"{{{w_ns}}}t")
- )
- def _setup_header_part(self, hf_part, project_name: str, part_label: str):
- """设置单个 header part 的内容
- 保留已有图片/绘图对象,仅更新或追加文本内容。
- 避免破坏模板中的公司 Logo 等图片资源。
- """
- # 模板页眉已有文字内容(含文本框)时保留模板原样,
- # 不再追加"项目名称 + 标类型",避免与模板自带页眉重复
- if self._header_part_has_text(hf_part):
- logger.debug("页眉已有文本内容,保留模板原样")
- return
- root = hf_part._element
- w_ns = WML_NS
- # 查找第一个段落
- paras = root.findall(f'{{{w_ns}}}p')
- if not paras:
- return
- para = paras[0]
- # ---- 检查段落中是否包含图片/绘图 ----
- drawing_runs = [] # 含图片的 run 索引
- text_runs = [] # 纯文本 run 索引
- for child in list(para):
- tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
- if tag == 'pPr':
- continue
- if tag == 'r':
- # 检查 run 内是否包含 drawing/pict
- has_image = False
- for sub in child:
- sub_tag = sub.tag.split('}')[-1] if '}' in sub.tag else sub.tag
- if sub_tag in ('drawing', 'pict'):
- has_image = True
- break
- if has_image:
- drawing_runs.append(child)
- else:
- text_runs.append(child)
- elif tag == 'drawing' or tag == 'pict':
- drawing_runs.append(child)
- else:
- text_runs.append(child)
- has_images = len(drawing_runs) > 0
- if has_images:
- # ---- 保留图片,只替换文本 run 的内容 ----
- # 清除现有文本 run 的文本
- for run_elem in text_runs:
- for t_elem in run_elem.findall(f'{{{w_ns}}}t'):
- t_elem.text = ""
- # 在最后一个图片 run 之后插入项目名称文本
- if project_name:
- last_drawing = drawing_runs[-1]
- parent = para
- # 找到最后一个 drawing run 在段落中的位置
- para_children = list(parent)
- try:
- insert_idx = para_children.index(last_drawing) + 1
- except ValueError:
- insert_idx = len(para_children)
- # 创建文本 run
- text_run = etree.Element(f'{{{w_ns}}}r')
- rPr = etree.SubElement(text_run, f'{{{w_ns}}}rPr')
- sz = etree.SubElement(rPr, f'{{{w_ns}}}sz')
- sz.set(f'{{{w_ns}}}val', str(int(SIZE_TABLE_NOTE * 2)))
- rFonts = etree.SubElement(rPr, f'{{{w_ns}}}rFonts')
- rFonts.set(f'{{{w_ns}}}eastAsia', FONT_BODY)
- rFonts.set(f'{{{w_ns}}}ascii', FONT_WESTERN)
- rFonts.set(f'{{{w_ns}}}hAnsi', FONT_WESTERN)
- t = etree.SubElement(text_run, f'{{{w_ns}}}t')
- t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
- t.text = f" {project_name}"
- parent.insert(insert_idx, text_run)
- if part_label:
- # 添加制表符 + 标类型
- tab_run = etree.SubElement(parent, f'{{{w_ns}}}r')
- etree.SubElement(tab_run, f'{{{w_ns}}}tab')
- tab_run2 = etree.SubElement(parent, f'{{{w_ns}}}r')
- etree.SubElement(tab_run2, f'{{{w_ns}}}tab')
- part_run = etree.Element(f'{{{w_ns}}}r')
- part_rPr = etree.SubElement(part_run, f'{{{w_ns}}}rPr')
- part_sz = etree.SubElement(part_rPr, f'{{{w_ns}}}sz')
- part_sz.set(f'{{{w_ns}}}val', str(int(SIZE_TABLE_NOTE * 2)))
- part_rFonts = etree.SubElement(part_rPr, f'{{{w_ns}}}rFonts')
- part_rFonts.set(f'{{{w_ns}}}eastAsia', FONT_BODY)
- part_rFonts.set(f'{{{w_ns}}}ascii', FONT_WESTERN)
- part_rFonts.set(f'{{{w_ns}}}hAnsi', FONT_WESTERN)
- part_t = etree.SubElement(part_run, f'{{{w_ns}}}t')
- part_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
- part_t.text = part_label
- parent.append(part_run)
- return
- # ---- 无图片:标准页眉设置(清除旧文本,保留 pPr) ----
- for child in list(para):
- tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
- if tag != 'pPr':
- para.remove(child)
- if project_name:
- self._add_text_run(para, project_name, SIZE_TABLE_NOTE, FONT_BODY, w_ns)
- if part_label:
- # 添加制表符
- tab_run = etree.SubElement(para, f'{{{w_ns}}}r')
- tab_elem = etree.SubElement(tab_run, f'{{{w_ns}}}tab')
- tab_run2 = etree.SubElement(para, f'{{{w_ns}}}r')
- tab_elem2 = etree.SubElement(tab_run2, f'{{{w_ns}}}tab')
- self._add_text_run(para, part_label, SIZE_TABLE_NOTE, FONT_BODY, w_ns)
- def _setup_footer_part(self, hf_part):
- """设置单个 footer part:居中页码"""
- root = hf_part._element
- w_ns = WML_NS
- # 清理旧的 PAGE 域代码
- self._remove_page_fields(root, w_ns)
- # 查找第一个段落
- paras = root.findall(f'{{{w_ns}}}p')
- if not paras:
- return
- para = paras[0]
- # 清除旧内容(保留 pPr)
- for child in list(para):
- tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
- if tag != 'pPr':
- para.remove(child)
- # 确保居中
- pPr = para.find(f'{{{w_ns}}}pPr')
- if pPr is None:
- pPr = etree.SubElement(para, f'{{{w_ns}}}pPr')
- para.insert(0, pPr)
- jc = pPr.find(f'{{{w_ns}}}jc')
- if jc is None:
- jc = etree.SubElement(pPr, f'{{{w_ns}}}jc')
- jc.set(f'{{{w_ns}}}val', 'center')
- # 创建 PAGE 域代码
- # run 1: fldChar begin
- run1 = etree.SubElement(para, f'{{{w_ns}}}r')
- rPr1 = etree.SubElement(run1, f'{{{w_ns}}}rPr')
- sz1 = etree.SubElement(rPr1, f'{{{w_ns}}}sz')
- sz1.set(f'{{{w_ns}}}val', str(int(SIZE_PAGE_NUM * 2)))
- rFonts1 = etree.SubElement(rPr1, f'{{{w_ns}}}rFonts')
- rFonts1.set(f'{{{w_ns}}}ascii', FONT_WESTERN)
- rFonts1.set(f'{{{w_ns}}}hAnsi', FONT_WESTERN)
- fld_begin = etree.SubElement(run1, f'{{{w_ns}}}fldChar')
- fld_begin.set(f'{{{w_ns}}}fldCharType', 'begin')
- # run 2: instrText
- run2 = etree.SubElement(para, f'{{{w_ns}}}r')
- instr = etree.SubElement(run2, f'{{{w_ns}}}instrText')
- instr.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
- instr.text = ' PAGE '
- # run 3: fldChar separate(Word 域标准结构:begin→instr→separate→结果→end,
- # 缺 separate 时部分查看器不显示域结果)
- run3 = etree.SubElement(para, f'{{{w_ns}}}r')
- fld_sep = etree.SubElement(run3, f'{{{w_ns}}}fldChar')
- fld_sep.set(f'{{{w_ns}}}fldCharType', 'separate')
- # run 4: 域结果占位(Word 打开时自动刷新为实际页码)
- run4 = etree.SubElement(para, f'{{{w_ns}}}r')
- rPr4 = etree.SubElement(run4, f'{{{w_ns}}}rPr')
- sz4 = etree.SubElement(rPr4, f'{{{w_ns}}}sz')
- sz4.set(f'{{{w_ns}}}val', str(int(SIZE_PAGE_NUM * 2)))
- rFonts4 = etree.SubElement(rPr4, f'{{{w_ns}}}rFonts')
- rFonts4.set(f'{{{w_ns}}}ascii', FONT_WESTERN)
- rFonts4.set(f'{{{w_ns}}}hAnsi', FONT_WESTERN)
- t4 = etree.SubElement(run4, f'{{{w_ns}}}t')
- t4.text = '1'
- # run 5: fldChar end
- run5 = etree.SubElement(para, f'{{{w_ns}}}r')
- fld_end = etree.SubElement(run5, f'{{{w_ns}}}fldChar')
- fld_end.set(f'{{{w_ns}}}fldCharType', 'end')
- @staticmethod
- def _remove_page_fields(root, w_ns: str):
- """移除已有的所有 PAGE 域代码"""
- runs_to_remove = []
- for run_elem in root.iter(f'{{{w_ns}}}r'):
- has_fldchar = False
- has_page = False
- for child in run_elem:
- tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
- if tag == 'fldChar':
- has_fldchar = True
- if tag == 'instrText' and child.text and 'PAGE' in child.text.upper():
- has_page = True
- if has_fldchar or has_page:
- runs_to_remove.append(run_elem)
- for run_elem in runs_to_remove:
- parent = run_elem.getparent()
- if parent is not None:
- parent.remove(run_elem)
- def _add_text_run(self, para, text: str, size_pt: float, font_name: str, w_ns: str):
- """向 XML 段落添加一个文本 run"""
- run = etree.SubElement(para, f'{{{w_ns}}}r')
- rPr = etree.SubElement(run, f'{{{w_ns}}}rPr')
- sz = etree.SubElement(rPr, f'{{{w_ns}}}sz')
- sz.set(f'{{{w_ns}}}val', str(int(size_pt * 2)))
- rFonts = etree.SubElement(rPr, f'{{{w_ns}}}rFonts')
- rFonts.set(f'{{{w_ns}}}eastAsia', font_name)
- rFonts.set(f'{{{w_ns}}}ascii', FONT_WESTERN)
- rFonts.set(f'{{{w_ns}}}hAnsi', FONT_WESTERN)
- t = etree.SubElement(run, f'{{{w_ns}}}t')
- t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
- t.text = text
- # ================================================================
- # Phase 7: 自动目录(TOC)
- # ================================================================
- def _insert_toc(self, doc: DocxDocument, project_name: str):
- """在文档开头插入 Word 自动目录
- 目录在封面之后,正文之前。
- 使用 Word TOC 域代码实现自动生成。
- V3.1 修复:模板中的静态"目录"文字页无法自动更新。
- 现在会先找到第一个真实章节,移除该位置之前的所有静态目录内容,
- 然后在同一位置插入 TOC 域代码。
- """
- body = doc.element.body
- w_ns = WML_NS
- # ---- 检查是否已有 TOC 域代码 ----
- existing_toc_field = self._find_existing_toc(body, w_ns)
- if existing_toc_field:
- logger.info(
- "模板已有 TOC 域代码,跳过重复插入"
- "(请在 Word 中右键目录 → 更新域以刷新)"
- )
- return
- # ---- 找到正文起点的位置:第一个分区标记(商务部分/技术部分)或第一个"第X章" ----
- # 架构规范:封面 → 目录(TOC) → 商务部分标题 → 商务章节 → 技术部分标题 → 技术章节。
- # 分区标题("商务部分")位于其下章节之前,TOC 必须插在分区标题之前,
- # 否则目录会出现在"商务部分"之后,破坏整体架构顺序。
- insert_before = None
- chapter_re = re.compile(r"^第\s*[一二三四五六七八九十\d]+\s*章")
- part_markers = {"商务部分", "技术部分", "附件", "附录"}
- for para in doc.paragraphs:
- text = para.text.strip()
- if text in part_markers or chapter_re.match(text):
- insert_before = para._element
- break
- if insert_before is None:
- logger.warning("未找到章节起始位置,跳过目录插入")
- return
- # ---- 移除第一个章节之前的静态目录内容 ----
- # 在 insert_before 之前的所有段落中,找到"目录"标题及后续的目录行,
- # 全部移除(它们无法自动更新,且会干扰 TOC 域代码的位置)
- _remove_static_toc_content(doc, insert_before)
- # ---- 插入分页符 ----
- page_break_para = OxmlElement('w:p')
- page_break_run = OxmlElement('w:r')
- page_break = OxmlElement('w:br')
- page_break.set(qn('w:type'), 'page')
- page_break_run.append(page_break)
- page_break_para.append(page_break_run)
- insert_before.addprevious(page_break_para)
- # ---- 目录标题 ----
- toc_title_para = OxmlElement('w:p')
- toc_title_pPr = OxmlElement('w:pPr')
- toc_title_jc = OxmlElement('w:jc')
- toc_title_jc.set(qn('w:val'), 'center')
- toc_title_pPr.append(toc_title_jc)
- toc_title_para.append(toc_title_pPr)
- toc_title_run = OxmlElement('w:r')
- toc_title_rPr = OxmlElement('w:rPr')
- toc_title_sz = OxmlElement('w:sz')
- toc_title_sz.set(qn('w:val'), str(int(SIZE_TOC_TITLE * 2)))
- toc_title_rPr.append(toc_title_sz)
- toc_title_b = OxmlElement('w:b')
- toc_title_rPr.append(toc_title_b)
- toc_title_rFonts = OxmlElement('w:rFonts')
- toc_title_rFonts.set(qn('w:eastAsia'), FONT_HEADING)
- toc_title_rFonts.set(qn('w:ascii'), FONT_WESTERN)
- toc_title_rFonts.set(qn('w:hAnsi'), FONT_WESTERN)
- toc_title_rPr.append(toc_title_rFonts)
- toc_title_run.append(toc_title_rPr)
- toc_title_t = OxmlElement('w:t')
- toc_title_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
- toc_title_t.text = '目 录'
- toc_title_run.append(toc_title_t)
- toc_title_para.append(toc_title_run)
- insert_before.addprevious(toc_title_para)
- # ---- TOC 域代码 ----
- toc_para = OxmlElement('w:p')
- # TOC field: begin
- toc_run1 = OxmlElement('w:r')
- toc_fld_begin = OxmlElement('w:fldChar')
- toc_fld_begin.set(qn('w:fldCharType'), 'begin')
- toc_run1.append(toc_fld_begin)
- toc_para.append(toc_run1)
- # TOC field: instrText
- toc_run2 = OxmlElement('w:r')
- toc_instr = OxmlElement('w:instrText')
- toc_instr.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
- # TOC 指令:根据 Heading 1-4 生成目录(用户规范:目录最多展示 4 级),
- # 显示页码右对齐
- toc_instr.text = (
- ' TOC \\o "1-4" \\h \\z \\u '
- '\\t "Heading 1,1,Heading 2,2,Heading 3,3,Heading 4,4" '
- )
- toc_run2.append(toc_instr)
- toc_para.append(toc_run2)
- # TOC field: separator
- toc_run3 = OxmlElement('w:r')
- toc_fld_sep = OxmlElement('w:fldChar')
- toc_fld_sep.set(qn('w:fldCharType'), 'separate')
- toc_run3.append(toc_fld_sep)
- toc_para.append(toc_run3)
- # TOC placeholder text
- toc_run4 = OxmlElement('w:r')
- toc_run5 = OxmlElement('w:r')
- toc_placeholder = OxmlElement('w:t')
- toc_placeholder.text = '(请在 Word 中右键目录 → 更新域 以生成目录)'
- toc_run4.append(toc_placeholder)
- toc_para.append(toc_run4)
- # TOC field: end
- toc_fld_end = OxmlElement('w:fldChar')
- toc_fld_end.set(qn('w:fldCharType'), 'end')
- toc_run5.append(toc_fld_end)
- toc_para.append(toc_run5)
- insert_before.addprevious(toc_para)
- # ---- 目录后插入分页符 ----
- page_break_para2 = OxmlElement('w:p')
- page_break_run2 = OxmlElement('w:r')
- page_break2 = OxmlElement('w:br')
- page_break2.set(qn('w:type'), 'page')
- page_break_run2.append(page_break2)
- page_break_para2.append(page_break_run2)
- insert_before.addprevious(page_break_para2)
- self._fixed_count += 1
- logger.info("自动目录(TOC)已插入")
- @staticmethod
- def _find_existing_toc(body, w_ns: str) -> bool:
- """检测文档 body 中是否已有 TOC 域代码"""
- for para_elem in body.iter(f'{{{w_ns}}}p'):
- instr_texts = []
- for instr in para_elem.iter(f'{{{w_ns}}}instrText'):
- if instr.text:
- instr_texts.append(instr.text)
- combined = " ".join(instr_texts)
- if "TOC" in combined:
- return True
- return False
- # ================================================================
- # Phase 8: 表格格式统一
- # ================================================================
- def _fix_table_format(
- self,
- doc: DocxDocument,
- protected_table_elements: Optional[set] = None,
- ):
- """表格格式统一
- - 表格内文字:五号宋体(10.5pt)
- - 数字右对齐、文字左对齐
- - 表头自动检测并加粗(支持多行表头)
- - 边框样式保持一致
- """
- for table in doc.tables:
- if table._element in (protected_table_elements or set()):
- continue
- if not table.rows:
- continue
- # ---- 检测真实表头行数 ----
- # 策略:连续加粗的行视为表头(支持多行表头)
- header_row_count = 0
- for row_idx, row in enumerate(table.rows):
- # 检查该行是否有加粗且非空的单元格
- bold_cells = 0
- non_empty_cells = 0
- for cell in row.cells:
- cell_text = cell.text.strip()
- if cell_text:
- non_empty_cells += 1
- for para in cell.paragraphs:
- for run in para.runs:
- if run.bold and cell_text:
- bold_cells += 1
- break
- # 如果该行超过一半的非空单元格是加粗的 → 表头行
- if non_empty_cells > 0 and bold_cells >= non_empty_cells * 0.4:
- header_row_count = row_idx + 1
- else:
- break # 遇到非表头行,停止
- # ---- LLM 复核:首行是否真的是列名 ----
- # 只有首行存在较长正文式单元格(>20 字)时才调用 LLM 判断,
- # 避免对每张表都产生一次调用;判断为正文则不再当表头加粗。
- llm_denied_header = False
- if header_row_count > 0 and table.rows:
- first_row_texts = [
- c.text.strip() for c in table.rows[0].cells
- ]
- if any(len(t) > 20 for t in first_row_texts):
- judged = _llm_is_header_row(first_row_texts)
- if judged is False:
- header_row_count = 0
- llm_denied_header = True
- logger.info(
- f" LLM 判定表首行非列名,按正文样式处理: "
- f"{' | '.join(t[:14] for t in first_row_texts[:3])}"
- )
- # 如果未检测到表头(无加粗),默认第一行为表头
- if header_row_count == 0 and not llm_denied_header:
- header_row_count = 1
- for row_idx, row in enumerate(table.rows):
- is_header = row_idx < header_row_count
- for cell in row.cells:
- cell_text = cell.text.strip()
- # ---- 多段落单元格合并:单元格内多个 <w:p> 合并为一段 ----
- # 参考投书/PDF 提取的表格常在单元格内产生多个段落(如
- # “规定”+“分值”被拆成两段),Word 中显示为换行。这里把
- # 后续段落的文本并入第一段,保留第一段格式。
- _merge_cell_paragraphs(cell)
- for para in cell.paragraphs:
- for run in para.runs:
- if self._run_has_image(run):
- continue
- # 字号:表头 10.5pt 加粗,数据行 10.5pt
- if run.font.size and abs(run.font.size.pt - SIZE_TABLE_NOTE) > 0.5:
- run.font.size = Pt(SIZE_TABLE_NOTE)
- elif not run.font.size:
- run.font.size = Pt(SIZE_TABLE_NOTE)
- # 字体:宋体
- run.font.name = FONT_BODY
- self._set_east_asian_font(run, FONT_BODY)
- # 表头加粗
- if is_header and not run.bold:
- run.bold = True
- # 对齐:纯数字 → 右对齐;其他 → 左对齐
- if cell_text and re.match(r'^[\d,.,。、%%¥¥\s]+$', cell_text):
- para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
- else:
- para.alignment = WD_ALIGN_PARAGRAPH.LEFT
- # 单元格级对齐(tcPr/w:jc)同步:有些表格的居中是在
- # 单元格属性上而非段落属性,必须一并清除/覆盖。
- _set_cell_align_by_content(cell, cell_text)
- # 正文句子去加粗(模板正文常自带加粗,按规范正文不加粗)
- try:
- fixed_bold = _debold_body_sentences(doc)
- if fixed_bold > 0:
- self._fixed_count += fixed_bold
- except Exception as e:
- logger.debug(f"正文句子去加粗失败: {e}")
- # 表格宽度统一:横向填满页面可用宽度,且不超出页面
- try:
- section = doc.sections[0]
- content_width = int(
- (
- section.page_width
- - section.left_margin
- - section.right_margin
- )
- / 635 # EMU → dxa
- )
- generated_tables = [
- table for table in doc.tables
- if table._element not in (protected_table_elements or set())
- ]
- fixed_width = _normalize_table_widths_for_tables(
- generated_tables, content_width
- )
- if fixed_width > 0:
- self._fixed_count += fixed_width
- logger.info(
- f"表格宽度归一化: {fixed_width} 个表格调整到页面宽度"
- )
- except Exception as e:
- logger.debug(f"表格宽度归一化失败: {e}")
- self._fixed_count += 1
- # ================================================================
- # Phase 9: 禁用格式清理
- # ================================================================
- def _remove_forbidden_formatting(
- self,
- doc: DocxDocument,
- protected_table_elements: Optional[set] = None,
- protected_paragraph_elements: Optional[set] = None,
- ):
- """移除禁用格式
- 禁用:艺术字体、倾斜、下划线、着重号、阴影文字
- 注意:下划线必须保留——模板/招标文件中的关键字段(公司名称、
- 金额、日期、签章区等)使用下划线标记,便于人工检查与手写填充;
- && 占位符会在占位符清理阶段转换为下划线。
- """
- for para in doc.paragraphs:
- if para._element in (protected_paragraph_elements or set()):
- continue
- for run in para.runs:
- if self._run_has_image(run):
- continue
- # 斜体
- if run.font.italic:
- run.font.italic = False
- # 阴影
- try:
- rPr = run._element.find(qn('w:rPr'))
- if rPr is not None:
- for tag in ('w:shadow', 'w:emboss', 'w:imprint', 'w:dstrike'):
- elem = rPr.find(qn(tag))
- if elem is not None:
- rPr.remove(elem)
- except Exception:
- pass
- # 表格内同样处理
- for table in doc.tables:
- if table._element in (protected_table_elements or set()):
- continue
- for row in table.rows:
- for cell in row.cells:
- for para in cell.paragraphs:
- for run in para.runs:
- if run.font.italic:
- run.font.italic = False
- self._fixed_count += 1
- # ================================================================
- # Phase 10: 占位符兜底清理
- # ================================================================
- def _cleanup_placeholders(self, doc: DocxDocument):
- """清理残留占位符:&& 替换为下划线,报告未解析的 %%...%%"""
- placeholder_re = re.compile(r"%%(.+?)%%")
- and_fixed = 0
- unresolved = []
- def _process_paragraphs(paragraphs, location: str = ""):
- nonlocal and_fixed
- for para in paragraphs:
- for run in para.runs:
- if self._run_has_image(run):
- continue
- # && → _
- if "&&" in (run.text or ""):
- run.text = run.text.replace("&&", "_")
- and_fixed += 1
- # 收集 %%...%%
- for match in placeholder_re.finditer(run.text or ""):
- key = match.group(1).strip()
- if key not in unresolved:
- unresolved.append(f"[{location}] %%{key}%%")
- _process_paragraphs(doc.paragraphs, "正文")
- for table_idx, table in enumerate(doc.tables):
- for row in table.rows:
- for cell in row.cells:
- _process_paragraphs(cell.paragraphs, f"表格{table_idx}")
- for section in doc.sections:
- if section.header:
- _process_paragraphs(section.header.paragraphs, "页眉")
- if section.footer:
- _process_paragraphs(section.footer.paragraphs, "页脚")
- if and_fixed > 0:
- self._fixed_count += and_fixed
- if unresolved:
- self._warnings.append(
- f"未解析占位符 ({len(unresolved)} 处): "
- + ", ".join(u[-50:] for u in unresolved[:5])
- )
- # ================================================================
- # 工具方法
- # ================================================================
- @staticmethod
- def _run_has_image(run) -> bool:
- """检测 run 中是否包含图片"""
- try:
- for child in run._element:
- tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
- if tag in ('drawing', 'pict'):
- return True
- except Exception:
- pass
- return False
- @staticmethod
- def _get_east_asian_font(run) -> Optional[str]:
- """获取东亚字体"""
- try:
- rPr = run._element.find(qn('w:rPr'))
- if rPr is not None:
- rFonts = rPr.find(qn('w:rFonts'))
- if rFonts is not None:
- return rFonts.get(qn('w:eastAsia'))
- except Exception:
- pass
- return None
- @staticmethod
- def _set_east_asian_font(run, font_name: str):
- """设置东亚字体"""
- try:
- rPr = run._element.find(qn('w:rPr'))
- if rPr is None:
- rPr = OxmlElement('w:rPr')
- run._element.insert(0, rPr)
- rFonts = rPr.find(qn('w:rFonts'))
- if rFonts is None:
- rFonts = OxmlElement('w:rFonts')
- rPr.insert(0, rFonts)
- rFonts.set(qn('w:eastAsia'), font_name)
- except Exception:
- pass
- # ============================================================
- # 模块级回退函数
- # ============================================================
- def _build_fallback_heading_analysis(doc: DocxDocument) -> "HeadingAnalysis":
- """正则回退方案:当 LLM 标题分析不可用时,用正则检测全文档标题
- 检测规则(与用户规范严格对齐):
- Heading 1: "第X章 ..." → 章标题
- Heading 2: "一、二、三、..." → 一级节标题
- Heading 3: "(一)(二)(三)..." → 二级节标题
- Heading 4: "1. 2. 3. ..." → 三级节标题
- Heading 5: "(1) (2) (3) ..." → 四级节标题
- Heading 6: "1) 2) 3) ..." 或 "a. b. c. ..." → 五级/六级
- 排除规则:
- - 过长文本(>80字)视为正文而非标题
- - 标点结尾视为正文
- - 章节内已通过 _detect_heading_level 回退
- """
- from step6_exporting.heading_analyzer import HeadingAnalysis, HeadingInfo
- all_paras = list(doc.paragraphs)
- headings = []
- # 各级标题正则(按优先级从高到低)
- _PATTERNS = [
- # (Word Heading level, regex, max_length)
- (1, re.compile(r"^第\s*[一二三四五六七八九十\d]+\s*章[::\s、]*(.*)"), 80),
- (2, re.compile(r"^[一二三四五六七八九十]+[、,]\s*\S"), 80),
- (3, re.compile(r"^[((][一二三四五六七八九十]+[))]\s*\S"), 60),
- (4, re.compile(r"^\d+[\.、]\s*\S"), 55),
- ]
- # 分区标记
- part_markers = {"商务部分", "技术部分", "附件", "附录"}
- for para_idx, para in enumerate(all_paras):
- text = para.text.strip()
- if not text:
- continue
- # 字段标签行("xxx:"结尾)或完整句子结尾(句号/逗号/分号等)不是标题;
- # 字段名:值 的数据行也不是标题
- if text.endswith((":", ":")):
- continue
- if re.search(r"[。;,、!?]\s*$", text):
- continue
- if re.match(r"^[^::]*[::]\s*\S", text):
- continue
- # 检查分区标记
- if text in part_markers:
- headings.append(HeadingInfo(
- para_index=para_idx, level=1,
- title=text, full_text=text,
- detection_method="fallback_part", confidence=0.9,
- ))
- continue
- # 按优先级匹配标题模式
- for level, pattern, max_len in _PATTERNS:
- m = pattern.match(text)
- if not m:
- continue
- # 提取标题内容
- if level == 1:
- title = m.group(1).strip() or text
- else:
- prefix_end = m.end()
- title = text[prefix_end:].strip()
- # 排除规则
- if len(text) > max_len:
- continue
- if len(title) < 2:
- continue
- if title and title[-1] in ",。;:、!?,…;:!?":
- continue
- if ":" in title or ":" in title:
- continue # 含冒号的通常是数据列举(如"1、单位名称:xxx"),不是标题
- headings.append(HeadingInfo(
- para_index=para_idx,
- level=level,
- title=title,
- full_text=text,
- detection_method="fallback_regex",
- confidence=0.7,
- ))
- break # 匹配到最高优先级模式后不再继续
- analysis = HeadingAnalysis(
- headings=headings,
- total_paragraphs=len(all_paras),
- heading_count=len(headings),
- )
- logger.info(
- f"正则回退标题检测完成: {analysis.heading_count} 个标题 / "
- f"{analysis.total_paragraphs} 个段落"
- )
- # 统计各级标题
- level_counts: Dict[int, int] = {}
- for h in headings:
- if h.level > 0:
- level_counts[h.level] = level_counts.get(h.level, 0) + 1
- for lv in sorted(level_counts):
- logger.info(f" Heading {lv}: {level_counts[lv]} 个")
- return analysis
- def _remove_static_toc_content(doc: DocxDocument, first_chapter_element) -> int:
- """移除第一个真实章节之前的所有静态目录内容
- 静态目录页("目录"标题 + "第一章 xxx...1" 等内容行)
- 无法自动更新,必须被 TOC 域代码替代。
- 策略:
- 1. 在 first_chapter_element 之前找到"目录"/"目 录"段落
- 2. 将"目录"标题及其之后、first_chapter_element 之前的所有
- 目录内容行(匹配章节行或短行)一并移除
- 3. 在 first_chapter_element 之前插入分页符(为 TOC 留页面)
- Args:
- doc: DOCX 文档
- first_chapter_element: 第一个真实章节的 XML 元素
- Returns:
- 移除的段落数量
- """
- body = doc.element.body
- body_children = list(body)
- # 找到 first_chapter_element 在 body 中的位置
- try:
- chapter_pos = body_children.index(first_chapter_element)
- except ValueError:
- return 0
- # 在 chapter 之前查找"目录"标题
- toc_title_pos = -1
- for i in range(chapter_pos - 1, -1, -1):
- child = body_children[i]
- if child.tag.endswith('}p'):
- text = _extract_paragraph_element_text(child).strip()
- if text in ('目录', '目 录'):
- toc_title_pos = i
- break
- if toc_title_pos < 0:
- return 0 # 没有静态目录,无需清理
- # 移除"目录"标题及其后的静态目录内容行;
- # 遇到非目录内容(如索引表章节标题、委托书正文)立即停止,避免误删真实内容。
- removed = 0
- for i in range(toc_title_pos, chapter_pos):
- child = body_children[i]
- if not child.tag.endswith('}p'):
- break # 目录页不含表格,遇到表格说明已到真实内容区
- # 分节符段落(pPr 内含 w:sectPr)不能删:删除会合并节,
- # 并使该节的页眉/页脚引用丢失(页眉页码消失)。
- pPr = child.find(qn('w:pPr'))
- if pPr is not None and pPr.find(qn('w:sectPr')) is not None:
- continue
- text = _extract_paragraph_element_text(child).strip()
- if i > toc_title_pos and not _is_static_toc_line(text):
- break # 非目录内容(索引表标题等)→ 保留,后续段落也不再删
- body.remove(child)
- removed += 1
- if removed > 0:
- logger.info(
- f"静态目录内容已移除: {removed} 个段落 "
- f"(位置 {toc_title_pos}→{chapter_pos - 1})"
- )
- return removed
- def _extract_paragraph_element_text(para_elem) -> str:
- """从 XML 段落元素中提取纯文本"""
- texts = []
- for node in para_elem.iter():
- tag = node.tag.split('}')[-1] if '}' in node.tag else node.tag
- if tag == 't' and node.text:
- texts.append(node.text)
- return ''.join(texts)
- def _is_static_toc_line(text: str) -> bool:
- """判断段落文本是否像静态目录内容行
- 静态目录行特征(任一命中即视为目录内容):
- - 以"第X章"开头(目录章节行)
- - 含点线(…、……、....)
- - 以页码结尾且行较短
- - 短行且含"目录"(目录页说明/提示行)
- 非目录行(如索引表章节标题、授权委托书正文)返回 False,
- 用于 _remove_static_toc_content 的截断判断,防止误删真实内容。
- """
- t = text.strip()
- if not t:
- return True # 空行属于目录页排版
- if re.match(r"^第\s*[一二三四五六七八九十百\d]+\s*章", t):
- return True
- if "…" in t or "……" in t or "...." in t:
- return True
- if re.search(r"\d+\s*页?$", t) and len(t) <= 60:
- return True
- if len(t) <= 30 and "目录" in t:
- return True
- return False
- __all__ = ["FormatApplier"]
|