优化抖音图文发送:标题与图片合并为单次图片消息

变更项:\n1. 图文类型取消先发文本再发图片的流程。\n2. 新增标题渲染逻辑,将文案绘制到合并图第一页顶部后统一发送图片。\n3. 新增中文字体加载与按像素宽度自动换行能力,避免标题超宽截断。\n4. 渲染失败时回退原图,保证发送链路稳定。
This commit is contained in:
liuwei
2026-04-20 15:47:31 +08:00
parent a0a6ea8e08
commit 5efbabb879

View File

@@ -9,7 +9,7 @@ from urllib.parse import urlparse
from loguru import logger from loguru import logger
from pathlib import Path from pathlib import Path
from PIL import Image from PIL import Image, ImageDraw, ImageFont
from base.plugin_common.message_plugin_interface import MessagePluginInterface from base.plugin_common.message_plugin_interface import MessagePluginInterface
from base.plugin_common.plugin_interface import PluginStatus from base.plugin_common.plugin_interface import PluginStatus
@@ -158,8 +158,10 @@ class DouyinParserPlugin(MessagePluginInterface):
if not merged_pages: if not merged_pages:
return False, "图片合并失败" return False, "图片合并失败"
title = media_info.get('title') or "" title = media_info.get('title') or ""
# 按你的需求,图文类型不再单独发送一条文本消息。
# 这里把文案直接绘制到合并后第一页的顶部,让“文字 + 图片”作为同一条图片消息的一部分发送。
if len(title) > 0: if len(title) > 0:
await self.bot.send_text_message((roomid if roomid else sender), title) merged_pages[0] = self._append_title_to_image(merged_pages[0], title)
for page in merged_pages: for page in merged_pages:
await self.bot.send_image_message((roomid if roomid else sender), page) await self.bot.send_image_message((roomid if roomid else sender), page)
return True, f"发送合并图片成功({len(merged_pages)}页)" return True, f"发送合并图片成功({len(merged_pages)}页)"
@@ -483,3 +485,96 @@ class DouyinParserPlugin(MessagePluginInterface):
return outputs if outputs else None return outputs if outputs else None
except Exception: except Exception:
return None return None
def _append_title_to_image(self, image_bytes: bytes, title: str) -> bytes:
"""
将标题绘制到图片顶部,返回新的图片二进制数据。
设计说明:
1) 微信接口没有“单条消息同时携带纯文本+图片”的通用发送 API
2) 为了满足“图文合并发送”,这里把标题渲染为图片顶部文字区域;
3) 渲染失败时直接回退原图,避免影响主流程可用性。
"""
if not title:
return image_bytes
try:
source = Image.open(io.BytesIO(image_bytes))
if source.mode in ("RGBA", "P"):
source = source.convert("RGB")
width, height = source.size
# 文字区域留出左右/上下内边距,保证可读性。
pad_x = 36
pad_y = 26
font = self._load_chinese_font(44)
wrapped_lines = self._wrap_text_for_image(title.strip(), font, max(100, width - pad_x * 2))
if not wrapped_lines:
return image_bytes
# 行高按字体大小动态计算,并增加少量行间距。
line_height = max(44, int(font.size * 1.4))
text_block_height = pad_y * 2 + line_height * len(wrapped_lines)
# 新建画布:上方白底承载文案,下方保留原图内容。
canvas = Image.new("RGB", (width, height + text_block_height), (255, 255, 255))
canvas.paste(source, (0, text_block_height))
draw = ImageDraw.Draw(canvas)
y = pad_y
for line in wrapped_lines:
draw.text((pad_x, y), line, font=font, fill=(34, 34, 34))
y += line_height
output = io.BytesIO()
canvas.save(output, format="JPEG", quality=88)
return output.getvalue()
except Exception as e:
self.LOG.warning(f"标题绘制失败,回退原图: {e}")
return image_bytes
def _load_chinese_font(self, size: int) -> ImageFont.FreeTypeFont:
"""
尝试加载常见中文字体,保证标题在不同系统尽量可读。
如果都不可用,则回退到 Pillow 默认字体(可能不支持完整中文)。
"""
font_candidates = [
"C:/Windows/Fonts/msyh.ttc",
"C:/Windows/Fonts/simhei.ttf",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/System/Library/Fonts/PingFang.ttc",
]
for font_path in font_candidates:
if os.path.exists(font_path):
try:
return ImageFont.truetype(font_path, size=size)
except Exception:
continue
return ImageFont.load_default()
def _wrap_text_for_image(self, text: str, font: ImageFont.ImageFont, max_width: int) -> List[str]:
"""
按像素宽度将文本自动换行,避免标题超宽被截断。
实现策略:
- 逐字追加,超过最大宽度就换行;
- 保留原有换行语义(按行分段后再逐字处理)。
"""
draw = ImageDraw.Draw(Image.new("RGB", (10, 10)))
lines: List[str] = []
for para in text.splitlines():
if not para:
lines.append("")
continue
current = ""
for ch in para:
test = current + ch
text_width = int(draw.textlength(test, font=font))
if current and text_width > max_width:
lines.append(current)
current = ch
else:
current = test
if current:
lines.append(current)
return lines