185 lines
7.5 KiB
Python
185 lines
7.5 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
多平台文章配图生成器
|
||
为知乎/公众号/小红书生成平台风格的 SVG 配图(base64 内联,无需外部资源)
|
||
"""
|
||
import base64, math, textwrap, re
|
||
from typing import List, Optional
|
||
|
||
PLATFORM_STYLES = {
|
||
"zhihu": {
|
||
"primary": "#0084ff",
|
||
"primary_rgb": "0, 132, 255",
|
||
"accent": "#e8f4fd",
|
||
"gradient_start": "#e8f4fd",
|
||
"gradient_end": "#f5f9ff",
|
||
"card_bg": "#ffffff",
|
||
"title_color": "#1a1a1a",
|
||
"dim_color": "#c0c4cc",
|
||
},
|
||
"wechat": {
|
||
"primary": "#07c160",
|
||
"primary_rgb": "7, 193, 96",
|
||
"accent": "#f0faf4",
|
||
"gradient_start": "#f0faf4",
|
||
"gradient_end": "#e8f5ee",
|
||
"card_bg": "#ffffff",
|
||
"title_color": "#1a1a1a",
|
||
"dim_color": "#c0c4cc",
|
||
},
|
||
"xiaohongshu": {
|
||
"primary": "#ff2442",
|
||
"primary_rgb": "255, 36, 66",
|
||
"accent": "#fff5f5",
|
||
"gradient_start": "#fff5f5",
|
||
"gradient_end": "#fff0f0",
|
||
"card_bg": "#ffffff",
|
||
"title_color": "#262626",
|
||
"dim_color": "#bfbfbf",
|
||
},
|
||
}
|
||
|
||
FONT = "-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', sans-serif"
|
||
|
||
|
||
def _wrap_chinese(text: str, chars_per_line: int = 14) -> List[str]:
|
||
"""将中文文本按字数折行,尽量在标点处断开"""
|
||
if not text:
|
||
return [""]
|
||
lines = []
|
||
remainder = text
|
||
while len(remainder) > chars_per_line:
|
||
chunk = remainder[:chars_per_line]
|
||
# 尝试在最后一个标点处断开
|
||
cut = max(chunk.rfind(c) + 1 for c in (",", "、", "。", "!", "?", ":", ";", ")", " ", "—") if c in chunk[:-1])
|
||
if cut <= 0:
|
||
cut = chars_per_line
|
||
lines.append(remainder[:cut].strip())
|
||
remainder = remainder[cut:].strip()
|
||
if remainder:
|
||
lines.append(remainder)
|
||
return lines
|
||
|
||
|
||
def _to_base64(svg: str) -> str:
|
||
try:
|
||
import cairosvg
|
||
png = cairosvg.svg2png(bytestring=svg.encode('utf-8'))
|
||
return 'data:image/png;base64,' + base64.b64encode(png).decode('ascii')
|
||
except Exception:
|
||
return 'data:image/svg+xml;base64,' + base64.b64encode(svg.encode('utf-8')).decode('ascii')
|
||
|
||
|
||
def _alt_attr(text: str) -> str:
|
||
return text.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"').replace("'", ''')
|
||
|
||
|
||
def _img_tag(src: str, alt: str, width: int = 1080) -> str:
|
||
return f'<p><img src="{src}" alt="{_alt_attr(alt)}" style="width:100%;max-width:{width}px;border-radius:8px;"></p>\n'
|
||
|
||
|
||
def generate_lead(platform: str, title: str, field: str = "", brand: str = "宇之然") -> str:
|
||
"""生成文章头图(1080×600)"""
|
||
s = PLATFORM_STYLES.get(platform, PLATFORM_STYLES["zhihu"])
|
||
lines = _wrap_chinese(title, 16)
|
||
|
||
title_lines = ""
|
||
y_start = 160
|
||
for i, line in enumerate(lines[:3]):
|
||
title_lines += f'<text x="80" y="{y_start + i*60}" font-size="44" font-weight="bold" fill="{s["title_color"]}">{_alt_attr(line)}</text>\n'
|
||
|
||
field_badge = ""
|
||
if field:
|
||
field_badge = f'''
|
||
<rect x="80" y="{y_start + len(lines[:3]) * 60 + 20}" width="{len(field)*14 + 32}" height="34" rx="17" fill="{s["primary"]}" opacity="0.12"/>
|
||
<text x="96" y="{y_start + len(lines[:3]) * 60 + 43}" font-size="14" fill="{s["primary"]}" font-weight="500">{_alt_attr(field)}</text>'''
|
||
|
||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="600" viewBox="0 0 1080 600" style="width:100%;max-width:1080px;border-radius:8px;">
|
||
<defs>
|
||
<linearGradient id="lead_bg" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="{s["gradient_start"]}"/><stop offset="100%" stop-color="{s["gradient_end"]}"/></linearGradient>
|
||
</defs>
|
||
<rect width="1080" height="600" fill="url(#lead_bg)"/>
|
||
<circle cx="120" cy="120" r="220" fill="{s["primary"]}" opacity="0.04"/>
|
||
<circle cx="960" cy="480" r="180" fill="{s["primary"]}" opacity="0.06"/>
|
||
<circle cx="540" cy="300" r="320" fill="{s["primary"]}" opacity="0.02"/>
|
||
<rect x="80" y="80" width="80" height="4" rx="2" fill="{s["primary"]}"/>
|
||
<g font-family="{FONT}">
|
||
{title_lines}
|
||
{field_badge}
|
||
<text x="80" y="540" font-size="14" fill="{s["dim_color"]}">{_alt_attr(brand)}</text>
|
||
</g>
|
||
</svg>'''
|
||
return _img_tag(_to_base64(svg), title)
|
||
|
||
|
||
def generate_section_card(platform: str, section_title: str, section_num: int) -> str:
|
||
"""生成章节分隔图(800×160)"""
|
||
s = PLATFORM_STYLES.get(platform, PLATFORM_STYLES["zhihu"])
|
||
num_text = f"{section_num:02d}"
|
||
|
||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="800" height="160" viewBox="0 0 800 160" style="width:100%;max-width:800px;border-radius:8px;">
|
||
<rect width="800" height="160" fill="{s["card_bg"]}" rx="10"/>
|
||
<rect x="0" y="0" width="5" height="160" fill="{s["primary"]}" rx="2.5"/>
|
||
<text x="36" y="72" font-size="56" font-weight="bold" fill="{s["primary"]}" opacity="0.12" font-family="{FONT}">{num_text}</text>
|
||
<text x="36" y="120" font-size="20" font-weight="bold" fill="{s["title_color"]}" font-family="{FONT}">{_alt_attr(section_title)}</text>
|
||
</svg>'''
|
||
return _img_tag(_to_base64(svg), section_title, 800)
|
||
|
||
|
||
def generate_quote_card(platform: str, quote: str, source: str = "") -> str:
|
||
"""生成金句卡片(800×280)"""
|
||
s = PLATFORM_STYLES.get(platform, PLATFORM_STYLES["zhihu"])
|
||
lines = _wrap_chinese(quote, 20)
|
||
|
||
quote_lines = ""
|
||
y_start = 110
|
||
for i, line in enumerate(lines[:4]):
|
||
quote_lines += f'<text x="80" y="{y_start + i*36}" font-size="18" fill="{s["title_color"]}" font-weight="500">{_alt_attr(line)}</text>\n'
|
||
|
||
source_line = ""
|
||
if source:
|
||
source_line = f'<text x="80" y="{y_start + min(len(lines), 4)*36 + 6}" font-size="13" fill="{s["dim_color"]}">{_alt_attr(f"— {source}")}</text>'
|
||
|
||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="800" height="280" viewBox="0 0 800 280" style="width:100%;max-width:800px;border-radius:8px;">
|
||
<rect width="800" height="280" fill="{s["accent"]}" rx="10"/>
|
||
<text x="40" y="80" font-size="56" fill="{s["primary"]}" opacity="0.2" font-family="Georgia, serif">"</text>
|
||
<g font-family="{FONT}">
|
||
{quote_lines}
|
||
{source_line}
|
||
</g>
|
||
</svg>'''
|
||
return _img_tag(_to_base64(svg), f"金句:{quote[:30]}", 800)
|
||
|
||
|
||
def generate_data_highlight(platform: str, number: str, label: str) -> str:
|
||
"""生成数据高亮卡片(800×220)"""
|
||
s = PLATFORM_STYLES.get(platform, PLATFORM_STYLES["zhihu"])
|
||
|
||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="800" height="220" viewBox="0 0 800 220" style="width:100%;max-width:800px;border-radius:8px;">
|
||
<rect width="800" height="220" fill="{s["primary"]}" rx="10"/>
|
||
<g font-family="{FONT}">
|
||
<text x="80" y="120" font-size="60" font-weight="bold" fill="#ffffff">{_alt_attr(number)}</text>
|
||
<text x="80" y="170" font-size="16" fill="#ffffff" opacity="0.85">{_alt_attr(label)}</text>
|
||
</g>
|
||
</svg>'''
|
||
return _img_tag(_to_base64(svg), label, 800)
|
||
|
||
|
||
def insert_lead_image(html: str, platform: str, title: str, field: str) -> str:
|
||
"""在 HTML 正文开头插入头图(仅必要环节)"""
|
||
lead = generate_lead(platform, title, field)
|
||
h1_end = html.find('</h1>')
|
||
if h1_end != -1:
|
||
html = html[:h1_end + 5] + '\n' + lead + html[h1_end + 5:]
|
||
else:
|
||
html = lead + html
|
||
return html
|
||
|
||
|
||
# 平台特定的配图密度
|
||
PLATFORM_IMAGE_COUNTS = {
|
||
"zhihu": {"lead": True, "sections": True, "quotes": 0, "data": 0, "density": "medium"},
|
||
"wechat": {"lead": True, "sections": True, "quotes": 0, "data": 0, "density": "medium"},
|
||
"xiaohongshu": {"lead": True, "sections": True, "quotes": 0, "data": 0, "density": "high"},
|
||
}
|