Files
abot/utils/markdown_to_image.py

354 lines
16 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import subprocess
import time
import markdown
from pathlib import Path
from playwright.async_api import async_playwright
import os
import asyncio
from loguru import logger
# linux 下需要安装字体
# sudo apt-get install -y fonts-noto-cjk fonts-noto-cjk-extra
# sudo apt-get install -y fonts-noto-color-emoji fonts-noto-cjk fonts-wqy-microhei
# 将 Markdown 字符串转换为 HTML
def md_str_to_html(md_content, output_html):
"""
将 Markdown 字符串转换为 HTML 文件,并添加支持中文和 Emoji 的样式。
:param md_content: 输入的 Markdown 字符串
:param output_html: 输出的 HTML 文件路径
"""
# 转换 Markdown 为 HTML启用额外功能如表格、代码高亮
html_content = markdown.markdown(md_content, extensions=['extra', 'codehilite'])
# 添加基本的 HTML 结构和样式,支持中文和 Emoji
css = """
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', 'Noto Sans CJK SC', 'Microsoft YaHei', sans-serif;
padding: 20px 25px;
line-height: 1.6;
max-width: 750px;
margin: 0 auto;
background-color: #f9f9f9;
color: #333;
border: 1px solid #f0f0f0;
font-size: 16px;
}
h1, h2, h3, h4, h5, h6 {
color: #222;
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.3;
}
h1 {
font-size: 2.2em;
padding-bottom: 12px;
border-bottom: 1px solid #eee;
text-align: center;
margin-bottom: 25px;
color: #1a1a1a;
}
h2 {
font-size: 1.8em;
padding-bottom: 10px;
margin-top: 30px;
border-bottom: 1px solid #eee;
color: #2c3e50;
}
h3 {
font-size: 1.5em;
margin-top: 25px;
padding-left: 12px;
border-left: 4px solid #ddd;
color: #34495e;
}
pre, code {
background-color: #f5f5f5;
padding: 12px;
border-radius: 4px;
font-family: 'Courier New', Courier, monospace;
font-size: 0.95em;
border: 1px solid #eee;
}
table {
border-collapse: collapse;
width: 100%;
margin: 18px 0;
background-color: white;
}
th, td {
border: 1px solid #eee;
padding: 10px 12px;
text-align: left;
}
th {
background-color: #fafafa;
font-weight: 600;
}
/* 确保 Emoji 正确渲染 */
span, p, li {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', 'Noto Sans CJK SC', 'Microsoft YaHei', sans-serif;
font-size: 16px;
}
p {
margin: 16px 0;
color: #333;
line-height: 1.8;
font-size: 16px;
}
ul, ol {
padding-left: 25px;
margin: 18px 0;
}
li {
margin: 8px 0;
line-height: 1.7;
font-size: 16px;
}
blockquote {
margin: 18px 0;
padding: 12px 18px;
background-color: #f8f8f8;
border-left: 5px solid #ddd;
color: #555;
font-size: 1em;
}
/* 强调样式 */
strong {
color: #222;
font-weight: 600;
}
/* 链接样式 */
a {
color: #3498db;
text-decoration: none;
}
a:hover {
text-decoration: underline;
color: #2980b9;
}
/* 星级评分样式 */
h3 em {
color: #fa8c16;
font-style: normal;
font-size: 1.1em;
}
/* 时间和标签样式 */
.time, .tag {
color: #777;
font-size: 0.95em;
}
/* 底部署名样式 */
.signature {
margin-top: 35px;
text-align: right;
color: #777;
font-size: 0.95em;
font-style: italic;
}
</style>
"""
# 写入 HTML 文件
with open(output_html, 'w', encoding='utf-8') as f:
f.write('<html><head>')
f.write('<meta charset="UTF-8">') # 确保 UTF-8 编码
f.write(css)
f.write('</head><body>')
f.write(html_content)
f.write('</body></html>')
def check_chromium_installed(path):
return os.path.isfile(path) and os.access(path, os.X_OK)
async def html_to_image(html_file, output_image):
"""
使用 Playwright 加载 HTML 文件并截图(异步)。
"""
try:
async with async_playwright() as p:
browser_path = None
if os.name == 'nt': # Windows
possible_chrome_paths = [
r"C:\Users\Liu_WIN10\AppData\Local\Google\Chrome\Application\chrome.exe",
r"C:\Users\Liu-OPEN\AppData\Local\Google\Chrome\Application\chrome.exe",
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe"
]
for path in possible_chrome_paths:
if check_chromium_installed(path):
browser_path = path
print(f"找到浏览器路径: {browser_path}")
break
else: # Linux
import glob
user_home = os.path.expanduser("~")
glob_pattern = os.path.join(user_home, ".cache", "ms-playwright", "chromium-*", "chrome-linux",
"chrome")
chrome_paths = glob.glob(glob_pattern)
browser_path = None
for path in sorted(chrome_paths, reverse=True): # 按版本名排序,最新优先
if check_chromium_installed(path):
browser_path = path
print(f"找到 Playwright Chromium 路径: {browser_path}")
break
if not browser_path:
print("未找到已安装的 Chromium 浏览器,尝试使用 Playwright 默认安装")
try:
print("正在安装 Playwright 浏览器...")
subprocess.run(["playwright", "install", "chromium"], check=True)
print("Playwright 浏览器安装完成")
except Exception as install_error:
print(f"安装 Playwright 浏览器失败: {install_error}")
browser = await p.chromium.launch() # 使用默认路径
else:
browser = await p.chromium.launch(executable_path=browser_path)
# 业务逻辑不变
try:
page = await browser.new_page()
await page.goto(f'file://{os.path.abspath(html_file)}')
await page.set_viewport_size({"width": 750, "height": 800})
await page.wait_for_timeout(500)
await page.screenshot(path=output_image, full_page=True)
except Exception as e:
print(f"截图失败: {e}")
finally:
await page.close()
await browser.close()
except Exception as e:
print(f"浏览器操作失败: {e}")
if "Executable doesn't exist" in str(e):
print("请运行 'playwright install' 命令安装必要的浏览器组件")
raise
# 主函数:从字符串转换 Markdown 到图片(异步版)
async def convert_md_str_to_image(md_content: str, output_image: str) -> str:
"""
将 Markdown 字符串转换为图片(异步)。
Args:
md_content (str): Markdown 内容字符串
output_image (str): 输出图片的文件名(不含路径)
Returns:
str: 生成的图片文件的绝对路径
Raises:
FileNotFoundError: 如果临时目录无法创建或访问
ValueError: 如果 md_content 为空
"""
# 验证输入
if not md_content:
raise ValueError("Markdown content cannot be empty")
# 获取项目根目录
project_root = os.getcwd()
project_root_path = Path(project_root).resolve()
# 创建临时目录
temp_dir = project_root_path / "temp"
try:
temp_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
logger.error(f"Failed to create temp directory: {e}")
raise FileNotFoundError(f"Could not create temp directory: {temp_dir}")
# 生成唯一的临时文件名
timestamp = int(time.time())
temp_html_filename = f"temp_output_{timestamp}.html"
temp_html_path = temp_dir / temp_html_filename
output_image_path = temp_dir / output_image
# 确保输出图片路径的父目录存在
output_image_path.parent.mkdir(parents=True, exist_ok=True)
try:
# 将 Markdown 转换为 HTML
md_str_to_html(md_content, str(temp_html_path))
# 将 HTML 转换为图片
await html_to_image(str(temp_html_path), str(output_image_path))
logger.debug(f"图片已生成:{output_image_path}")
return str(output_image_path.resolve())
except Exception as e:
logger.error(f"Error converting markdown to image: {e}")
raise
finally:
# 可选:清理临时 HTML 文件
if temp_html_path.exists():
try:
temp_html_path.unlink()
logger.debug(f"Deleted temporary HTML file: {temp_html_path}")
except Exception as e:
logger.warning(f"Failed to delete temporary HTML file: {e}")
# 示例使用
if __name__ == "__main__":
# 示例 Markdown 字符串(包含中文和 Emoji
md_content = """#🌟「4KED康复训练群 - 05-30 总结」🌟
## 📊 今日数据快报
- **总消息数**:📩 约300条
- **最活跃时段**:🔥 09:00-10:00 (📈 50条/小时)
- **聊天时段**:🕒 08:28 - 16:16
## 🌌 话题总结
### 1⃣ 【车辆保险费用上涨】 ⭐⭐⭐⭐⭐
🕒 **聊天时段**11:33 - 13:16 (👥 6人参与
🔍 **话题回顾**
本次讨论围绕 **车辆保险费用上涨** 展开,堪称今日群聊的“流量担当”。一开始,[@Summer✊] 抛出了一个爆炸性问题:“今年车辆保费居然比去年贵”,瞬间点燃了大家的热情。随后,[@火鸡味锅巴] 表示支持,提出了 **保险改革导致价格上涨**,认为 **保险公司收益未达预期,保费自然水涨船高**,并举了一个让人信服的例子 **自己的保险从8K+只返了170元**。然而,[@达文西] 却持相反意见,抛出 **可以不买车损险**,强调 **认真开车就能省下大头费用**,还顺手甩出一句调侃“车损是大头”。
讨论的高潮出现在 [@啊菜] 的加入,他不仅提出了 **进口车保险确实更贵**,还分享了一段 **奥迪比雷车贵是合理的对比**,让整个话题从抱怨上升到了品牌差异的讨论层面。大家你一言我一语,气氛热烈得像是开了一场线上辩论会!
👍 **金句回顾**"保的少了,保价贵了,主打的就是个减量加价" —— [@火鸡味锅巴]
📌 **额外信息**:讨论中提及了 **保险改革和统一保费政策**,有兴趣的可以去深入研究一下。
### 2⃣ 【幼儿园六一活动攀比】 ⭐⭐⭐⭐
🕒 **聊天时段**15:17 - 15:25 (👥 5人参与
🔍 **高能讨论**
本话题的火花由 [@暗香] 无意间点燃,他随口提到 **幼儿园六一活动零食大礼包攀比**,没想到立刻引发了一场头脑风暴。[@水牛] 率先下场,详细分析了 **老师组织活动的问题**,从 **统一准备没新意** 到 **自己准备变攀比**,娓娓道来,最后得出一个令人拍案叫绝的结论:“这种事情就是老师不会搞”。紧接着,[@Summer✊] 不甘示弱,掏出了 **幽默建议** 作为佐证,比如 **带两瓶拉菲或者直接带钱把同学东西全买了**,让讨论瞬间变得硬核起来。
然而,[@互联网赵括] 却用一贯的幽默风格插话“带15升哇哈哈”搭配一个搞笑表情“猪头”把严肃的气氛冲淡了不少引得大家纷纷刷屏“哈哈哈”。
📌 **实用干货**:这次聊出了不少好东西,比如推荐了 **编五彩绳作为活动创意**,实测可用,建议收藏!
### 3⃣ 【手工制作高达模型的痛苦】 ⭐⭐⭐⭐
🕒 **聊天时段**09:10 - 09:29 (👥 5人参与
🔍 **讨论亮点**
这次讨论围绕 **手工制作高达模型的痛苦经历** 展开,简直是群聊中的一场“思想盛宴”。一开始,大家还在轻松闲聊,但 [@火鸡味锅巴] 突然抛出了一个独特的视角:“深刻体会了胶佬的痛苦,涂不完的热熔胶”,瞬间让话题升温。他还详细补充了 **制作过程中的各种困难**,比如 **热熔胶烫手、时间紧迫、还要上色**,逻辑清晰得让人不得不服。
随后,[@清风] 表示认同,补充了 **可以优化制作比如加LED灯光**,并提到自己如果参与必然“大杀四方”。而 [@Summer✊] 则提出了疑问:“你真弄啊”,引发了一轮新的讨论。大家围绕 **制作难度** 和 **创意想法** 你来我往,聊得不亦乐乎。
👍 **精华总结**"太不容易了,时间又紧,明年请假得了" —— [@火鸡味锅巴]
### 4⃣ 【谈恋爱风险与个性妹子】 ⭐⭐⭐
🕒 **聊天时段**13:39 - 14:00 (👥 5人参与
🔍 **精彩瞬间**
这次讨论的焦点是 **谈恋爱的风险**,一开始只是 [@T T] 的随口一问:“现在的男生要谈个恋爱风险蛮高”,没想到却掀起了一波热议。[@互联网赵括] 率先响应,提出了 **有个性的妹子通常不差**,并分享了一个真实案例:“我印象里比较有个性的姑娘不会长得太差”,让大家对问题有了更直观的理解。随后,[@火鸡味锅巴] 提出了完全不同的 **观点**,理由是 **何必因为一棵树放弃一片森林**,还顺带调侃了一句:“谈恋爱干嘛,互相满足生理需求不就好了”。
讨论中,[@Y] 还搬出了搞笑补充 **榜一大哥的调侃**,试图证明 **恋爱风险确实高**,这让话题从日常闲聊上升到了“情感高度”。虽然最后大家没达成一致,但这场唇枪舌剑真是精彩纷呈!
### 5⃣ 【水费欠款离谱事件】 ⭐⭐⭐
🕒 **聊天时段**10:34 - 10:38 (👥 5人参与
🔍 **讨论小结**
相比前面的话题,这次的 **水费欠款事件** 显得轻松不少,但依然趣味横生。话题从 [@雨的回忆] 的一句“买的房子原房东欠了2万多吨水费” 开始,聊着聊着就跑到了 **如何处理欠款的搞笑讨论**。比如,有人提到 **催前房东交钱**[@互联网赵括] 立马接梗,分享了一段 **调侃原房东可能是干屠宰或发电的**,比如 **“拿来发电我都信”**,笑点密集,群里瞬间刷屏了一堆“哈哈”表情。
[@火鸡味锅巴] 还不忘补刀:“欠了多少我也不知道”,让这场讨论成了名副其实的“欢乐场”。虽然话题不算深刻,但这种轻松的氛围也让大家放松了不少。
## 🎖️ 今日荣誉榜
🏆 **群聊 MVP**[@火鸡味锅巴]
👑 **获奖理由**
✅ 发起 3 个热门话题,贡献 5 个表情包/段子
✅ **创新贡献**"高达模型制作痛苦心得"(已申请专利 🎉)
✨ *本总结由 AI 自动生成,快来看看你今天是不是最靓的崽!🔥*"""
spath = asyncio.run(convert_md_str_to_image(md_content, "output.png"))
print(spath)