#!/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']*)>([^<]*)', _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('&', '&').replace('<', '<').replace('>', '>').replace('"', '"').replace("'", ''')
def _img_tag(src: str, alt: str, width: int = 1080) -> str:
return f'

\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'{_alt_attr(line)}\n'
field_badge = ""
if field:
field_badge = f'''
{_alt_attr(field)}'''
svg = f''''''
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''''''
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'{_alt_attr(line)}\n'
source_line = ""
if source:
source_line = f'{_alt_attr(f"— {source}")}'
svg = f''''''
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''''''
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('')
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"},
}