Files
yu-zhi-ran/scripts/image_generator.py
T

185 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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.
#!/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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;').replace("'", '&#39;')
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"},
}