"""将 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_html}
') 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}" 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"{cells_html}") body = "\n".join(rows_html) return f'{body}
' 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'
{img_html}
', 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='
'.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'{t}') inner = "".join(inner_spans) or _escape(text) block_type = "heading" html_str = ( f'{inner}' ) 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'{t}') 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'
{inner}
' ) 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'{t}') inner = "".join(inner_spans) or _escape(text) block_type = "text" html_str = ( 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'
{table_html}
' ) 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""" {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)