perf: improve markdown image rendering
This commit is contained in:
@@ -7,6 +7,7 @@ import psutil
|
|||||||
from playwright.async_api import async_playwright
|
from playwright.async_api import async_playwright
|
||||||
import os
|
import os
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
@@ -18,11 +19,11 @@ async def safe_close_browser(browser, timeout: float = 4.0) -> None:
|
|||||||
for page in context.pages[:]:
|
for page in context.pages[:]:
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(page.close(), timeout=1.5)
|
await asyncio.wait_for(page.close(), timeout=1.5)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(context.close(), timeout=timeout)
|
await asyncio.wait_for(context.close(), timeout=timeout)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -66,26 +67,40 @@ async def safe_close_browser(browser, timeout: float = 4.0) -> None:
|
|||||||
logger.warning(f"force kill failed: {e}")
|
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):
|
async def md_str_to_html_content(md_content):
|
||||||
"""将 Markdown 字符串转换为更有风格的 HTML 内容。"""
|
"""将 Markdown 字符串转换为更美观的 HTML 内容。"""
|
||||||
html_body = markdown.markdown(md_content, extensions=['extra', 'codehilite'])
|
html_body = markdown.markdown(md_content, extensions=['extra', 'codehilite'])
|
||||||
|
hero_title, hero_meta, remain_html = _split_hero(html_body)
|
||||||
|
|
||||||
css = """
|
css = """
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg1: #f6f3ff;
|
--bg1: #f7f4ff;
|
||||||
--bg2: #eef7ff;
|
--bg2: #eef8ff;
|
||||||
--paper: rgba(255,255,255,0.94);
|
--paper: #ffffff;
|
||||||
--text: #243042;
|
--text: #233044;
|
||||||
--muted: #64748b;
|
--muted: #718198;
|
||||||
--primary: #6d5efc;
|
--primary: #6d5efc;
|
||||||
--primary-soft: rgba(109,94,252,0.10);
|
--primary-soft: rgba(109,94,252,0.10);
|
||||||
--accent: #13b0a5;
|
--accent: #14b8a6;
|
||||||
--line: rgba(148,163,184,0.20);
|
--line: rgba(148,163,184,0.18);
|
||||||
--code-bg: #0f172a;
|
--code-bg: #0f172a;
|
||||||
--code-fg: #e2e8f0;
|
--code-fg: #e2e8f0;
|
||||||
--quote-bg: rgba(19,176,165,0.08);
|
--quote-bg: rgba(20,184,166,0.08);
|
||||||
--shadow: 0 18px 50px rgba(94, 92, 154, 0.12);
|
--shadow: 0 20px 45px rgba(80, 84, 125, 0.10);
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body { margin: 0; padding: 0; }
|
html, body { margin: 0; padding: 0; }
|
||||||
@@ -97,24 +112,53 @@ async def md_str_to_html_content(md_content):
|
|||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, #ffffff 0%, var(--bg1) 38%, transparent 70%),
|
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%),
|
radial-gradient(circle at top right, #ffffff 0%, var(--bg2) 34%, transparent 70%),
|
||||||
linear-gradient(180deg, #f7f8fc 0%, #eef4fb 100%);
|
linear-gradient(180deg, #f8f9fd 0%, #eef4fb 100%);
|
||||||
padding: 28px;
|
padding: 26px;
|
||||||
}
|
}
|
||||||
.wrap {
|
.wrap {
|
||||||
max-width: 820px;
|
max-width: 820px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
background: var(--paper);
|
background: rgba(255,255,255,0.96);
|
||||||
border: 1px solid rgba(255,255,255,0.7);
|
border: 1px solid rgba(255,255,255,0.7);
|
||||||
border-radius: 28px;
|
border-radius: 28px;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
}
|
||||||
.hero {
|
.hero {
|
||||||
min-height: 22px;
|
padding: 30px 34px 22px;
|
||||||
background: linear-gradient(135deg, rgba(109,94,252,0.14), rgba(19,176,165,0.09));
|
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);
|
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 {
|
.content {
|
||||||
padding: 24px 34px 34px;
|
padding: 24px 34px 34px;
|
||||||
}
|
}
|
||||||
@@ -126,18 +170,7 @@ async def md_str_to_html_content(md_content):
|
|||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
h1 {
|
h1 { display: none; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.42em;
|
font-size: 1.42em;
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
@@ -151,7 +184,7 @@ async def md_str_to_html_content(md_content):
|
|||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
color: #30435f;
|
color: #30435f;
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
border-left: 3px solid rgba(19,176,165,0.55);
|
border-left: 3px solid rgba(20,184,166,0.55);
|
||||||
}
|
}
|
||||||
p {
|
p {
|
||||||
margin: 14px 0;
|
margin: 14px 0;
|
||||||
@@ -166,16 +199,12 @@ async def md_str_to_html_content(md_content):
|
|||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
color: #334155;
|
color: #334155;
|
||||||
}
|
}
|
||||||
li::marker {
|
li::marker { color: var(--primary); }
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
strong {
|
strong {
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
em {
|
em { color: #5b6b84; }
|
||||||
color: #5b6b84;
|
|
||||||
}
|
|
||||||
code {
|
code {
|
||||||
background: rgba(109,94,252,0.08);
|
background: rgba(109,94,252,0.08);
|
||||||
color: #5b3df5;
|
color: #5b3df5;
|
||||||
@@ -204,7 +233,7 @@ async def md_str_to_html_content(md_content):
|
|||||||
border-spacing: 0;
|
border-spacing: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 20px 0;
|
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: 1px solid rgba(148,163,184,0.16);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -225,7 +254,7 @@ async def md_str_to_html_content(md_content):
|
|||||||
margin: 18px 0;
|
margin: 18px 0;
|
||||||
padding: 14px 18px;
|
padding: 14px 18px;
|
||||||
background: var(--quote-bg);
|
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-left: 5px solid var(--accent);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
color: #355468;
|
color: #355468;
|
||||||
@@ -259,9 +288,13 @@ async def md_str_to_html_content(md_content):
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<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">
|
<div class="content">
|
||||||
{html_body}
|
{remain_html}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
@@ -300,7 +333,8 @@ async def html_to_image(html_content, output_image):
|
|||||||
launch_args = [
|
launch_args = [
|
||||||
"--no-sandbox",
|
"--no-sandbox",
|
||||||
"--disable-setuid-sandbox",
|
"--disable-setuid-sandbox",
|
||||||
"--disable-dev-shm-usage"
|
"--disable-dev-shm-usage",
|
||||||
|
"--disable-gpu"
|
||||||
]
|
]
|
||||||
|
|
||||||
if browser_path:
|
if browser_path:
|
||||||
@@ -311,26 +345,24 @@ async def html_to_image(html_content, output_image):
|
|||||||
browser = await p.chromium.launch(args=launch_args)
|
browser = await p.chromium.launch(args=launch_args)
|
||||||
|
|
||||||
try:
|
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()
|
page = await context.new_page()
|
||||||
|
|
||||||
logger.debug("Measure body height")
|
logger.debug("Set page content")
|
||||||
body_height = await page.evaluate("document.body.scrollHeight")
|
await page.set_content(html_content, wait_until='domcontentloaded', timeout=15000)
|
||||||
await page.set_viewport_size({"width": 750, "height": body_height})
|
|
||||||
|
|
||||||
logger.debug("Wait for fonts ready")
|
logger.debug("Wait for fonts ready")
|
||||||
await page.evaluate("document.fonts.ready")
|
await page.evaluate("document.fonts.ready")
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
logger.debug("Set page content")
|
logger.debug(f"Take screenshot: output={output_image}")
|
||||||
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}")
|
|
||||||
await page.screenshot(
|
await page.screenshot(
|
||||||
path=output_image,
|
path=output_image,
|
||||||
full_page=True,
|
full_page=True,
|
||||||
timeout=30000,
|
timeout=15000,
|
||||||
animations="disabled"
|
animations="disabled"
|
||||||
)
|
)
|
||||||
if not os.path.exists(output_image):
|
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)
|
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:
|
if not md_content:
|
||||||
raise ValueError("Markdown content cannot be empty")
|
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
|
last_error = e
|
||||||
logger.warning(f"第 {attempt + 1} 次尝试失败: {e}")
|
logger.warning(f"第 {attempt + 1} 次尝试失败: {e}")
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
await asyncio.sleep((attempt + 1) * 2)
|
await asyncio.sleep(1.5)
|
||||||
|
|
||||||
raise RuntimeError(f"图片生成失败,已重试 {max_retries} 次。最后错误: {last_error}")
|
raise RuntimeError(f"图片生成失败,已重试 {max_retries} 次。最后错误: {last_error}")
|
||||||
|
|||||||
Reference in New Issue
Block a user