diff --git a/scripts/image_generator.py b/scripts/image_generator.py
index 43cfffe..198aba5 100644
--- a/scripts/image_generator.py
+++ b/scripts/image_generator.py
@@ -3,9 +3,67 @@
多平台文章配图生成器
为知乎/公众号/小红书生成平台风格的 SVG 配图(base64 内联,无需外部资源)
"""
-import base64, math, textwrap, re
+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",
@@ -39,7 +97,7 @@ PLATFORM_STYLES = {
},
}
-FONT = "-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', sans-serif"
+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]:
@@ -61,15 +119,6 @@ def _wrap_chinese(text: str, chars_per_line: int = 14) -> List[str]:
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("'", ''')
@@ -166,11 +215,11 @@ def generate_data_highlight(platform: str, number: str, label: str) -> str:
def insert_lead_image(html: str, platform: str, title: str, field: str) -> str:
- """在 HTML 正文开头插入头图(仅必要环节)"""
+ """在 HTML 正文开头插入头图(位于 h1 之前)"""
lead = generate_lead(platform, title, field)
- h1_end = html.find('')
- if h1_end != -1:
- html = html[:h1_end + 5] + '\n' + lead + html[h1_end + 5:]
+ h1_start = html.find('
')
+ if h1_start != -1:
+ html = html[:h1_start] + lead + '\n' + html[h1_start:]
else:
html = lead + html
return html
diff --git a/scripts/prompt_loader.py b/scripts/prompt_loader.py
index 2608041..6cd2b60 100644
--- a/scripts/prompt_loader.py
+++ b/scripts/prompt_loader.py
@@ -45,7 +45,7 @@ _PROMPT_DEFAULTS = {
"variables": ["gaps"],
},
"section_expansion": {
- "content": "你是一个资深作者,正在写一篇关于「{topic_title}」的文章。请写「{section_title}」这一节。\n\n今天日期:{date}。\n\n笔记要点:\n{content}\n\n【输出要求】\n输出3-6段纯粹、流畅的段落文字,每节内容根据平台需求控制在200-800字之间。\n\n格式:\n- 禁止任何标题/列表/格式标记(#、-、*、1.、**等)\n- 每段3-5句,段间空行分隔\n- 用「你」或「我」视角,自然口语化。全文统一使用「你」称呼读者,不要混用「你们」\n- **禁止以「引言」「核心观点」「受众痛点」「总结」「开头钩子」「独特视角」这类结构标签开头**——直接从场景或痛点切入\n\n内容要求(让文章在各平台能被推荐):\n- 开头直接切入场景或痛点,一句话抓住注意力,不要铺垫\n- 每个观点配具体案例或数据(用「据统计」「调研显示」等),不要空泛说理\n- 有独特判断和立场,避免正确废话\n- 回答「所以呢」——读者看完能带走什么\n- **避免AI套话**:不要出现「一个真实的XX案例很能说明问题」「说回到XX这件事」「这就是XX被XX后的样子」「如果你也XX」「值得注意的是」「首先其次最后」「综上所述」\n- 结尾有情绪感召力,让人想点赞/收藏/转发\n- 引用数据或案例时,在行内用(来源:报告/案例名称)标注\n\n直接输出段落正文,不要任何附加说明。\n\n【注意:如果你是写最后一节,在正文写完后加一行 --- 分隔,然后写 **参考资料**,每行一个来源:- 来源名称(简要说明)。前面几节不要加这个。】",
+ "content": "你是一个资深作者,正在写一篇关于「{topic_title}」的文章。请写「{section_title}」这一节。\n\n今天日期:{date}。\n\n笔记要点:\n{content}\n\n【输出要求】\n输出3-6段纯粹、流畅的段落文字,每节内容根据平台需求控制在200-800字之间。\n\n格式:\n- 禁止任何标题/列表/格式标记(#、-、*、1.、**等)\n- 每段3-5句,段间空行分隔\n- 用「你」或「我」视角,自然口语化。全文统一使用「你」称呼读者,不要混用「你们」\n- **禁止以「引言」「核心观点」「受众痛点」「总结」「开头钩子」「独特视角」这类结构标签开头**——直接从场景或痛点切入\n\n内容要求(让文章在各平台能被推荐):\n- 开头直接切入场景或痛点,一句话抓住注意力,不要铺垫\n- 每个观点配具体案例或数据(用「据统计」「调研显示」等),不要空泛说理\n- 有独特判断和立场,避免正确废话\n- 回答「所以呢」——读者看完能带走什么\n- **避免AI套话**:不要出现「一个真实的XX案例很能说明问题」「说回到XX这件事」「这就是XX被XX后的样子」「如果你也XX」「值得注意的是」「首先其次最后」「综上所述」\n- 结尾有情绪感召力,让人想点赞/收藏/转发\n- 引用数据或案例时,在行内用(来源:报告/案例名称)标注\n\n直接输出段落正文,不要任何附加说明。\n\n【注意:如果你是写最后一节,在正文写完后加一行 --- 分隔,然后写 **参考资料**,每行一个来源:- 来源名称(简要说明)。如果知道该来源的公开URL,在名称后用空格加上URL。前面几节不要加这个。】",
"temperature": 0.75, "max_tokens": 3000,
"variables": ["topic_title", "section_title", "date", "content"],
},