"""export_service.py — 将 Blocks 转换为 .doc 文件并返回永久下载链接""" import base64 import io import json import time import unicodedata from pathlib import Path from typing import Optional from docx import Document from docx.enum.table import WD_TABLE_ALIGNMENT, WD_CELL_VERTICAL_ALIGNMENT from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.shared import Pt, RGBColor, Inches from docx.enum.section import WD_ORIENT from lxml import etree from app.config import settings from app.core.exceptions import ExportError # ------------------------------------------------------------------ # # TOC 更新服务(使用 WPS/Word COM API) # ------------------------------------------------------------------ # def update_document_fields(file_path: str) -> bool: """使用 WPS/Word COM API 更新文档中的所有域(目录、页码等) Args: file_path: 文档文件路径(绝对路径) Returns: bool: 更新是否成功 Note: - 仅在 Windows 平台且安装了 WPS/Word 时可用 - 如果更新失败,不影响原文件(会有错误日志) - 更新会直接覆盖原文件 - 支持 .docx 和 .doc 格式 """ import platform import os # 仅在 Windows 平台尝试更新 if platform.system() != 'Windows': print(f"提示: 非 Windows 平台,跳过域更新(文档可在打开时自动更新)") return False # 检查文件是否存在 if not os.path.exists(file_path): print(f"警告: 文件不存在: {file_path}") return False try: import win32com.client except ImportError: print(f"提示: 未安装 pywin32,跳过域更新(文档可在打开时自动更新)") return False try: print(f"正在启动 WPS/Word 后台进程更新域...") # 转换为绝对路径(COM API 需要) abs_file_path = os.path.abspath(file_path) # 尝试 WPS try: app = win32com.client.Dispatch("Kwps.Application") app_name = "WPS" except Exception: # 如果 WPS 不可用,尝试 Microsoft Word try: app = win32com.client.Dispatch("Word.Application") app_name = "Microsoft Word" except Exception: print(f"提示: 未找到 WPS 或 Word,跳过域更新(文档可在打开时自动更新)") return False app.Visible = False app.DisplayAlerts = False doc = None try: # 打开文档(使用绝对路径) doc = app.Documents.Open(abs_file_path) # 更新所有域(目录 + 页码) doc.Fields.Update() # 再次更新目录(部分版本需要调用两次才能正确填充页码) for field in doc.Fields: if field.Type == 13: # wdFieldTOC = 13 field.Update() # 保存并覆盖原文件 doc.Save() print(f"✓ 使用 {app_name} 成功更新文档域") return True except Exception as e: print(f"警告: 更新文档域时出错: {e}") return False finally: if doc: try: doc.Close(SaveChanges=False) except: pass try: app.Quit() except: pass except Exception as e: print(f"警告: 启动 WPS/Word 失败: {e}") return False # ------------------------------------------------------------------ # # 样式文件加载 # ------------------------------------------------------------------ # def load_style_file(style_id: Optional[str] = None) -> dict: """加载样式 JSON;style_id=None 时使用默认样式文件""" if style_id is not None: # 阶段 1 占位 raise ExportError(f"样式 ID 暂不支持: {style_id}(阶段 1 功能)") path = Path(settings.default_style_file) if not path.exists(): raise ExportError(f"默认样式文件不存在: {path}") try: with open(path, encoding="utf-8") as f: return json.load(f) except (OSError, json.JSONDecodeError) as exc: raise ExportError(f"样式文件解析失败: {exc}") from exc def build_style_map(style_data: dict) -> dict[str, dict]: """将样式列表转为双键映射(style_id 和 name 均可命中)""" mapping: dict[str, dict] = {} for s in style_data.get("styles", []): if s.get("style_id"): mapping[s["style_id"]] = s if s.get("name"): mapping[s["name"]] = s return mapping # ------------------------------------------------------------------ # # JSON ↔ lxml 互转 # ------------------------------------------------------------------ # def dict_to_element(d: dict) -> etree._Element: """递归将字典转为 lxml Element""" elem = etree.Element(d["@tag"], attrib=dict(d.get("@attrib", {}))) if d.get("#text"): elem.text = d["#text"] if d.get("#tail"): elem.tail = d["#tail"] for child_tag, child_val in d.get("@children", {}).items(): items = child_val if isinstance(child_val, list) else [child_val] for item in items: if isinstance(item, dict): elem.append(dict_to_element(item)) return elem def inject_styles_from_json(doc: Document, style_data: dict) -> None: """将 JSON 中所有样式的 full_xml_definition upsert 到文档 节点""" styles_element = doc.styles.element for style_entry in style_data.get("styles", []): xml_def = style_entry.get("full_xml_definition") if not xml_def: continue try: new_elem = dict_to_element(xml_def) except Exception: continue style_id_key = qn("w:styleId") new_style_id = new_elem.get(style_id_key) if new_style_id: existing = styles_element.find( f'.//{qn("w:style")}[@{qn("w:styleId")}="{new_style_id}"]' ) if existing is not None: styles_element.remove(existing) styles_element.append(new_elem) # 注入编号格式定义 inject_numbering_from_json(doc, style_data) # 强制清除 python-docx 的样式缓存,确保后续使用的是新注入的样式 # 这一步很重要,因为 python-docx 会缓存样式对象 try: # 清除样式字典缓存,强制重新从 XML 读取 if hasattr(doc.styles, '_element'): # 触发样式重新加载 doc.styles._element = styles_element except Exception: pass def inject_numbering_from_json(doc: Document, style_data: dict) -> None: """将 JSON 中的编号格式定义注入到文档 由于 python-docx 对 numbering part 的支持有限, 我们需要在保存后通过修改 ZIP 文件来注入编号格式。 这个函数主要是为了记录编号定义,实际注入在 blocks_to_docx_bytes 中完成。 """ # 暂时不在这里注入,而是在生成文档后通过 ZIP 修改 pass def _resolve_style_id(style_map: dict, *keys: str) -> Optional[str]: """解析样式 ID""" for key in keys: entry = style_map.get(key) if entry and entry.get("style_id"): return entry["style_id"] return None def _apply_paragraph_style(para, style: dict): """应用段落级样式(对齐方式、行距、缩进等)""" # 对齐方式 align = style.get('align') if align == 'center': para.alignment = WD_ALIGN_PARAGRAPH.CENTER elif align == 'right': para.alignment = WD_ALIGN_PARAGRAPH.RIGHT elif align == 'justify': para.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY elif align == 'left': para.alignment = WD_ALIGN_PARAGRAPH.LEFT # 段落格式(行距、缩进等)- 如果需要的话 pf = para.paragraph_format # 行距 if style.get('line_spacing'): try: pf.line_spacing = style['line_spacing'] except Exception: pass # 段前间距 if style.get('space_before'): try: pf.space_before = Pt(style['space_before']) except Exception: pass # 段后间距 if style.get('space_after'): try: pf.space_after = Pt(style['space_after']) except Exception: pass def _apply_run_style(run, style: dict): """应用 run 级样式(字符级格式)- 增强版""" # 字体名称(支持中文字体 eastAsia)- 优先处理字体 if style.get('font_name'): font_name = style['font_name'] run.font.name = font_name # 对于中文字体,需要设置 eastAsia 属性(关键!) try: r = run._element rPr = r.get_or_add_rPr() rFonts = rPr.get_or_add_rFonts() # 设置所有字体属性,确保中文字体正确应用 rFonts.set(qn('w:ascii'), font_name) rFonts.set(qn('w:hAnsi'), font_name) rFonts.set(qn('w:eastAsia'), font_name) rFonts.set(qn('w:cs'), font_name) # 复杂文字 except Exception: pass # 字号(优先处理) if style.get('font_size'): try: size_pt = float(style['font_size']) run.font.size = Pt(size_pt) # 确保字号正确应用到 XML r = run._element rPr = r.get_or_add_rPr() # 移除旧的字号元素 for sz in rPr.findall(qn('w:sz')): rPr.remove(sz) for szCs in rPr.findall(qn('w:szCs')): rPr.remove(szCs) # 添加新的字号元素(Word 使用半磅单位) sz = OxmlElement('w:sz') sz.set(qn('w:val'), str(int(size_pt * 2))) rPr.append(sz) szCs = OxmlElement('w:szCs') szCs.set(qn('w:val'), str(int(size_pt * 2))) rPr.append(szCs) except Exception: pass # 粗体 if style.get('bold'): run.bold = True # 确保粗体正确应用 try: r = run._element rPr = r.get_or_add_rPr() # 移除旧的粗体元素 for b in rPr.findall(qn('w:b')): rPr.remove(b) for bCs in rPr.findall(qn('w:bCs')): rPr.remove(bCs) # 添加新的粗体元素 b = OxmlElement('w:b') rPr.append(b) bCs = OxmlElement('w:bCs') rPr.append(bCs) except Exception: pass # 斜体 if style.get('italic'): run.italic = True try: r = run._element rPr = r.get_or_add_rPr() for i in rPr.findall(qn('w:i')): rPr.remove(i) for iCs in rPr.findall(qn('w:iCs')): rPr.remove(iCs) i = OxmlElement('w:i') rPr.append(i) iCs = OxmlElement('w:iCs') rPr.append(iCs) except Exception: pass # 下划线 if style.get('underline'): run.underline = True # 删除线 if style.get('strike'): run.font.strike = True # 颜色 if style.get('color'): try: # 移除可能的 # 前缀 color = style['color'].lstrip('#') if len(color) == 6: run.font.color.rgb = RGBColor( int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16) ) except (ValueError, AttributeError): pass # ------------------------------------------------------------------ # # 页面设置应用 # ------------------------------------------------------------------ # def twips_to_emu(twips: int) -> int: """twips 转 EMU (1 twips = 635 EMU)""" if twips is None: return None return int(twips * 635) def apply_page_setup(doc: Document, style_data: dict) -> None: """应用页面设置到文档 从 style_data 中读取 page_setup,应用到文档的第一个 section Args: doc: python-docx Document 对象 style_data: 样式数据(包含 page_setup) """ page_setup = style_data.get("page_setup") if not page_setup: return sections = page_setup.get("sections", []) if not sections: return # 应用第一节的设置 section_data = sections[0] # 检查文档是否有 section if not doc.sections: return section = doc.sections[0] try: # 页边距(twips → EMU) top_margin = section_data.get("top_margin") if top_margin is not None: section.top_margin = twips_to_emu(top_margin) bottom_margin = section_data.get("bottom_margin") if bottom_margin is not None: section.bottom_margin = twips_to_emu(bottom_margin) left_margin = section_data.get("left_margin") if left_margin is not None: section.left_margin = twips_to_emu(left_margin) right_margin = section_data.get("right_margin") if right_margin is not None: section.right_margin = twips_to_emu(right_margin) gutter = section_data.get("gutter") if gutter is not None and gutter > 0: section.gutter = twips_to_emu(gutter) # 纸张尺寸(twips → EMU) page_width = section_data.get("page_width") if page_width is not None: section.page_width = twips_to_emu(page_width) page_height = section_data.get("page_height") if page_height is not None: section.page_height = twips_to_emu(page_height) # 方向 orientation = section_data.get("orientation") if orientation == "landscape": section.orientation = WD_ORIENT.LANDSCAPE elif orientation == "portrait": section.orientation = WD_ORIENT.PORTRAIT # 页眉页脚距离(twips → EMU) header_distance = section_data.get("header_distance") if header_distance is not None: section.header_distance = twips_to_emu(header_distance) footer_distance = section_data.get("footer_distance") if footer_distance is not None: section.footer_distance = twips_to_emu(footer_distance) # 首页页眉页脚不同 different_first_page = section_data.get("different_first_page") if different_first_page is not None: section.different_first_page_header_footer = different_first_page # 文档网格(需要通过 XML 操作) grid_type = section_data.get("grid_type") chars_per_line = section_data.get("chars_per_line") lines_per_page = section_data.get("lines_per_page") if grid_type or chars_per_line or lines_per_page: _apply_document_grid(section, grid_type, chars_per_line, lines_per_page) except Exception as e: # 如果应用页面设置失败,不影响文档生成,只是可能使用默认设置 print(f"警告: 应用页面设置失败: {e}") pass def _apply_document_grid(section, grid_type: str = None, chars_per_line: int = None, lines_per_page: int = None) -> None: """应用文档网格设置(通过 XML 操作) Args: section: python-docx Section 对象 grid_type: 网格类型(default/lines/linesAndChars/snapToChars) chars_per_line: 每行字符数 lines_per_page: 每页行数 """ try: # 获取 section 的 XML 元素 sectPr = None if hasattr(section, '_sectPr'): sectPr = section._sectPr elif hasattr(section, '_element'): sectPr = section._element if sectPr is None: return # 查找或创建 docGrid 元素 docGrid = sectPr.find(qn('w:docGrid')) if docGrid is None: # 如果不存在,创建新的 docGrid 元素 docGrid = OxmlElement('w:docGrid') # 插入到合适的位置(在 sectPr 的子元素中) sectPr.append(docGrid) # 设置网格类型 if grid_type: docGrid.set(qn('w:type'), grid_type) # 设置每页行数(linePitch) # 注意:Word XML 中 linePitch 表示行间距,用于控制每页行数 if lines_per_page is not None and lines_per_page > 0: docGrid.set(qn('w:linePitch'), str(lines_per_page)) # 设置每行字符数(charSpace) # 注意:Word XML 中 charSpace 表示字符间距,用于控制每行字符数 if chars_per_line is not None and chars_per_line > 0: docGrid.set(qn('w:charSpace'), str(chars_per_line)) except Exception as e: print(f"警告: 应用文档网格失败: {e}") pass # ------------------------------------------------------------------ # # Blocks → Word 转换 # ------------------------------------------------------------------ # def blocks_to_docx_bytes(blocks: list[dict], style_map: dict, style_data: dict) -> bytes: """将 Blocks 列表转换为 Word 文档字节流 Args: blocks: Block 列表 style_map: 样式映射 style_data: 样式数据 Returns: Word 文档字节流 """ doc = Document() # 注入样式 inject_styles_from_json(doc, style_data) # 应用页面设置(在注入样式之后) apply_page_setup(doc, style_data) # 验证并修正 Normal 样式的段后间距 # 这是为了确保样式正确应用,避免 python-docx 的默认值覆盖 _fix_normal_style_spacing(doc, style_data) # 检查 blocks 中是否有常用的字体和字号,用于修改 Normal 样式 # 这样可以确保空行在 Word 中显示正确的字体 _update_normal_style_if_needed(doc, blocks) # ★ 新增:检查是否有 TOC block,如果有则设置自动更新域 has_toc = any(block.get('type') == 'toc' for block in blocks) if has_toc: _set_update_fields_on_open(doc) for block in blocks: block_type = block['type'] if block_type == 'heading': _render_heading_block(doc, block, style_map) elif block_type == 'paragraph': _render_paragraph_block(doc, block, style_map) elif block_type == 'table': _render_table_block(doc, block, style_map) elif block_type == 'image': _render_image_block(doc, block, style_map) elif block_type == 'toc': # ★ 新增:处理 TOC block _render_toc_block(doc, block) # 先保存到临时缓冲区 buf = io.BytesIO() doc.save(buf) # 通过 ZIP 操作注入编号格式 docx_bytes = _inject_numbering_via_zip(buf.getvalue(), style_data) return docx_bytes def _fix_normal_style_spacing(doc: Document, style_data: dict): """验证并修正 Normal 样式的段后间距 从 style_data 中读取 Normal 样式的段后间距定义, 确保文档中的 Normal 样式与之一致 关键修复:python-docx 默认模板中 Normal 样式的 styleId 可能是 "Normal" 而不是 "1", 需要同时检查这两种情况 """ try: # 查找 Normal 样式的定义 normal_style_def = None for style_entry in style_data.get("styles", []): if style_entry.get("name") == "Normal" or style_entry.get("style_id") == "1": normal_style_def = style_entry break if not normal_style_def: return # 从 full_xml_definition 中提取段后间距 xml_def = normal_style_def.get("full_xml_definition", {}) pPr = xml_def.get("@children", {}).get("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}pPr", {}) spacing = pPr.get("@children", {}).get("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}spacing", {}) spacing_attrib = spacing.get("@attrib", {}) # 检查是否定义了段后间距 after_key = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}after" has_after = after_key in spacing_attrib after_value = spacing_attrib.get(after_key, "0") # 如果样式定义中没有 w:after 或者 w:after="0",确保文档中所有 Normal 样式也是 0 if not has_after or after_value == "0": styles_element = doc.styles.element # 需要修正的所有 Normal 样式 ID(python-docx 可能使用不同的 ID) normal_style_ids = ["1", "Normal", "normal"] for style_id in normal_style_ids: normal_elem = styles_element.find( f'.//{qn("w:style")}[@{qn("w:styleId")}="{style_id}"]' ) if normal_elem is not None: _apply_spacing_fix_to_style_elem(normal_elem) # 也尝试通过名称查找(可能有其他命名的 Normal 样式) for style_elem in styles_element.findall(qn("w:style")): name_elem = style_elem.find(qn("w:name")) if name_elem is not None: name_val = name_elem.get(qn("w:val")) if name_val and name_val.lower() == "normal": _apply_spacing_fix_to_style_elem(style_elem) except Exception as e: # 如果修正失败,不影响文档生成,只是可能保留默认的 10 磅间距 print(f"警告: 修正 Normal 样式段后间距失败: {e}") pass def _apply_spacing_fix_to_style_elem(style_elem): """对单个样式元素应用间距修复""" try: # 找到或创建 pPr 节点 pPr_elem = style_elem.find(qn("w:pPr")) if pPr_elem is None: pPr_elem = OxmlElement("w:pPr") # 插入到第一个位置(在 name 之后) name_elem = style_elem.find(qn("w:name")) if name_elem is not None: idx = list(style_elem).index(name_elem) + 1 style_elem.insert(idx, pPr_elem) else: style_elem.insert(0, pPr_elem) # 找到或创建 spacing 节点 spacing_elem = pPr_elem.find(qn("w:spacing")) if spacing_elem is None: spacing_elem = OxmlElement("w:spacing") pPr_elem.append(spacing_elem) # 确保 w:after="0"(明确设置为 0,而不是依赖默认值) spacing_elem.set(qn("w:after"), "0") except Exception: pass def _update_normal_style_if_needed(doc: Document, blocks: list[dict]): """更新 Normal 样式以匹配 blocks 中最常用的字体 这样可以确保空行在 Word 中显示正确的字体和字号 """ # 统计段落中最常用的字体和字号 font_counts = {} size_counts = {} for block in blocks: if block['type'] == 'paragraph': style = block.get('style', {}) font_name = style.get('font_name') font_size = style.get('font_size') if font_name: font_counts[font_name] = font_counts.get(font_name, 0) + 1 if font_size: size_counts[font_size] = size_counts.get(font_size, 0) + 1 # 找到最常用的字体和字号 most_common_font = max(font_counts.items(), key=lambda x: x[1])[0] if font_counts else None most_common_size = max(size_counts.items(), key=lambda x: x[1])[0] if size_counts else None # 如果找到了常用字体或字号,更新 Normal 样式 if most_common_font or most_common_size: try: normal_style = doc.styles['Normal'] if most_common_font: # 修改 Normal 样式的字体 style_element = normal_style.element rPr = style_element.find(qn('w:rPr')) if rPr is None: rPr = OxmlElement('w:rPr') # 插入到第一个子元素之前 if len(style_element): style_element.insert(0, rPr) else: style_element.append(rPr) rFonts = rPr.find(qn('w:rFonts')) if rFonts is None: rFonts = OxmlElement('w:rFonts') rPr.append(rFonts) # 设置所有字体属性 rFonts.set(qn('w:ascii'), most_common_font) rFonts.set(qn('w:hAnsi'), most_common_font) rFonts.set(qn('w:eastAsia'), most_common_font) if most_common_size: # 修改 Normal 样式的字号 style_element = normal_style.element rPr = style_element.find(qn('w:rPr')) if rPr is None: rPr = OxmlElement('w:rPr') if len(style_element): style_element.insert(0, rPr) else: style_element.append(rPr) # 删除旧的字号元素 old_sz = rPr.find(qn('w:sz')) if old_sz is not None: rPr.remove(old_sz) old_szCs = rPr.find(qn('w:szCs')) if old_szCs is not None: rPr.remove(old_szCs) # 添加新的字号元素 sz = OxmlElement('w:sz') sz.set(qn('w:val'), str(int(most_common_size * 2))) # Word 使用半磅 rPr.append(sz) szCs = OxmlElement('w:szCs') szCs.set(qn('w:val'), str(int(most_common_size * 2))) rPr.append(szCs) except Exception: # 如果修改样式失败,继续(不影响文档生成) pass def _inject_numbering_via_zip(docx_bytes: bytes, style_data: dict) -> bytes: """通过 ZIP 操作注入编号格式到 Word 文档 Args: docx_bytes: 原始 Word 文档字节流 style_data: 样式数据(包含 numbering 定义) Returns: 注入编号格式后的 Word 文档字节流 """ numbering_def = style_data.get("numbering") if not numbering_def: # 没有编号定义,直接返回原文档 return docx_bytes try: from zipfile import ZipFile from lxml import etree # 读取原文档 input_buf = io.BytesIO(docx_bytes) output_buf = io.BytesIO() with ZipFile(input_buf, 'r') as zip_read: with ZipFile(output_buf, 'w') as zip_write: # 复制所有文件 for item in zip_read.infolist(): data = zip_read.read(item.filename) # 跳过 numbering.xml,我们会重新写入 if item.filename == 'word/numbering.xml': continue zip_write.writestr(item, data) # 将 JSON 格式的编号定义转换为 XML numbering_element = dict_to_element(numbering_def) numbering_xml = etree.tostring( numbering_element, encoding='UTF-8', xml_declaration=True, standalone=True ) # 写入 numbering.xml zip_write.writestr('word/numbering.xml', numbering_xml) # 确保 _rels/document.xml.rels 中有 numbering 的关系 # 读取 document.xml.rels try: rels_data = zip_read.read('word/_rels/document.xml.rels') rels_root = etree.fromstring(rels_data) # 检查是否已有 numbering 关系 ns = {'r': 'http://schemas.openxmlformats.org/package/2006/relationships'} numbering_rels = rels_root.xpath( '//r:Relationship[@Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering"]', namespaces=ns ) if not numbering_rels: # 添加 numbering 关系 from docx.oxml.ns import qn rel_elem = etree.SubElement(rels_root, qn('r:Relationship')) # 找到最大的 rId existing_ids = [int(r.get('Id')[3:]) for r in rels_root.findall(qn('r:Relationship')) if r.get('Id', '').startswith('rId')] next_id = max(existing_ids) + 1 if existing_ids else 1 rel_elem.set('Id', f'rId{next_id}') rel_elem.set('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering') rel_elem.set('Target', 'numbering.xml') # 写回 rels 文件 rels_xml = etree.tostring(rels_root, encoding='UTF-8', xml_declaration=True) zip_write.writestr('word/_rels/document.xml.rels', rels_xml) except KeyError: # 如果没有 rels 文件,忽略 pass return output_buf.getvalue() except Exception as e: # 如果注入失败,返回原文档 print(f"警告: 通过 ZIP 注入编号格式失败: {e}") return docx_bytes def _render_heading_block(doc: Document, block: dict, style_map: dict): """渲染标题块(支持编号格式和自定义样式)- 增强版""" level = block['level'] content = block['content'] style_name = block.get('word_style', f'Heading {level}') block_style = block.get('style', {}) # 创建段落 para = doc.add_paragraph() # 应用 Word 样式 style_id = _resolve_style_id(style_map, style_name, f'Heading {level}') if style_id: try: para.style = doc.styles[style_id] except KeyError: para.style = f'Heading {level}' else: para.style = f'Heading {level}' # 应用 Block 级自定义样式(段落级) _apply_paragraph_style(para, block_style) # 渲染内容 if isinstance(content, list): # 富文本:应用 run 级样式 _render_rich_text(para, content, block_style) else: # 纯文本:应用 block 级样式到 run run = para.add_run(str(content)) # 标题也需要应用自定义样式(如果有的话) if block_style: _apply_run_style(run, block_style) # 尝试应用编号格式(如果样式中包含编号定义) try: # 检查样式是否有编号定义 style_element = para.style.element if style_element is not None: from docx.oxml.ns import qn # 查找样式中的编号属性 pPr = style_element.find(qn('w:pPr')) if pPr is not None: numPr = pPr.find(qn('w:numPr')) if numPr is not None: # 样式中有编号定义,复制到段落 para_pPr = para._element.get_or_add_pPr() # 移除可能存在的旧编号属性 old_numPr = para_pPr.find(qn('w:numPr')) if old_numPr is not None: para_pPr.remove(old_numPr) # 复制编号属性 import copy para_pPr.append(copy.deepcopy(numPr)) except Exception as e: # 如果应用编号失败,继续(标题仍然会显示,只是没有编号) pass def _render_paragraph_block(doc: Document, block: dict, style_map: dict): """渲染段落块(支持富文本和自定义样式)- 增强版""" content = block['content'] style_name = block.get('word_style', 'Normal') block_style = block.get('style', {}) para = doc.add_paragraph() # 应用 Word 样式 style_id = _resolve_style_id(style_map, style_name, 'Normal') if style_id: try: para.style = doc.styles[style_id] except KeyError: para.style = 'Normal' else: para.style = 'Normal' # 应用 Block 级自定义样式(段落级) _apply_paragraph_style(para, block_style) # 渲染内容 if isinstance(content, list): # 富文本:应用 run 级样式 _render_rich_text(para, content, block_style) elif content: # 有内容的纯文本 run = para.add_run(str(content)) _apply_run_style(run, block_style) else: # 空内容 - 关键修复:确保空段落也能保留样式 # 创建空 run 并应用样式,这样用户在 Word 中输入文本时会自动应用这些样式 run = para.add_run('') _apply_run_style(run, block_style) # 对于空段落,还需要确保段落格式正确 # 特别是字体和字号,即使 run 是空的也要设置 if block_style.get('font_name') or block_style.get('font_size'): # 再添加一个空格符 run 来"激活"样式(Word 的特殊处理) # 然后立即删除,但样式会保留 try: # 方法:在段落属性中设置默认 run 属性 pPr = para._element.get_or_add_pPr() rPr = pPr.find(qn('w:rPr')) if rPr is None: rPr = OxmlElement('w:rPr') pPr.insert(0, rPr) # 设置字体 if block_style.get('font_name'): font_name = block_style['font_name'] rFonts = rPr.find(qn('w:rFonts')) if rFonts is None: rFonts = OxmlElement('w:rFonts') rPr.append(rFonts) rFonts.set(qn('w:ascii'), font_name) rFonts.set(qn('w:hAnsi'), font_name) rFonts.set(qn('w:eastAsia'), font_name) rFonts.set(qn('w:cs'), font_name) # 设置字号 if block_style.get('font_size'): size_pt = float(block_style['font_size']) # 移除旧的字号 for sz in rPr.findall(qn('w:sz')): rPr.remove(sz) for szCs in rPr.findall(qn('w:szCs')): rPr.remove(szCs) # 添加新的字号 sz = OxmlElement('w:sz') sz.set(qn('w:val'), str(int(size_pt * 2))) rPr.append(sz) szCs = OxmlElement('w:szCs') szCs.set(qn('w:val'), str(int(size_pt * 2))) rPr.append(szCs) except Exception: pass def _render_rich_text(para, segments: list, block_style: dict = None): """渲染富文本格式 - 增强版""" for seg in segments: text = seg.get('text', '') seg_style = seg.get('style', {}) run = para.add_run(text) # 合并样式:block_style 作为默认,seg_style 覆盖 merged_style = {} if block_style: merged_style.update(block_style) merged_style.update(seg_style) # 应用合并后的样式 if merged_style: _apply_run_style(run, merged_style) # 处理 word_style(如果片段有独立的 word_style) word_style = seg.get('word_style') if word_style: # 注意:run 不能直接应用样式,只能应用字符样式 # 这里我们只应用格式属性 pass def _render_table_block(doc: Document, block: dict, style_map: dict): """渲染表格块(支持合并单元格、列宽、行高等)""" table_data = block['content'] if isinstance(table_data, str): try: table_data = json.loads(table_data) except json.JSONDecodeError: return rows = table_data.get('rows', []) if not rows: return # 使用 col_widths 确定真实列数(而不是第一行的单元格数) # 注意: col_widths 应该从 metadata 中获取, 而不是 content metadata = block.get('metadata', {}) col_widths = metadata.get('col_widths', []) if col_widths: num_cols = len(col_widths) else: # 回退:扫描所有行,找到最大的列索引 num_cols = 0 for row_data in rows: col_index = 0 for cell_data in row_data.get('cells', []): colspan = cell_data.get('colspan', 1) col_index += colspan num_cols = max(num_cols, col_index) if num_cols == 0: return num_rows = len(rows) # 创建表格 table = doc.add_table(rows=num_rows, cols=num_cols) # 应用表格样式 table_style = block.get('word_style', 'Table Grid') try: table.style = table_style except KeyError: table.style = 'Table Grid' # 设置表格对齐方式(默认居中) block_style = block.get('style', {}) table_align = block_style.get('table_align', 'center') # 默认居中 if table_align == 'center': table.alignment = WD_TABLE_ALIGNMENT.CENTER elif table_align == 'left': table.alignment = WD_TABLE_ALIGNMENT.LEFT elif table_align == 'right': table.alignment = WD_TABLE_ALIGNMENT.RIGHT # 设置列宽 # 注意: col_widths 中存储的是百分比值(如 [50, 50] 表示两列各占50%) # 需要根据表格总宽度计算实际列宽(磅值) if col_widths: # 获取表格总宽度设置 table_width = metadata.get('table_width', 100) table_width_unit = metadata.get('table_width_unit', 'percent') # 计算表格实际宽度(磅) if table_width_unit == 'percent': # 百分比模式:基于页面可用宽度计算 # 假设 A4 纸张,页面宽度约 595磅(21cm),左右边距各约71磅(2.5cm) # 可用宽度 = 595 - 71 - 71 = 453 磅 page_available_width_pt = 453.0 # 可以从 style_data 中的 page_setup 获取更精确的值 actual_table_width_pt = page_available_width_pt * (table_width / 100.0) elif table_width_unit == 'cm': # 厘米转磅: 1cm = 28.35磅 actual_table_width_pt = table_width * 28.35 elif table_width_unit == 'inch': # 英寸转磅: 1inch = 72磅 actual_table_width_pt = table_width * 72.0 else: # 默认使用百分比模式 page_available_width_pt = 453.0 actual_table_width_pt = page_available_width_pt * (table_width / 100.0) # 计算列宽百分比总和,用于归一化 col_widths_sum = sum(col_widths) # 根据百分比计算每列的实际宽度并设置 # 注意:为了避免因百分比总和不为100而导致的问题,我们基于实际总和进行归一化 for col_idx, width_percent in enumerate(col_widths): if col_idx < len(table.columns): # 归一化:基于实际的百分比总和计算每列占表格宽度的比例 # 例如:如果7列各14%,总和98%,则每列实际占 14/98 的表格宽度 if col_widths_sum > 0: col_width_pt = actual_table_width_pt * (width_percent / col_widths_sum) else: # 如果总和为0,平均分配 col_width_pt = actual_table_width_pt / len(col_widths) table.columns[col_idx].width = Pt(col_width_pt) # 填充内容并处理合并单元格 merge_map = {} # {(row, col): (end_row, end_col)} 记录合并区域 occupied = {} # {(row, col): True} 记录哪些位置已被占用(被合并的单元格) for r_idx, row_data in enumerate(rows): # 设置行高 row_height = row_data.get('height') if row_height: table.rows[r_idx].height = Pt(row_height) cells_data = row_data.get('cells', []) # 遍历单元格数据 # 注意:单元格在 cells 数组中的索引就是它的列索引(col_idx) col_offset = 0 # 当前应该填充到哪一列 for cell_idx, cell_data in enumerate(cells_data): # 跳过被上方合并单元格占用的列 while occupied.get((r_idx, col_offset), False): col_offset += 1 if col_offset >= num_cols: break if col_offset >= num_cols: break # 超出列数,停止处理 # 跳过被合并的单元格(rowspan=0 或 colspan=0 表示这个单元格被合并了) rowspan = cell_data.get('rowspan', 1) colspan = cell_data.get('colspan', 1) if rowspan == 0 or colspan == 0: # 这个单元格已被合并,跳过 continue # 获取起始单元格 start_cell = table.rows[r_idx].cells[col_offset] # 处理合并单元格 if colspan > 1 or rowspan > 1: # 计算结束位置 end_col = min(col_offset + colspan - 1, num_cols - 1) end_row = min(r_idx + rowspan - 1, num_rows - 1) # 合并单元格 if end_col > col_offset or end_row > r_idx: try: end_cell = table.rows[end_row].cells[end_col] start_cell.merge(end_cell) merge_map[(r_idx, col_offset)] = (end_row, end_col) # 标记被合并的单元格位置为已占用 for merge_r in range(r_idx, end_row + 1): for merge_c in range(col_offset, end_col + 1): if merge_r != r_idx or merge_c != col_offset: # 不标记起始单元格 occupied[(merge_r, merge_c)] = True except Exception as e: pass # 合并失败,继续 # 设置单元格宽度(如果有) cell_width = cell_data.get('width') if cell_width: try: start_cell.width = Pt(cell_width) except Exception: pass # 填充单元格内容 cell_text = cell_data.get('text', '') cell_style = cell_data.get('style', {}) cell_word_style = cell_data.get('word_style') # 获取单元格的 word_style # 设置单元格垂直对齐(默认居中) valign = cell_style.get('valign', 'center') # 默认垂直居中 if valign == 'center' or valign == 'middle': start_cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER elif valign == 'top': start_cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.TOP elif valign == 'bottom': start_cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.BOTTOM # 清空默认段落 start_cell.text = '' para = start_cell.paragraphs[0] # 应用单元格的 Word 样式(如果有) if cell_word_style: # 尝试从 style_map 解析样式ID style_id = _resolve_style_id(style_map, cell_word_style) if style_id: # 通过 style_id 应用样式 try: para.style = doc.styles[style_id] except KeyError: # 如果 style_id 不存在,尝试直接使用名称 try: para.style = cell_word_style except KeyError: # 都失败了,使用 Normal para.style = 'Normal' else: # 没有找到 style_id,尝试直接使用名称 try: para.style = cell_word_style except KeyError: # 失败了,使用 Normal para.style = 'Normal' # 应用单元格段落级样式(对齐) _apply_paragraph_style(para, cell_style) # 渲染单元格内容(支持富文本) if isinstance(cell_text, list): # 富文本格式 _render_rich_text(para, cell_text, cell_style) else: # 纯文本格式 run = para.add_run(str(cell_text)) # 应用单元格 run 级样式 _apply_run_style(run, cell_style) # 移动到下一列位置(考虑colspan) col_offset += colspan def _render_image_block(doc: Document, block: dict, style_map: dict = None): """渲染图片块(支持 Base64 Data URL 和 Word 样式)""" content = block['content'] style = block.get('style', {}) word_style = block.get('word_style', 'Normal') # 只处理 Data URL if not isinstance(content, str) or not content.startswith('data:'): return try: # 解析 data:image/png;base64,xxxxx if ',' not in content: return header, b64_data = content.split(',', 1) image_bytes = base64.b64decode(b64_data) # 创建段落并应用 Word 样式 paragraph = doc.add_paragraph() # 应用 Word 样式(如"图表标题") if style_map: style_id = _resolve_style_id(style_map, word_style, 'Normal') if style_id: try: paragraph.style = doc.styles[style_id] except KeyError: # 如果找不到样式ID,尝试使用样式名称 try: paragraph.style = word_style except KeyError: paragraph.style = 'Normal' else: # 如果没有找到映射,尝试直接使用 word_style try: paragraph.style = word_style except KeyError: paragraph.style = 'Normal' else: # 没有 style_map,尝试直接使用 word_style try: paragraph.style = word_style except KeyError: paragraph.style = 'Normal' # 设置对齐方式(可能覆盖样式中的对齐) align = style.get('align', 'left') if align == 'center': paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER elif align == 'right': paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT elif align == 'left': paragraph.alignment = WD_ALIGN_PARAGRAPH.LEFT # 插入图片 run = paragraph.add_run() width = style.get('width', 10.0) height = style.get('height', 7.0) unit = style.get('unit', 'cm') # 转换为磅(Word内部单位:1厘米 = 28.35磅,1英寸 = 72磅) if unit == 'cm': width_pt = width * 28.35 height_pt = height * 28.35 else: # inches width_pt = width * 72 height_pt = height * 72 run.add_picture( io.BytesIO(image_bytes), width=Pt(width_pt), height=Pt(height_pt) ) except Exception as e: # 失败时添加占位文本 p = doc.add_paragraph(f"[图片加载失败]") p.runs[0].font.color.rgb = RGBColor(255, 0, 0) def _set_update_fields_on_open(doc: Document): """在文档设置中写入 updateFields,打开时自动触发域更新 这样用户在 Word/WPS 中打开文档时,会自动更新所有域(包括目录和页码) """ try: settings = doc.settings.element update_fields = OxmlElement('w:updateFields') update_fields.set(qn('w:val'), 'true') settings.append(update_fields) except Exception as e: print(f"警告: 设置自动更新域失败: {e}") def _render_toc_block(doc: Document, block: dict): """渲染目录块 Args: doc: python-docx Document 对象 block: TOC block 数据 """ # 获取目录标题和配置 content = block.get('content', {}) if isinstance(content, dict): toc_title = content.get('title', '目录') else: toc_title = '目录' metadata = block.get('metadata', {}) toc_config = metadata.get('toc_config', {}) # 获取配置参数 levels = toc_config.get('levels', '1-3') # 默认包含1-3级标题 use_hyperlinks = toc_config.get('use_hyperlinks', True) use_outline_levels = toc_config.get('use_outline_levels', True) # 0. 在目录前添加分页符(让目录从新页开始) doc.add_page_break() # 1. 添加目录标题(可选) if toc_title: title_para = doc.add_paragraph(toc_title) title_para.style = 'Normal' # 使用正文样式 title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER # 2. 插入目录域 toc_para = doc.add_paragraph() _create_toc_field(toc_para, levels, use_hyperlinks, use_outline_levels) # 3. 添加分节符(目录后开始新节,页码重新编号) # 使用分节符而不是简单的分页符,这样可以: # - 目录单独占一节(不显示页码或显示罗马数字) # - 正文从新节开始,页码从1开始 paragraph = doc.add_paragraph() run = paragraph.add_run() # 插入分节符(nextPage 类型:下一页开始新节) from docx.enum.section import WD_SECTION paragraph._element.getparent().remove(paragraph._element) # 移除空段落 # 添加一个新节 new_section = doc.add_section(WD_SECTION.NEW_PAGE) # 4. 设置新节的页码(如果配置要求) if toc_config.get('use_page_numbers', True): # 为新节(正文部分)添加页码,从1开始 _add_page_number_footer_with_restart(doc, new_section) def _create_toc_field(paragraph, levels: str = '1-3', use_hyperlinks: bool = True, use_outline_levels: bool = True): """在段落中创建 TOC 域代码 Args: paragraph: 段落对象 levels: 包含的标题层级,如 "1-3" 表示 1-3 级标题 use_hyperlinks: 是否使用超链接 use_outline_levels: 是否使用大纲级别 """ run = paragraph.add_run() # 开始域字符 fldChar_begin = OxmlElement('w:fldChar') fldChar_begin.set(qn('w:fldCharType'), 'begin') fldChar_begin.set(qn('w:dirty'), '1') # 标记域需要更新 # 域代码指令 # TOC 域代码格式:TOC \o "1-3" \h \z \u # \o "1-3": 使用大纲级别 1-3 # \h: 使用超链接 # \z: 隐藏 Web 视图中的页码 # \u: 使用 Unicode instrText = OxmlElement('w:instrText') instrText.set(qn('xml:space'), 'preserve') toc_code = f'TOC \\o "{levels}"' if use_hyperlinks: toc_code += ' \\h' toc_code += ' \\z \\u' # 标准选项 instrText.text = toc_code # 分隔符 fldChar_sep = OxmlElement('w:fldChar') fldChar_sep.set(qn('w:fldCharType'), 'separate') # 占位文字(更新域后会被真实目录替换) placeholder_r = OxmlElement('w:r') placeholder_rpr = OxmlElement('w:rPr') placeholder_color = OxmlElement('w:color') placeholder_color.set(qn('w:val'), '808080') # 灰色提示 placeholder_rpr.append(placeholder_color) placeholder_r.append(placeholder_rpr) # 结束域字符 fldChar_end = OxmlElement('w:fldChar') fldChar_end.set(qn('w:fldCharType'), 'end') # 将所有元素添加到 run run._r.extend([fldChar_begin, instrText, fldChar_sep, placeholder_r, fldChar_end]) def _add_page_number_footer(doc: Document): """在页脚居中插入「第 X 页 / 共 Y 页」 Args: doc: python-docx Document 对象 """ try: section = doc.sections[0] section.footer_distance = Pt(20) # 页脚距底边设置 footer = section.footer footer.is_linked_to_previous = False # 清空默认段落并居中 para = footer.paragraphs[0] para.clear() para.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER para.paragraph_format.space_before = Pt(8) para.paragraph_format.space_after = Pt(15) def add_field(run_elem, field_type): """向 run 的 XML 元素里插入一个域""" fldChar_b = OxmlElement('w:fldChar') fldChar_b.set(qn('w:fldCharType'), 'begin') instr = OxmlElement('w:instrText') instr.set(qn('xml:space'), 'preserve') instr.text = field_type fldChar_s = OxmlElement('w:fldChar') fldChar_s.set(qn('w:fldCharType'), 'separate') fldChar_e = OxmlElement('w:fldChar') fldChar_e.set(qn('w:fldCharType'), 'end') run_elem.extend([fldChar_b, instr, fldChar_s, fldChar_e]) r1 = para.add_run("第 ") r2 = para.add_run() add_field(r2._r, ' PAGE ') # 当前页码 r3 = para.add_run(" 页 / 共 ") r4 = para.add_run() add_field(r4._r, ' NUMPAGES ') # 总页数 para.add_run(" 页") except Exception as e: print(f"警告: 添加页码页脚失败: {e}") def _add_page_number_footer_with_restart(doc: Document, section): """在指定节的页脚居中插入页码,并设置从1开始编号 Args: doc: python-docx Document 对象 section: 要添加页码的节 """ try: # 设置页脚距底边 section.footer_distance = Pt(20) # 获取页脚,不链接到前面的节 footer = section.footer footer.is_linked_to_previous = False # 清空默认段落并居中 para = footer.paragraphs[0] para.clear() para.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER para.paragraph_format.space_before = Pt(8) para.paragraph_format.space_after = Pt(15) def add_field(run_elem, field_type): """向 run 的 XML 元素里插入一个域""" fldChar_b = OxmlElement('w:fldChar') fldChar_b.set(qn('w:fldCharType'), 'begin') instr = OxmlElement('w:instrText') instr.set(qn('xml:space'), 'preserve') instr.text = field_type fldChar_s = OxmlElement('w:fldChar') fldChar_s.set(qn('w:fldCharType'), 'separate') fldChar_e = OxmlElement('w:fldChar') fldChar_e.set(qn('w:fldCharType'), 'end') run_elem.extend([fldChar_b, instr, fldChar_s, fldChar_e]) r1 = para.add_run("第 ") r2 = para.add_run() add_field(r2._r, ' PAGE ') # 当前页码 r3 = para.add_run(" 页 / 共 ") r4 = para.add_run() add_field(r4._r, ' SECTIONPAGES ') # 当前节的总页数(只计算正文,不包括目录) para.add_run(" 页") # 设置该节的页码从1开始 # 通过修改节属性中的 pageNum 设置 sectPr = section._sectPr pgNumType = sectPr.find(qn('w:pgNumType')) if pgNumType is None: pgNumType = OxmlElement('w:pgNumType') sectPr.append(pgNumType) pgNumType.set(qn('w:start'), '1') # 从1开始编号 except Exception as e: print(f"警告: 添加页码页脚(带重启)失败: {e}") # ------------------------------------------------------------------ # # 公共工具 # ------------------------------------------------------------------ # def _safe_filename(name: str) -> str: """生成安全的文件名""" name = unicodedata.normalize("NFKC", name) for ch in r'\/:*?"<>|': name = name.replace(ch, "_") return name.strip() or "document" def _make_filename(blocks: list[dict]) -> str: """从 blocks 中提取第一个标题或段落作为文件名 + 时间戳 Args: blocks: Block 列表 Returns: 文件名(不含扩展名) """ # 查找第一个标题或段落 first_text = "" for block in blocks: if block['type'] in ('heading', 'paragraph'): content = block['content'] if isinstance(content, list): # 富文本:拼接所有片段 first_text = "".join(seg.get("text", "") for seg in content) else: first_text = str(content) if first_text.strip(): break # 提取第一行 first_line = first_text.split('\n')[0].strip() safe = _safe_filename(first_line) if first_line else "document" # 限制长度 if len(safe) > 50: safe = safe[:50] ts = int(time.time() * 1000) return f"{safe}_{ts}"