perf: improve markdown image rendering

This commit is contained in:
liuwei
2026-03-21 13:38:03 +08:00
parent 839d351d18
commit d5619db571

View File

@@ -7,6 +7,7 @@ import psutil
from playwright.async_api import async_playwright
import os
import asyncio
import re
from loguru import logger
@@ -18,11 +19,11 @@ async def safe_close_browser(browser, timeout: float = 4.0) -> None:
for page in context.pages[:]:
try:
await asyncio.wait_for(page.close(), timeout=1.5)
except:
except Exception:
pass
try:
await asyncio.wait_for(context.close(), timeout=timeout)
except:
except Exception:
pass
try:
@@ -66,26 +67,40 @@ async def safe_close_browser(browser, timeout: float = 4.0) -> None:
logger.warning(f"force kill failed: {e}")
def _split_hero(html_body: str):
title_match = re.search(r'<h1>(.*?)</h1>', html_body, re.S | re.I)
hero_title = title_match.group(1).strip() if title_match else "聊天总结"
remain = re.sub(r'<h1>.*?</h1>', '', html_body, count=1, flags=re.S | re.I).strip()
meta_match = re.match(r'^<p>(.*?)</p>', remain, re.S | re.I)
hero_meta = meta_match.group(1).strip() if meta_match else "群聊总结 / 自动生成"
if meta_match:
remain = re.sub(r'^<p>.*?</p>', '', remain, count=1, flags=re.S | re.I).strip()
return hero_title, hero_meta, remain
async def md_str_to_html_content(md_content):
"""将 Markdown 字符串转换为更有风格的 HTML 内容。"""
"""将 Markdown 字符串转换为更美观的 HTML 内容。"""
html_body = markdown.markdown(md_content, extensions=['extra', 'codehilite'])
hero_title, hero_meta, remain_html = _split_hero(html_body)
css = """
<style>
:root {
--bg1: #f6f3ff;
--bg2: #eef7ff;
--paper: rgba(255,255,255,0.94);
--text: #243042;
--muted: #64748b;
--bg1: #f7f4ff;
--bg2: #eef8ff;
--paper: #ffffff;
--text: #233044;
--muted: #718198;
--primary: #6d5efc;
--primary-soft: rgba(109,94,252,0.10);
--accent: #13b0a5;
--line: rgba(148,163,184,0.20);
--accent: #14b8a6;
--line: rgba(148,163,184,0.18);
--code-bg: #0f172a;
--code-fg: #e2e8f0;
--quote-bg: rgba(19,176,165,0.08);
--shadow: 0 18px 50px rgba(94, 92, 154, 0.12);
--quote-bg: rgba(20,184,166,0.08);
--shadow: 0 20px 45px rgba(80, 84, 125, 0.10);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
@@ -97,24 +112,53 @@ async def md_str_to_html_content(md_content):
background:
radial-gradient(circle at top left, #ffffff 0%, var(--bg1) 38%, transparent 70%),
radial-gradient(circle at top right, #ffffff 0%, var(--bg2) 34%, transparent 70%),
linear-gradient(180deg, #f7f8fc 0%, #eef4fb 100%);
padding: 28px;
linear-gradient(180deg, #f8f9fd 0%, #eef4fb 100%);
padding: 26px;
}
.wrap {
max-width: 820px;
margin: 0 auto;
background: var(--paper);
background: rgba(255,255,255,0.96);
border: 1px solid rgba(255,255,255,0.7);
border-radius: 28px;
box-shadow: var(--shadow);
overflow: hidden;
backdrop-filter: blur(8px);
}
.hero {
min-height: 22px;
background: linear-gradient(135deg, rgba(109,94,252,0.14), rgba(19,176,165,0.09));
padding: 30px 34px 22px;
background:
radial-gradient(circle at 85% 20%, rgba(255,255,255,0.42), transparent 24%),
linear-gradient(135deg, rgba(109,94,252,0.18), rgba(20,184,166,0.10));
border-bottom: 1px solid var(--line);
}
.hero-badge {
display: inline-block;
padding: 6px 12px;
border-radius: 999px;
font-size: 12px;
color: #5747ef;
background: rgba(255,255,255,0.70);
border: 1px solid rgba(109,94,252,0.12);
margin-bottom: 14px;
letter-spacing: .04em;
}
.hero-title {
margin: 0;
font-size: 2.05em;
line-height: 1.28;
font-weight: 800;
color: #1f2557;
text-align: center;
letter-spacing: -0.02em;
}
.hero-meta {
margin: 10px auto 0;
max-width: 620px;
text-align: center;
color: var(--muted);
font-size: 0.92em;
line-height: 1.7;
}
.content {
padding: 24px 34px 34px;
}
@@ -126,18 +170,7 @@ async def md_str_to_html_content(md_content):
line-height: 1.35;
letter-spacing: -0.01em;
}
h1 {
margin: 0 0 10px 0;
font-size: 2.08em;
text-align: center;
color: #1f2557;
}
h1 + p {
margin-top: 10px;
text-align: center;
color: var(--muted);
font-size: 1.02em;
}
h1 { display: none; }
h2 {
font-size: 1.42em;
margin-top: 30px;
@@ -151,7 +184,7 @@ async def md_str_to_html_content(md_content):
margin-top: 24px;
color: #30435f;
padding-left: 12px;
border-left: 3px solid rgba(19,176,165,0.55);
border-left: 3px solid rgba(20,184,166,0.55);
}
p {
margin: 14px 0;
@@ -166,16 +199,12 @@ async def md_str_to_html_content(md_content):
margin: 8px 0;
color: #334155;
}
li::marker {
color: var(--primary);
}
li::marker { color: var(--primary); }
strong {
color: #1e293b;
font-weight: 700;
}
em {
color: #5b6b84;
}
em { color: #5b6b84; }
code {
background: rgba(109,94,252,0.08);
color: #5b3df5;
@@ -204,7 +233,7 @@ async def md_str_to_html_content(md_content):
border-spacing: 0;
width: 100%;
margin: 20px 0;
background: rgba(255,255,255,0.88);
background: rgba(255,255,255,0.96);
border: 1px solid rgba(148,163,184,0.16);
border-radius: 16px;
overflow: hidden;
@@ -225,7 +254,7 @@ async def md_str_to_html_content(md_content):
margin: 18px 0;
padding: 14px 18px;
background: var(--quote-bg);
border: 1px solid rgba(19,176,165,0.16);
border: 1px solid rgba(20,184,166,0.16);
border-left: 5px solid var(--accent);
border-radius: 14px;
color: #355468;
@@ -259,9 +288,13 @@ async def md_str_to_html_content(md_content):
</head>
<body>
<div class="wrap">
<div class="hero"></div>
<div class="hero">
<div class="hero-badge">AI 群聊总结</div>
<h1 class="hero-title">{hero_title}</h1>
<div class="hero-meta">{hero_meta}</div>
</div>
<div class="content">
{html_body}
{remain_html}
</div>
</div>
</body>
@@ -300,7 +333,8 @@ async def html_to_image(html_content, output_image):
launch_args = [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage"
"--disable-dev-shm-usage",
"--disable-gpu"
]
if browser_path:
@@ -311,26 +345,24 @@ async def html_to_image(html_content, output_image):
browser = await p.chromium.launch(args=launch_args)
try:
context = await browser.new_context(device_scale_factor=1)
context = await browser.new_context(
viewport={"width": 780, "height": 960},
device_scale_factor=1.2
)
page = await context.new_page()
logger.debug("Measure body height")
body_height = await page.evaluate("document.body.scrollHeight")
await page.set_viewport_size({"width": 750, "height": body_height})
logger.debug("Set page content")
await page.set_content(html_content, wait_until='domcontentloaded', timeout=15000)
logger.debug("Wait for fonts ready")
await page.evaluate("document.fonts.ready")
await asyncio.sleep(0.2)
logger.debug("Set page content")
await page.set_content(html_content, wait_until='load')
await asyncio.sleep(0.5)
logger.debug(f"Take screenshot: output={output_image}, height={body_height}")
logger.debug(f"Take screenshot: output={output_image}")
await page.screenshot(
path=output_image,
full_page=True,
timeout=30000,
timeout=15000,
animations="disabled"
)
if not os.path.exists(output_image):
@@ -341,7 +373,7 @@ async def html_to_image(html_content, output_image):
await safe_close_browser(browser)
async def convert_md_str_to_image(md_content: str, output_image: str, max_retries: int = 3) -> str:
async def convert_md_str_to_image(md_content: str, output_image: str, max_retries: int = 2) -> str:
if not md_content:
raise ValueError("Markdown content cannot be empty")
@@ -373,6 +405,6 @@ async def convert_md_str_to_image(md_content: str, output_image: str, max_retrie
last_error = e
logger.warning(f"{attempt + 1} 次尝试失败: {e}")
if attempt < max_retries - 1:
await asyncio.sleep((attempt + 1) * 2)
await asyncio.sleep(1.5)
raise RuntimeError(f"图片生成失败,已重试 {max_retries} 次。最后错误: {last_error}")