fix: hybrid SVG+PIL PNG image rendering with CJK fonts, prompt URL reference
This commit is contained in:
+64
-15
@@ -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'<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",
|
||||
@@ -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('</h1>')
|
||||
if h1_end != -1:
|
||||
html = html[:h1_end + 5] + '\n' + lead + html[h1_end + 5:]
|
||||
h1_start = html.find('<h1>')
|
||||
if h1_start != -1:
|
||||
html = html[:h1_start] + lead + '\n' + html[h1_start:]
|
||||
else:
|
||||
html = lead + html
|
||||
return html
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user