html_utils.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. """
  2. HTML/Markdown 处理工具模块
  3. 提供 HTML 和 Markdown 内容的处理功能:
  4. - 图片引用处理(转换为 base64)
  5. - HTML 表格转换
  6. - 表格解析
  7. """
  8. import os
  9. import base64
  10. import pandas as pd
  11. from io import StringIO
  12. from html import unescape
  13. from typing import List, Optional
  14. import re
  15. def find_image_in_multiple_locations(img_src: str, json_path: str) -> Optional[str]:
  16. """
  17. 在多个可能的位置查找图片文件
  18. Args:
  19. img_src: 图片源路径
  20. json_path: JSON 文件路径(用于相对路径解析)
  21. Returns:
  22. 找到的图片完整路径,如果未找到则返回 None
  23. """
  24. json_dir = os.path.dirname(json_path)
  25. # 可能的搜索路径
  26. search_paths = [
  27. # 相对于JSON文件的路径
  28. os.path.join(json_dir, img_src),
  29. # 相对于JSON文件父目录的路径
  30. os.path.join(os.path.dirname(json_dir), img_src),
  31. # imgs目录(常见的图片目录)
  32. os.path.join(json_dir, 'imgs', os.path.basename(img_src)),
  33. os.path.join(os.path.dirname(json_dir), 'imgs', os.path.basename(img_src)),
  34. # images目录
  35. os.path.join(json_dir, 'images', os.path.basename(img_src)),
  36. os.path.join(os.path.dirname(json_dir), 'images', os.path.basename(img_src)),
  37. # 同名目录
  38. os.path.join(json_dir, os.path.splitext(os.path.basename(json_path))[0], os.path.basename(img_src)),
  39. ]
  40. # 如果是绝对路径,也加入搜索
  41. if os.path.isabs(img_src):
  42. search_paths.insert(0, img_src)
  43. # 查找存在的文件
  44. for path in search_paths:
  45. if os.path.exists(path):
  46. return path
  47. return None
  48. def process_html_images(html_content: str, json_path: str) -> str:
  49. """
  50. 处理HTML内容中的图片引用,将本地图片转换为base64
  51. Args:
  52. html_content: HTML 内容
  53. json_path: JSON 文件路径(用于相对路径解析)
  54. Returns:
  55. 处理后的 HTML 内容(图片已转换为 base64)
  56. """
  57. # 匹配HTML图片标签: <img src="path" ... />
  58. img_pattern = r'<img\s+[^>]*src\s*=\s*["\']([^"\']+)["\'][^>]*/?>'
  59. def replace_html_image(match):
  60. full_tag = match.group(0)
  61. img_src = match.group(1)
  62. # 如果已经是base64或者网络链接,直接返回
  63. if img_src.startswith('data:image') or img_src.startswith('http'):
  64. return full_tag
  65. # 增强的图片查找
  66. full_img_path = find_image_in_multiple_locations(img_src, json_path)
  67. # 尝试转换为base64
  68. try:
  69. if full_img_path and os.path.exists(full_img_path):
  70. with open(full_img_path, 'rb') as img_file:
  71. img_data = img_file.read()
  72. # 获取文件扩展名确定MIME类型
  73. ext = os.path.splitext(full_img_path)[1].lower()
  74. mime_type = {
  75. '.png': 'image/png',
  76. '.jpg': 'image/jpeg',
  77. '.jpeg': 'image/jpeg',
  78. '.gif': 'image/gif',
  79. '.bmp': 'image/bmp',
  80. '.webp': 'image/webp'
  81. }.get(ext, 'image/jpeg')
  82. # 转换为base64
  83. img_base64 = base64.b64encode(img_data).decode('utf-8')
  84. data_url = f"data:{mime_type};base64,{img_base64}"
  85. # 替换src属性,保持其他属性不变
  86. updated_tag = re.sub(
  87. r'src\s*=\s*["\'][^"\']+["\']',
  88. f'src="{data_url}"',
  89. full_tag
  90. )
  91. return updated_tag
  92. else:
  93. # 文件不存在,显示详细的错误信息
  94. error_content = f"""
  95. <div style="
  96. color: #d32f2f;
  97. border: 2px dashed #d32f2f;
  98. padding: 10px;
  99. margin: 10px 0;
  100. border-radius: 5px;
  101. background-color: #ffebee;
  102. text-align: center;
  103. ">
  104. <strong>🖼️ 图片无法加载</strong><br>
  105. <small>原始路径: {img_src}</small><br>
  106. <small>JSON文件: {os.path.basename(json_path)}</small><br>
  107. <em>请检查图片文件是否存在</em>
  108. </div>
  109. """
  110. return error_content
  111. except Exception as e:
  112. # 转换失败,返回错误信息
  113. error_content = f"""
  114. <div style="
  115. color: #f57c00;
  116. border: 2px dashed #f57c00;
  117. padding: 10px;
  118. margin: 10px 0;
  119. border-radius: 5px;
  120. background-color: #fff3e0;
  121. text-align: center;
  122. ">
  123. <strong>⚠️ 图片处理失败</strong><br>
  124. <small>文件: {img_src}</small><br>
  125. <small>错误: {str(e)}</small>
  126. </div>
  127. """
  128. return error_content
  129. # 替换所有HTML图片标签
  130. processed_content = re.sub(img_pattern, replace_html_image, html_content, flags=re.IGNORECASE)
  131. return processed_content
  132. def process_markdown_images(md_content: str, json_path: str) -> str:
  133. """
  134. 处理Markdown中的图片引用,将本地图片转换为base64
  135. Args:
  136. md_content: Markdown 内容
  137. json_path: JSON 文件路径(用于相对路径解析)
  138. Returns:
  139. 处理后的 Markdown 内容(图片已转换为 base64)
  140. """
  141. # 匹配Markdown图片语法: ![alt](path)
  142. img_pattern = r'!\[([^\]]*)\]\(([^)]+)\)'
  143. def replace_image(match):
  144. alt_text = match.group(1)
  145. img_path = match.group(2)
  146. # 如果已经是base64或者网络链接,直接返回
  147. if img_path.startswith('data:image') or img_path.startswith('http'):
  148. return match.group(0)
  149. # 处理相对路径
  150. if not os.path.isabs(img_path):
  151. # 相对于JSON文件的路径
  152. json_dir = os.path.dirname(json_path)
  153. full_img_path = os.path.join(json_dir, img_path)
  154. else:
  155. full_img_path = img_path
  156. # 尝试转换为base64
  157. try:
  158. if os.path.exists(full_img_path):
  159. with open(full_img_path, 'rb') as img_file:
  160. img_data = img_file.read()
  161. # 获取文件扩展名确定MIME类型
  162. ext = os.path.splitext(full_img_path)[1].lower()
  163. mime_type = {
  164. '.png': 'image/png',
  165. '.jpg': 'image/jpeg',
  166. '.jpeg': 'image/jpeg',
  167. '.gif': 'image/gif',
  168. '.bmp': 'image/bmp',
  169. '.webp': 'image/webp'
  170. }.get(ext, 'image/jpeg')
  171. # 转换为base64
  172. img_base64 = base64.b64encode(img_data).decode('utf-8')
  173. data_url = f"data:{mime_type};base64,{img_base64}"
  174. return f'![{alt_text}]({data_url})'
  175. else:
  176. # 文件不存在,返回原始链接但添加警告
  177. return f'![{alt_text} (文件不存在)]({img_path})'
  178. except Exception as e:
  179. # 转换失败,返回原始链接
  180. return f'![{alt_text} (加载失败)]({img_path})'
  181. # 替换所有图片引用
  182. processed_content = re.sub(img_pattern, replace_image, md_content)
  183. return processed_content
  184. def process_all_images_in_content(content: str, json_path: str) -> str:
  185. """
  186. 处理内容中的所有图片引用(包括Markdown和HTML格式)
  187. Args:
  188. content: 内容(可能包含 HTML 和 Markdown)
  189. json_path: JSON 文件路径
  190. Returns:
  191. 处理后的内容(所有图片已转换为 base64)
  192. """
  193. # 先处理HTML图片
  194. content = process_html_images(content, json_path)
  195. # 再处理Markdown图片
  196. content = process_markdown_images(content, json_path)
  197. return content
  198. def convert_html_table_to_markdown(content: str) -> str:
  199. """
  200. 将HTML表格转换为Markdown表格格式 - 支持横向滚动的增强版本
  201. Args:
  202. content: 包含 HTML 表格的内容
  203. Returns:
  204. 转换后的内容(HTML 表格已转换为 Markdown)
  205. """
  206. def replace_table(match):
  207. table_html = match.group(0)
  208. # 提取所有行
  209. rows = re.findall(r'<tr[^>]*>(.*?)</tr>', table_html, re.DOTALL | re.IGNORECASE)
  210. if not rows:
  211. return table_html
  212. markdown_rows = []
  213. max_cols = 0
  214. # 处理所有行,找出最大列数
  215. processed_rows = []
  216. for row in rows:
  217. # 提取单元格,支持 th 和 td
  218. cells = re.findall(r'<t[hd][^>]*>(.*?)</t[hd]>', row, re.DOTALL | re.IGNORECASE)
  219. if cells:
  220. clean_cells = []
  221. for cell in cells:
  222. cell_text = re.sub(r'<[^>]+>', '', cell).strip()
  223. cell_text = unescape(cell_text)
  224. # 限制单元格长度,避免表格过宽
  225. if len(cell_text) > 30:
  226. cell_text = cell_text[:27] + "..."
  227. clean_cells.append(cell_text or " ") # 空单元格用空格替代
  228. processed_rows.append(clean_cells)
  229. max_cols = max(max_cols, len(clean_cells))
  230. # 统一所有行的列数
  231. for i, row_cells in enumerate(processed_rows):
  232. while len(row_cells) < max_cols:
  233. row_cells.append(" ")
  234. # 构建Markdown行
  235. markdown_row = '| ' + ' | '.join(row_cells) + ' |'
  236. markdown_rows.append(markdown_row)
  237. # 在第一行后添加分隔符
  238. if i == 0:
  239. separator = '| ' + ' | '.join(['---'] * max_cols) + ' |'
  240. markdown_rows.append(separator)
  241. # 添加滚动提示
  242. if max_cols > 8:
  243. scroll_note = "\n> 📋 **提示**: 此表格列数较多,在某些视图中可能需要横向滚动查看完整内容。\n"
  244. return scroll_note + '\n'.join(markdown_rows) if markdown_rows else table_html
  245. return '\n'.join(markdown_rows) if markdown_rows else table_html
  246. # 替换所有HTML表格
  247. converted = re.sub(r'<table[^>]*>.*?</table>', replace_table, content, flags=re.DOTALL | re.IGNORECASE)
  248. return converted
  249. def parse_html_tables(html_content: str) -> List[pd.DataFrame]:
  250. """
  251. 解析HTML内容中的表格为DataFrame列表
  252. Args:
  253. html_content: HTML 内容
  254. Returns:
  255. DataFrame 列表,每个 DataFrame 对应一个表格
  256. """
  257. try:
  258. tables = pd.read_html(StringIO(html_content))
  259. return tables if tables else []
  260. except Exception:
  261. return []