doc-forge-remark/backend/services/word_to_html.py

674 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""将 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 "&nbsp;"
content_parts.append(f'<div style="{para_css};margin:0;">{inner_html}</div>')
cell_html = "".join(content_parts) if content_parts else "&nbsp;"
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)