| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928 |
- """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
- 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)
- 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
- # ------------------------------------------------------------------ #
- # 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)
-
- # 检查 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)
-
- # 先保存到临时缓冲区
- buf = io.BytesIO()
- doc.save(buf)
-
- # 通过 ZIP 操作注入编号格式
- docx_bytes = _inject_numbering_via_zip(buf.getvalue(), style_data)
-
- return docx_bytes
- 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):
- """渲染图片块(支持 Base64 Data URL)"""
- content = block['content']
- style = block.get('style', {})
-
- # 只处理 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)
-
- # 创建段落并设置对齐
- paragraph = doc.add_paragraph()
-
- align = style.get('align', 'left')
- if align == 'center':
- paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
- elif align == 'right':
- paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
-
- # 插入图片
- 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}"
|