优化抖音图文发送:标题与图片合并为单次图片消息
变更项:\n1. 图文类型取消先发文本再发图片的流程。\n2. 新增标题渲染逻辑,将文案绘制到合并图第一页顶部后统一发送图片。\n3. 新增中文字体加载与按像素宽度自动换行能力,避免标题超宽截断。\n4. 渲染失败时回退原图,保证发送链路稳定。
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user