"""pandoc docx → HTML 渲染模块(用于 WebView 展示)""" import base64 import re import subprocess import tempfile import zipfile from pathlib import Path def render_docx_to_html(docx_path: str | Path, include_answer: bool = True) -> str: """将 docx 渲染为 HTML Args: docx_path: docx 文件路径 include_answer: 是否包含答案区(教师版 True,学生版 False) Returns: HTML 片段字符串 """ docx_path = Path(docx_path) if not docx_path.exists(): return f"

题目文件不存在: {docx_path}

" try: html = _pandoc_convert(docx_path) # 后处理 html = _clean_pandoc_artifacts(html) html = _inject_styles(html) if not include_answer: html = _strip_answer_section(html) return html except Exception as e: return f"

渲染失败: {e}

" def render_answer_html(docx_path: str | Path) -> str: """仅渲染答案区 HTML""" docx_path = Path(docx_path) if not docx_path.exists(): return "" # 提取答案区段落的文本 from docx import Document doc = Document(str(docx_path)) in_answer = False answer_texts = [] for para in doc.paragraphs: text = para.text.strip() if not text: continue if text.startswith("【答案】"): in_answer = True if in_answer: answer_texts.append(text) if not answer_texts: return "" return "
".join(answer_texts) def _pandoc_convert(docx_path: Path) -> str: """pandoc docx → HTML (MathML)""" result = subprocess.run( [ "pandoc", str(docx_path), "-t", "html", "--mathjax", "--no-highlight", "--wrap=none", ], capture_output=True, text=True, timeout=30, ) if result.returncode != 0: raise RuntimeError(f"pandoc 错误: {result.stderr}") html = result.stdout # 处理图片:提取 docx 内嵌图片 → base64 内嵌到 HTML html = _embed_images(html, docx_path) # 修复公式:pandoc 将所有公式输出为 display math \[...] # 将段落内的短公式转为 inline math \(...) html = _fix_math_display_mode(html) return html def _embed_images(html: str, docx_path: Path) -> str: """将 HTML 中的图片引用替换为 base64 data URI""" try: with zipfile.ZipFile(docx_path, 'r') as zf: media_files = [n for n in zf.namelist() if n.startswith('word/media/')] for media_path in media_files: filename = media_path.split('/')[-1] if filename in html: image_data = zf.read(media_path) ext = filename.rsplit('.', 1)[-1].lower() mime = _get_mime(ext) data_uri = f"data:{mime};base64,{base64.b64encode(image_data).decode()}" # 替换 img src html = html.replace(f'src="media/{filename}"', f'src="{data_uri}"') except Exception: pass return html def _clean_pandoc_artifacts(html: str) -> str: """清理 pandoc 产生的 artifact""" # 移除 @@@UUID 图片 alt 文本 html = re.sub(r'@@@[a-f0-9-]+', '', html) # 移除空 alt html = html.replace('alt=""', '') return html def _inject_styles(html: str) -> str: """注入 CSS 样式""" styles = """ """ if "" in html: html = html.replace("", f"{styles}") else: html = f"{styles}{html}" return html def _strip_answer_section(html: str) -> str: """从 HTML 中去除答案区内容 在 pandoc 生成的 HTML 中,【答案】是普通文本, 我们需要找到它并删除之后的内容 """ # 匹配 【答案】 及其后的所有 HTML 内容 match = re.search(r'【答案】.*', html, re.DOTALL) if match: # 仅保留 【答案】 之前的内容 html = html[:match.start()].rstrip() return html def _fix_math_display_mode(html: str) -> str: """将 pandoc 输出的行内公式从 display math \[...] 转为 inline math \(...) 判断标准:公式不在独立的

标签中,即公式前后都有同段落的文字。 短公式(单变量、短表达式)也转为 inline。 """ import re def should_be_inline(match): full_match = match.group(0) # 已经是 inline 的不处理 if full_match.startswith('\\('): return full_match # 提取公式内容 content = full_match[2:-2] # 去掉 \[ 和 \] # 短公式(少于20字符)且不含换行符的转为 inline if len(content) < 25 and '\n' not in content: return '\\(' + content + '\\)' return full_match # 处理 span 包裹的 math display html = re.sub( r'\\\[.*?\\\]', lambda m: m.group(0).replace('math display', 'math inline') .replace('\\[', '\\(').replace('\\]', '\\)'), html ) return html def _get_mime(ext: str) -> str: mime_map = { 'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif', 'bmp': 'image/bmp', 'svg': 'image/svg+xml', 'webp': 'image/webp', } return mime_map.get(ext, 'image/png')