From 80023bbd6ca86c4a4473ff6037835c2aa9eff8c5 Mon Sep 17 00:00:00 2001 From: Yuzhiran Dev Date: Tue, 26 May 2026 17:38:28 +0800 Subject: [PATCH] fix: hybrid SVG+PIL PNG image rendering with CJK fonts, prompt URL reference --- scripts/image_generator.py | 79 ++++++++++++++++++++++++++++++-------- scripts/prompt_loader.py | 2 +- 2 files changed, 65 insertions(+), 16 deletions(-) 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"], },