"""将 docx 文件转为保留样式的 HTML,每个元素标记 data-block-index 用于框选定位。"""
import base64
import html as html_module
import io
import json
import re
from dataclasses import dataclass
from typing import Union
from docx import Document
from docx.document import Document as DocumentObject
from docx.image.exceptions import UnrecognizedImageError
from docx.opc.constants import RELATIONSHIP_TYPE as RT
from docx.oxml.ns import qn
from docx.oxml.table import CT_Tbl
from docx.oxml.text.paragraph import CT_P
from docx.table import Table
from docx.text.paragraph import Paragraph
@dataclass
class RenderedBlock:
"""单个渲染块 —— 对应模板中的一个 Block。"""
block_index: int
block_type: str # heading / text / table / image
html: str # 该块的 HTML 片段
text_content: str # 纯文本(用于块列表展示)
style_json: str # 样式快照 JSON
table_json: str # 表格结构 JSON(仅 table 类型有值)
# ---------------------------------------------------------------------------
# 工具函数
# ---------------------------------------------------------------------------
def _escape(text: str) -> str:
return html_module.escape(text)
def _safe_pt(value) -> float | None:
if value is None:
return None
try:
return round(float(value.pt), 2)
except AttributeError:
return None
def _safe_indent(value) -> float | None:
if value is None:
return None
try:
return round(float(value.pt), 2)
except AttributeError:
return None
def _heading_level(style_name: str) -> int | None:
if not style_name:
return None
normalized = style_name.lower().replace(" ", "")
if normalized.startswith("heading"):
level = normalized.replace("heading", "")
if level.isdigit():
return int(level)
return None
def _get_list_info(paragraph: Paragraph) -> tuple[bool, int, str | None]:
"""检测段落是否为列表项,返回 (is_list, level, list_type)。
list_type: 'ordered' / 'unordered' / None
level: 缩进层级(0-based)
"""
pPr = paragraph._element.pPr
if pPr is None:
return False, 0, None
numPr = pPr.find(qn("w:numPr"))
if numPr is None:
return False, 0, None
ilvl_elem = numPr.find(qn("w:ilvl"))
numId_elem = numPr.find(qn("w:numId"))
if numId_elem is None:
return False, 0, None
level = int(ilvl_elem.get(qn("w:val"))) if ilvl_elem is not None else 0
# 尝试从文档 numbering 部分获取列表类型
list_type = "unordered"
try:
num_id_val = numId_elem.get(qn("w:numId"))
if num_id_val is not None:
document = paragraph._parent
# 查找 numbering 定义
numbering_part = None
if hasattr(document, 'part'):
numbering_part = document.part.numbering_part
elif hasattr(paragraph._parent, 'part'):
numbering_part = paragraph._parent.part.numbering_part
if numbering_part is not None:
numbering_xml = numbering_part._element
# 查找 numId 对应的 abstractNumId
num_elem = numbering_xml.find(f'.//w:num[@w:numId="{num_id_val}"]',
{'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'})
if num_elem is not None:
abstract_num_ref = num_elem.find(qn("w:abstractNumId"))
if abstract_num_ref is not None:
abstract_num_id = abstract_num_ref.get(qn("w:val"))
# 查找 abstractNum 的 lvl 定义
for lvl_elem in numbering_xml.findall(
f'.//w:abstractNum[@w:abstractNumId="{abstract_num_id}"]/w:lvl',
{'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
):
lvl_ilvl = lvl_elem.get(qn("w:ilvl"))
if lvl_ilvl is not None and int(lvl_ilvl) == level:
num_fmt = lvl_elem.find(qn("w:numFmt"))
if num_fmt is not None:
fmt_val = num_fmt.get(qn("w:val"))
if fmt_val == "bullet":
list_type = "unordered"
else:
list_type = "ordered"
break
except Exception:
pass
return True, level, list_type
def _alignment_css(paragraph: Paragraph) -> str:
from docx.enum.text import WD_ALIGN_PARAGRAPH
mapping = {
WD_ALIGN_PARAGRAPH.CENTER: "center",
WD_ALIGN_PARAGRAPH.RIGHT: "right",
WD_ALIGN_PARAGRAPH.JUSTIFY: "justify",
}
return mapping.get(paragraph.alignment, "left")
def _get_run_style(run) -> dict:
"""提取 run 的字体样式。"""
font = run.font
color = "#000000"
if font.color is not None and font.color.rgb is not None:
color = f"#{font.color.rgb}"
size = _safe_pt(font.size)
return {
"name": font.name,
"size": size,
"bold": bool(font.bold) if font.bold is not None else False,
"italic": bool(font.italic) if font.italic is not None else False,
"underline": bool(font.underline) if font.underline is not None else False,
"color": color,
}
def _run_style_inline(run) -> str:
"""将 run 的字体样式转为 CSS inline。"""
s = _get_run_style(run)
css = ""
if s["name"]:
css += f"font-family:'{s['name']}',sans-serif;"
if s["size"]:
css += f"font-size:{s['size']}pt;"
if s["bold"]:
css += "font-weight:bold;"
if s["italic"]:
css += "font-style:italic;"
if s["underline"]:
css += "text-decoration:underline;"
if s["color"] and s["color"] != "#000000":
css += f"color:{s['color']};"
return css
def _para_style_inline(paragraph: Paragraph) -> str:
"""段落级别样式(对齐、间距、缩进)。"""
fmt = paragraph.paragraph_format
css = ""
css += f"text-align:{_alignment_css(paragraph)};"
before = _safe_pt(fmt.space_before)
after = _safe_pt(fmt.space_after)
if before:
css += f"margin-top:{before}pt;"
if after:
css += f"margin-bottom:{after}pt;"
line_spacing = fmt.line_spacing
if line_spacing is not None:
if isinstance(line_spacing, float):
css += f"line-height:{line_spacing};"
else:
try:
css += f"line-height:{round(float(line_spacing.pt), 2)}pt;"
except AttributeError:
pass
indent = _safe_indent(fmt.first_line_indent)
if indent:
css += f"text-indent:{indent}pt;"
return css
def _capture_style_snapshot(paragraph: Paragraph, level: int = 0) -> dict:
"""段落样式快照(用于后续导出时还原)。"""
fmt = paragraph.paragraph_format
first_run_style = None
for run in paragraph.runs:
if run.text.strip():
first_run_style = _get_run_style(run)
break
return {
"font": first_run_style or {},
"paragraph": {
"alignment": _alignment_css(paragraph),
"spaceBefore": _safe_pt(fmt.space_before),
"spaceAfter": _safe_pt(fmt.space_after),
"lineSpacing": fmt.line_spacing,
"firstLineIndent": _safe_indent(fmt.first_line_indent),
},
"headingLevel": level,
}
# ---------------------------------------------------------------------------
# 图片提取
# ---------------------------------------------------------------------------
def _extract_images_from_docx(file_path: str) -> dict[str, str]:
"""提取 docx 中的所有图片,返回 {rId: base64_data_uri} 映射。"""
document = Document(file_path)
images: dict[str, str] = {}
for rel in document.part.rels.values():
if "image" in rel.reltype:
try:
image_bytes = rel.target_part.blob
ext = rel.target_part.partname.split(".")[-1].lower()
mime_map = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
"gif": "image/gif", "bmp": "image/bmp", "webp": "image/webp", "svg": "image/svg+xml"}
mime = mime_map.get(ext, "image/png")
data_uri = f"data:{mime};base64,{base64.b64encode(image_bytes).decode('ascii')}"
images[rel.rId] = data_uri
except Exception:
continue
return images
def _paragraph_contains_image(paragraph: Paragraph) -> bool:
"""检查段落是否包含内嵌图片。"""
drawings = paragraph._element.findall(".//" + qn("w:drawing"))
return len(drawings) > 0
def _render_image_html(paragraph: Paragraph, images: dict[str, str], block_index: int) -> str:
"""渲染段落中的图片为 HTML img 标签。"""
nsmap = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
"pic": "http://schemas.openxmlformats.org/drawingml/2006/picture",
}
parts: list[str] = []
drawings = paragraph._element.findall(".//" + qn("w:drawing"))
for drawing in drawings:
blip = drawing.find(".//" + qn("a:blip"), nsmap)
if blip is None:
continue
r_embed = blip.get(qn("r:embed"))
if r_embed and r_embed in images:
src = images[r_embed]
# 尝试获取图片尺寸
ext = drawing.find(".//" + qn("wp:extent"), nsmap)
cx = ext.get("cx") if ext is not None else None
cy = ext.get("cy") if ext is not None else None
style = "max-width:100%;height:auto;"
if cx and cy:
w = int(cx) // 9525
h = int(cy) // 9525
style = f"width:{w}px;height:{h}px;"
parts.append(f'')
return "\n".join(parts)
# ---------------------------------------------------------------------------
# 表格渲染
# ---------------------------------------------------------------------------
def _parse_border(border_element) -> dict | None:
"""解析单个 CT_Border 元素。"""
if border_element is None:
return None
color = border_element.get(qn("w:color")) or "000000"
sz = border_element.get(qn("w:sz")) or "4"
val = border_element.get(qn("w:val")) or "single"
if val == "nil" or val == "none":
return None
try:
width_px = max(1, int(sz) // 8)
except ValueError:
width_px = 1
return {"color": f"#{color}", "width": f"{width_px}px", "style": val}
def _table_border_css(table: Table) -> str:
"""提取表格整体的边框样式。"""
tbl_pr = table._tbl.tblPr
if tbl_pr is None:
return "border-collapse:collapse;"
borders = tbl_pr.find(qn("w:tblBorders"))
if borders is None:
return "border-collapse:collapse;"
top = _parse_border(borders.find(qn("w:top")))
left = _parse_border(borders.find(qn("w:left")))
bottom = _parse_border(borders.find(qn("w:bottom")))
right = _parse_border(borders.find(qn("w:right")))
inside_h = _parse_border(borders.find(qn("w:insideH")))
inside_v = _parse_border(borders.find(qn("w:insideV")))
rules: list[str] = ["border-collapse:collapse;"]
border_color = "#000000"
border_width = "1px"
if top:
border_color = top["color"]
border_width = top["width"]
elif left:
border_color = left["color"]
border_width = left["width"]
rules.append(f"border:{border_width} solid {border_color};")
rules.append(f"th,td{{border:{border_width} solid {border_color};}}")
return "".join(rules)
def _cell_shading(cell) -> str:
"""提取单元格底纹颜色。"""
tc_pr = cell._tc.tcPr
if tc_pr is None:
return ""
shading = tc_pr.find(qn("w:shd"))
if shading is None:
return ""
fill = shading.get(qn("w:fill"))
if fill and fill != "auto":
return f"background-color:#{fill};"
return ""
def _cell_width(cell) -> str:
"""提取单元格宽度。"""
tc_pr = cell._tc.tcPr
if tc_pr is None:
return ""
tc_w = tc_pr.find(qn("w:tcW"))
if tc_w is None:
return ""
w = tc_w.get(qn("w:w"))
if w:
return f"width:{w};"
return ""
def _cell_v_align(cell) -> str:
"""提取单元格垂直对齐。"""
tc_pr = cell._tc.tcPr
if tc_pr is None:
return ""
v_align = tc_pr.find(qn("w:vAlign"))
if v_align is None:
return ""
val = v_align.get(qn("w:val"))
if val:
return f"vertical-align:{val};"
return ""
def _render_cell_html(cell, is_header: bool = False) -> str:
"""渲染单个单元格为 HTML。"""
tag = "th" if is_header else "td"
colspan = 1
tc_pr = cell._tc.tcPr
if tc_pr is not None and tc_pr.gridSpan is not None:
colspan = tc_pr.gridSpan.val
content_parts: list[str] = []
for paragraph in cell.paragraphs:
para_css = _para_style_inline(paragraph)
inner_spans: list[str] = []
for run in paragraph.runs:
text = _escape(run.text)
if not text:
continue
run_css = _run_style_inline(run)
inner_spans.append(f'{text}')
inner_html = "".join(inner_spans) if inner_spans else " "
content_parts.append(f'
{inner}
' ) blocks.append(RenderedBlock( block_index=block_index, block_type=block_type, html=html_str, text_content=text, style_json=json.dumps(_capture_style_snapshot(item, level or 0), ensure_ascii=False), table_json="{}", )) block_index += 1 elif isinstance(item, Table): table_html = _render_table_html(item) table_structure = _extract_table_structure(item) # 获取表格中第一个非空文本作为描述 first_texts: list[str] = [] for row in item.rows: for cell in row.cells: txt = cell.text.strip() if txt: first_texts.append(txt) break if first_texts: break text_content = " | ".join(first_texts[:3]) if first_texts else f"表格 {table_structure['rows']}行{table_structure['cols']}列" html_str = ( f'