674 lines
24 KiB
Python
674 lines
24 KiB
Python
"""将 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'<img src="{src}" style="{style}" data-block-index="{block_index}" alt="文档图片" />')
|
||
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'<span style="{run_css}">{text}</span>')
|
||
inner_html = "".join(inner_spans) if inner_spans else " "
|
||
content_parts.append(f'<div style="{para_css};margin:0;">{inner_html}</div>')
|
||
|
||
cell_html = "".join(content_parts) if content_parts else " "
|
||
|
||
style_parts = [
|
||
"padding:4px 6px;",
|
||
_cell_shading(cell),
|
||
_cell_v_align(cell),
|
||
]
|
||
style = "".join(style_parts)
|
||
|
||
attrs = f' style="{style}"'
|
||
if colspan > 1:
|
||
attrs += f" colspan=\"{colspan}\""
|
||
return f"<{tag}{attrs}>{cell_html}</{tag}>"
|
||
|
||
|
||
def _render_table_html(table: Table) -> str:
|
||
"""将 Word 表格渲染为 HTML table。"""
|
||
border_css = _table_border_css(table)
|
||
rows_html: list[str] = []
|
||
for row in table.rows:
|
||
cells_html = "".join(_render_cell_html(cell) for cell in row.cells)
|
||
rows_html.append(f"<tr>{cells_html}</tr>")
|
||
body = "\n".join(rows_html)
|
||
return f'<table style="width:100%;{border_css}">{body}</table>'
|
||
|
||
|
||
def _extract_table_structure(table: Table) -> dict:
|
||
"""提取表格结构化数据(用于后续 AI 生成参考)。"""
|
||
rows = len(table.rows)
|
||
cols = max((len(row.cells) for row in table.rows), default=0)
|
||
data: list[list[str]] = []
|
||
for row in table.rows:
|
||
row_data: list[str] = []
|
||
for cell in row.cells:
|
||
text = "\n".join(p.text.strip() for p in cell.paragraphs if p.text.strip())
|
||
row_data.append(text)
|
||
data.append(row_data)
|
||
return {"rows": rows, "cols": cols, "data": data}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 迭代文档元素
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _iter_block_items(document: DocumentObject):
|
||
"""按文档顺序迭代段落和表格。"""
|
||
body = document.element.body
|
||
for child in body.iterchildren():
|
||
if isinstance(child, CT_P):
|
||
yield Paragraph(child, document)
|
||
elif isinstance(child, CT_Tbl):
|
||
yield Table(child, document)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 主转换函数
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def docx_to_html_blocks(file_path: str) -> list[RenderedBlock]:
|
||
"""将 docx 文件转为 RenderedBlock 列表。
|
||
|
||
每个块对应文档中的一个段落或表格,带有完整的 CSS 内联样式。
|
||
"""
|
||
document = Document(file_path)
|
||
images = _extract_images_from_docx(file_path)
|
||
blocks: list[RenderedBlock] = []
|
||
block_index = 0
|
||
|
||
for item in _iter_block_items(document):
|
||
if isinstance(item, Paragraph):
|
||
text = item.text.strip()
|
||
|
||
# 处理包含图片的段落
|
||
if _paragraph_contains_image(item):
|
||
img_html = _render_image_html(item, images, block_index)
|
||
if img_html:
|
||
blocks.append(RenderedBlock(
|
||
block_index=block_index,
|
||
block_type="image",
|
||
html=f'<div class="block-image" data-block-index="{block_index}" '
|
||
f'style="margin:6pt 0;text-align:center;">{img_html}</div>',
|
||
text_content="[图片]",
|
||
style_json="{}",
|
||
table_json="{}",
|
||
))
|
||
block_index += 1
|
||
# 如果图片段落中也有文字,继续处理文字
|
||
if not text:
|
||
continue
|
||
|
||
if not text:
|
||
# 空段落 —— 渲染为换行
|
||
blocks.append(RenderedBlock(
|
||
block_index=block_index,
|
||
block_type="text",
|
||
html='<div class="block-empty" data-block-index="{}" '
|
||
'style="height:12pt;"></div>'.format(block_index),
|
||
text_content="",
|
||
style_json=json.dumps(_capture_style_snapshot(item), ensure_ascii=False),
|
||
table_json="{}",
|
||
))
|
||
block_index += 1
|
||
continue
|
||
|
||
level = _heading_level(item.style.name if item.style is not None else "")
|
||
if level is not None:
|
||
# 标题块
|
||
para_css = _para_style_inline(item)
|
||
inner_spans: list[str] = []
|
||
for run in item.runs:
|
||
t = _escape(run.text)
|
||
if not t:
|
||
continue
|
||
run_css = _run_style_inline(run)
|
||
inner_spans.append(f'<span style="{run_css}">{t}</span>')
|
||
inner = "".join(inner_spans) or _escape(text)
|
||
block_type = "heading"
|
||
html_str = (
|
||
f'<h{level} class="block-heading" data-block-index="{block_index}" '
|
||
f'style="{para_css}margin:12pt 0 6pt 0;">{inner}</h{level}>'
|
||
)
|
||
else:
|
||
# 检测列表项
|
||
is_list, list_level, list_type = _get_list_info(item)
|
||
if is_list:
|
||
para_css = _para_style_inline(item)
|
||
inner_spans: list[str] = []
|
||
for run in item.runs:
|
||
t = _escape(run.text)
|
||
if not t:
|
||
continue
|
||
run_css = _run_style_inline(run)
|
||
inner_spans.append(f'<span style="{run_css}">{t}</span>')
|
||
inner = "".join(inner_spans) or _escape(text)
|
||
block_type = "list_item"
|
||
left_margin = 24 + list_level * 24 # 每级缩进 24px
|
||
list_style = f"padding-left:{left_margin}px;"
|
||
marker_class = "list-ordered" if list_type == "ordered" else "list-unordered"
|
||
html_str = (
|
||
f'<div class="block-list-item {marker_class}" data-block-index="{block_index}" '
|
||
f'data-list-level="{list_level}" data-list-type="{list_type}" '
|
||
f'style="{para_css}{list_style}margin:2pt 0;">{inner}</div>'
|
||
)
|
||
else:
|
||
# 普通文本块
|
||
para_css = _para_style_inline(item)
|
||
inner_spans: list[str] = []
|
||
for run in item.runs:
|
||
t = _escape(run.text)
|
||
if not t:
|
||
continue
|
||
run_css = _run_style_inline(run)
|
||
inner_spans.append(f'<span style="{run_css}">{t}</span>')
|
||
inner = "".join(inner_spans) or _escape(text)
|
||
block_type = "text"
|
||
html_str = (
|
||
f'<p class="block-text" data-block-index="{block_index}" '
|
||
f'style="{para_css}margin:0;">{inner}</p>'
|
||
)
|
||
|
||
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'<div class="block-table" data-block-index="{block_index}" '
|
||
f'style="margin:6pt 0;">{table_html}</div>'
|
||
)
|
||
|
||
blocks.append(RenderedBlock(
|
||
block_index=block_index,
|
||
block_type="table",
|
||
html=html_str,
|
||
text_content=text_content,
|
||
style_json="{}",
|
||
table_json=json.dumps(table_structure, ensure_ascii=False),
|
||
))
|
||
block_index += 1
|
||
|
||
return blocks
|
||
|
||
|
||
def docx_to_full_html(file_path: str) -> str:
|
||
"""将 docx 文件转为完整的独立 HTML 文档(可直接在 iframe 中渲染)。"""
|
||
blocks = docx_to_html_blocks(file_path)
|
||
body_html = "\n".join(block.html for block in blocks)
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<style>
|
||
* {{ margin:0; padding:0; box-sizing:border-box; }}
|
||
body {{
|
||
font-family: 'SimSun', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
||
font-size: 12pt;
|
||
color: #1a1d24;
|
||
line-height: 1.8;
|
||
padding: 40px 60px;
|
||
max-width: 794px;
|
||
margin: 0 auto;
|
||
background: #fff;
|
||
}}
|
||
.block-heading {{ font-weight: 600; }}
|
||
.block-text {{ }}
|
||
.block-table {{ width: 100%; overflow-x: auto; }}
|
||
.block-table table {{ width: 100%; border-collapse: collapse; }}
|
||
.block-table th, .block-table td {{ padding: 4px 6px; text-align: left; }}
|
||
.block-empty {{ }}
|
||
.block-list-item {{ }}
|
||
.block-list-item.list-unordered::before {{
|
||
content: "\\2022";
|
||
display: inline-block;
|
||
width: 1em;
|
||
margin-left: -1em;
|
||
color: #333;
|
||
}}
|
||
.block-list-item.list-ordered {{ }}
|
||
[data-block-index]:hover {{
|
||
outline: 1px dashed #5b5bd6;
|
||
outline-offset: 2px;
|
||
}}
|
||
[data-block-index].block-selected {{
|
||
outline: 2px solid #5b5bd6;
|
||
outline-offset: 2px;
|
||
background-color: rgba(91,91,214,0.06);
|
||
}}
|
||
[data-block-index].block-ai {{
|
||
outline: 2px solid rgba(91,91,214,0.5);
|
||
outline-offset: 2px;
|
||
background-color: rgba(91,91,214,0.08);
|
||
}}
|
||
[data-block-index].block-fixed {{
|
||
outline: 2px solid rgba(154,161,173,0.5);
|
||
outline-offset: 2px;
|
||
background-color: rgba(154,161,173,0.04);
|
||
}}
|
||
[data-block-index].block-variable {{
|
||
outline: 2px solid rgba(26,140,74,0.5);
|
||
outline-offset: 2px;
|
||
background-color: rgba(26,140,74,0.06);
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
{body_html}
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
def docx_to_html_body(file_path: str) -> str:
|
||
"""仅返回 body 内的 HTML 片段(不含 head/style 包裹)。"""
|
||
blocks = docx_to_html_blocks(file_path)
|
||
return "\n".join(block.html for block in blocks)
|