| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216 |
- """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
- # ------------------------------------------------------------------ #
- # 样式文件加载
- # ------------------------------------------------------------------ #
- 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 到文档 <w:styles> 节点"""
- 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)
-
- 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)
-
- # 先保存到临时缓冲区
- 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 = table_data.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
-
- # 设置列宽
- if col_widths:
- for col_idx, width in enumerate(col_widths):
- if col_idx < len(table.columns):
- table.columns[col_idx].width = Pt(width)
-
- # 填充内容并处理合并单元格
- 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', [])
-
- # 构建一个映射:col_index -> cell_data
- cells_by_col = {}
- for cell_data in cells_data:
- col_idx = cell_data.get('col_index', None)
- if col_idx is not None:
- cells_by_col[col_idx] = cell_data
-
- # 遍历所有列
- for col_idx in range(num_cols):
- # 检查这个位置是否被占用(被上方的合并单元格占用)
- if occupied.get((r_idx, col_idx), False):
- continue # 跳过被占用的位置
-
- # 检查是否有数据要填充到这个位置
- if col_idx not in cells_by_col:
- continue # 这个位置没有数据
-
- cell_data = cells_by_col[col_idx]
-
- # 跳过被合并的单元格(rowspan=0 表示这个单元格被上面的单元格合并了)
- rowspan = cell_data.get('rowspan', 1)
- if rowspan == 0:
- continue
-
- colspan = cell_data.get('colspan', 1)
-
- # 确保不越界
- if col_idx >= num_cols:
- continue
-
- # 获取起始单元格
- start_cell = table.rows[r_idx].cells[col_idx]
-
- # 处理合并单元格
- if colspan > 1 or rowspan > 1:
- # 计算结束位置
- end_col = min(col_idx + colspan - 1, num_cols - 1)
- end_row = min(r_idx + rowspan - 1, num_rows - 1)
-
- # 合并单元格
- if end_col > col_idx 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_idx)] = (end_row, end_col)
-
- # 标记被合并的单元格位置为已占用
- for merge_r in range(r_idx, end_row + 1):
- for merge_c in range(col_idx, end_col + 1):
- if merge_r != r_idx or merge_c != col_idx: # 不标记起始单元格
- occupied[(merge_r, merge_c)] = True
- except Exception:
- 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)
- 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 _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}"
|