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

234 lines
9.4 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, io, os
from typing import List, Optional
CJK_FONT = "/usr/share/fonts/google-noto-cjk/NotoSansCJKsc-Regular.otf"
CJK_FONT_BOLD = "/usr/share/fonts/google-noto-cjk/NotoSansCJKsc-Bold.otf"
def _svg_to_png_with_pil(svg: str) -> bytes:
"""用 cairosvg 渲染背景 + Pillow 叠加中文文字"""
from PIL import Image, ImageDraw, ImageFont
import cairosvg
text_elements = []
def _extract_text(m):
attrs = dict(re.findall(r'([\w-]+)="([^"]*)"', m.group(1)))
text_elements.append({
'x': int(float(attrs.get('x', 0))),
'y': int(float(attrs.get('y', 0))),
'size': int(float(attrs.get('font-size', 14))),
'weight': attrs.get('font-weight', 'normal'),
'fill': attrs.get('fill', '#000'),
'opacity': float(attrs.get('opacity', 1) or 1),
'text': m.group(2),
})
return ''
svg_no_text = re.sub(r'<text\b([^>]*)>([^<]*)</text>', _extract_text, svg)
png = cairosvg.svg2png(bytestring=svg_no_text.encode('utf-8'))
img = Image.open(io.BytesIO(png)).convert('RGBA')
draw = ImageDraw.Draw(img, 'RGBA')
for el in text_elements:
if not el['text'].strip():
continue
font_path = CJK_FONT_BOLD if el['weight'] in ('bold', '700', '800', '900') else CJK_FONT
try:
font = ImageFont.truetype(font_path, el['size'])
except Exception:
font = ImageFont.load_default()
fill_color = el['fill'].lstrip('#')
try:
r, g, b = tuple(int(fill_color[i:i+2], 16) for i in (0, 2, 4))
except Exception:
r, g, b = 0, 0, 0
a = int(255 * el['opacity'])
_, _, tw, th = draw.textbbox((0, 0), el['text'], font=font)
draw.text((el['x'], el['y'] - int(el['size'] * 0.85)), el['text'], font=font, fill=(r, g, b, a))
buf = io.BytesIO()
img.save(buf, 'PNG')
return buf.getvalue()
def _to_base64(svg: str) -> str:
try:
png = _svg_to_png_with_pil(svg)
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')
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, 'Noto Sans CJK SC', '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 _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 正文开头插入头图(位于 h1 之前)"""
lead = generate_lead(platform, title, field)
h1_start = html.find('<h1>')
if h1_start != -1:
html = html[:h1_start] + lead + '\n' + html[h1_start:]
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"},
}