renderer.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. """pandoc docx → HTML 渲染模块(用于 WebView 展示)"""
  2. import base64
  3. import re
  4. import subprocess
  5. import tempfile
  6. import zipfile
  7. from pathlib import Path
  8. def render_docx_to_html(docx_path: str | Path, include_answer: bool = True) -> str:
  9. """将 docx 渲染为 HTML
  10. Args:
  11. docx_path: docx 文件路径
  12. include_answer: 是否包含答案区(教师版 True,学生版 False)
  13. Returns: HTML 片段字符串
  14. """
  15. docx_path = Path(docx_path)
  16. if not docx_path.exists():
  17. return f"<p>题目文件不存在: {docx_path}</p>"
  18. try:
  19. html = _pandoc_convert(docx_path)
  20. # 后处理
  21. html = _clean_pandoc_artifacts(html)
  22. html = _inject_styles(html)
  23. if not include_answer:
  24. html = _strip_answer_section(html)
  25. return html
  26. except Exception as e:
  27. return f"<p>渲染失败: {e}</p>"
  28. def render_answer_html(docx_path: str | Path) -> str:
  29. """仅渲染答案区 HTML"""
  30. docx_path = Path(docx_path)
  31. if not docx_path.exists():
  32. return ""
  33. # 提取答案区段落的文本
  34. from docx import Document
  35. doc = Document(str(docx_path))
  36. in_answer = False
  37. answer_texts = []
  38. for para in doc.paragraphs:
  39. text = para.text.strip()
  40. if not text:
  41. continue
  42. if text.startswith("【答案】"):
  43. in_answer = True
  44. if in_answer:
  45. answer_texts.append(text)
  46. if not answer_texts:
  47. return ""
  48. return "<br>".join(answer_texts)
  49. def _pandoc_convert(docx_path: Path) -> str:
  50. """pandoc docx → HTML (MathML)"""
  51. result = subprocess.run(
  52. [
  53. "pandoc", str(docx_path),
  54. "-t", "html",
  55. "--mathjax",
  56. "--no-highlight",
  57. "--wrap=none",
  58. ],
  59. capture_output=True,
  60. text=True,
  61. timeout=30,
  62. )
  63. if result.returncode != 0:
  64. raise RuntimeError(f"pandoc 错误: {result.stderr}")
  65. html = result.stdout
  66. # 处理图片:提取 docx 内嵌图片 → base64 内嵌到 HTML
  67. html = _embed_images(html, docx_path)
  68. # 修复公式:pandoc 将所有公式输出为 display math \[...]
  69. # 将段落内的短公式转为 inline math \(...)
  70. html = _fix_math_display_mode(html)
  71. return html
  72. def _embed_images(html: str, docx_path: Path) -> str:
  73. """将 HTML 中的图片引用替换为 base64 data URI"""
  74. try:
  75. with zipfile.ZipFile(docx_path, 'r') as zf:
  76. media_files = [n for n in zf.namelist() if n.startswith('word/media/')]
  77. for media_path in media_files:
  78. filename = media_path.split('/')[-1]
  79. if filename in html:
  80. image_data = zf.read(media_path)
  81. ext = filename.rsplit('.', 1)[-1].lower()
  82. mime = _get_mime(ext)
  83. data_uri = f"data:{mime};base64,{base64.b64encode(image_data).decode()}"
  84. # 替换 img src
  85. html = html.replace(f'src="media/{filename}"', f'src="{data_uri}"')
  86. except Exception:
  87. pass
  88. return html
  89. def _clean_pandoc_artifacts(html: str) -> str:
  90. """清理 pandoc 产生的 artifact"""
  91. # 移除 @@@UUID 图片 alt 文本
  92. html = re.sub(r'@@@[a-f0-9-]+', '', html)
  93. # 移除空 alt
  94. html = html.replace('alt=""', '')
  95. return html
  96. def _inject_styles(html: str) -> str:
  97. """注入 CSS 样式"""
  98. styles = """
  99. <style>
  100. body {
  101. font-family: '宋体', 'SimSun', 'Noto Serif CJK SC', serif;
  102. font-size: 16px;
  103. line-height: 1.8;
  104. color: #333;
  105. padding: 8px;
  106. }
  107. img {
  108. max-width: 100%;
  109. height: auto;
  110. }
  111. math, mjx-container {
  112. font-size: 100%;
  113. }
  114. blockquote {
  115. margin: 8px 0;
  116. padding: 4px 12px;
  117. border-left: 3px solid #e0e0e0;
  118. color: #555;
  119. }
  120. .answer-section {
  121. margin-top: 16px;
  122. padding-top: 12px;
  123. border-top: 1px dashed #ccc;
  124. }
  125. </style>
  126. """
  127. if "<head>" in html:
  128. html = html.replace("<head>", f"<head>{styles}")
  129. else:
  130. html = f"{styles}{html}"
  131. return html
  132. def _strip_answer_section(html: str) -> str:
  133. """从 HTML 中去除答案区内容
  134. 在 pandoc 生成的 HTML 中,【答案】是普通文本,
  135. 我们需要找到它并删除之后的内容
  136. """
  137. # 匹配 【答案】 及其后的所有 HTML 内容
  138. match = re.search(r'【答案】.*', html, re.DOTALL)
  139. if match:
  140. # 仅保留 【答案】 之前的内容
  141. html = html[:match.start()].rstrip()
  142. return html
  143. def _fix_math_display_mode(html: str) -> str:
  144. """将 pandoc 输出的行内公式从 display math \[...] 转为 inline math \(...)
  145. 判断标准:公式不在独立的 <p> 标签中,即公式前后都有同段落的文字。
  146. 短公式(单变量、短表达式)也转为 inline。
  147. """
  148. import re
  149. def should_be_inline(match):
  150. full_match = match.group(0)
  151. # 已经是 inline 的不处理
  152. if full_match.startswith('\\('):
  153. return full_match
  154. # 提取公式内容
  155. content = full_match[2:-2] # 去掉 \[ 和 \]
  156. # 短公式(少于20字符)且不含换行符的转为 inline
  157. if len(content) < 25 and '\n' not in content:
  158. return '\\(' + content + '\\)'
  159. return full_match
  160. # 处理 span 包裹的 math display
  161. html = re.sub(
  162. r'<span class="math display">\\\[.*?\\\]</span>',
  163. lambda m: m.group(0).replace('math display', 'math inline')
  164. .replace('\\[', '\\(').replace('\\]', '\\)'),
  165. html
  166. )
  167. return html
  168. def _get_mime(ext: str) -> str:
  169. mime_map = {
  170. 'png': 'image/png',
  171. 'jpg': 'image/jpeg',
  172. 'jpeg': 'image/jpeg',
  173. 'gif': 'image/gif',
  174. 'bmp': 'image/bmp',
  175. 'svg': 'image/svg+xml',
  176. 'webp': 'image/webp',
  177. }
  178. return mime_map.get(ext, 'image/png')