| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216 |
- """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"<p>题目文件不存在: {docx_path}</p>"
- 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"<p>渲染失败: {e}</p>"
- 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 "<br>".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 = """
- <style>
- body {
- font-family: '宋体', 'SimSun', 'Noto Serif CJK SC', serif;
- font-size: 16px;
- line-height: 1.8;
- color: #333;
- padding: 8px;
- }
- img {
- max-width: 100%;
- height: auto;
- }
- math, mjx-container {
- font-size: 100%;
- }
- blockquote {
- margin: 8px 0;
- padding: 4px 12px;
- border-left: 3px solid #e0e0e0;
- color: #555;
- }
- .answer-section {
- margin-top: 16px;
- padding-top: 12px;
- border-top: 1px dashed #ccc;
- }
- </style>
- """
- if "<head>" in html:
- html = html.replace("<head>", f"<head>{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 \(...)
- 判断标准:公式不在独立的 <p> 标签中,即公式前后都有同段落的文字。
- 短公式(单变量、短表达式)也转为 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'<span class="math display">\\\[.*?\\\]</span>',
- 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')
|