"""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')