#!/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'

{_alt_attr(alt)}

\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''' {title_lines} {field_badge} {_alt_attr(brand)} ''' 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''' {num_text} {_alt_attr(section_title)} ''' 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''' " {quote_lines} {source_line} ''' 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''' {_alt_attr(number)} {_alt_attr(label)} ''' 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"}, }