refactor: Split painter script

This commit is contained in:
LWR 2023-01-18 22:13:01 +08:00
parent d507c3ceb7
commit cbe27f09d0
7 changed files with 1948 additions and 1931 deletions

View File

@ -17,8 +17,8 @@ from .live import LiveDanmaku, LiveRoom
from .model import PushTarget
from .user import User
from ..exception import LiveException
from ..painter.DynamicPicGenerator import DynamicPicGenerator
from ..utils import config, redis
from ..utils.Painter import DynamicPicGenerator
from ..utils.utils import get_credential, timestamp_format, get_unames_and_faces_by_uids
if typing.TYPE_CHECKING:

View File

@ -12,9 +12,9 @@ from pydantic import BaseModel, PrivateAttr
from .model import LiveOn, LiveOff, DynamicUpdate, Message, PushType, PushTarget
from .room import Up
from ..painter.LiveReportGenerator import LiveReportGenerator
from ..utils import config
from ..utils.AsyncEvent import AsyncEvent
from ..utils.Painter import LiveReportGenerator
class Bot(BaseModel, AsyncEvent):

View File

@ -0,0 +1,830 @@
import asyncio
import json
import os
from typing import Optional, Union, Tuple, List, Dict, Any
from PIL import Image, ImageDraw, ImageFont
from PIL.Image import Resampling
from emoji import is_emoji
from .PicGenerator import Color, PicGenerator
from ..utils import config
from ..utils.network import request
from ..utils.utils import open_url_image, timestamp_format, split_list, limit_str_length, \
mask_round, mask_rounded_rectangle
class DynamicPicGenerator:
"""
动态图片生成器
"""
__resource_base_path = os.path.dirname(os.path.dirname(__file__))
@classmethod
async def generate(cls, param: Dict[str, Any]) -> str:
"""
根据传入动态信息生成动态图片
Args:
param: 动态信息
Returns:
动态图片的 Base64 字符串
"""
width = 740
height = 10000
text_margin = 25
img_margin = 10
generator = PicGenerator(width, height)
pic = generator.set_pos(175, 60).draw_rounded_rectangle(0, 0, width, height, 35, Color.WHITE).copy_bottom(35)
# 提取参数
desc = param["desc"]
dynamic_id = desc["dynamic_id"]
origin_dynamic_id = desc["orig_dy_id"] if "orig_dy_id" in desc else None
dynamic_type = desc["type"]
user_profile = desc["user_profile"]
card = json.loads(param["card"])
display = param["display"]
# 动态头部
face, pendant = await asyncio.gather(open_url_image(user_profile["info"]["face"]),
open_url_image(user_profile["pendant"]["image"]))
official = user_profile["card"]["official_verify"]["type"]
vip = user_profile["vip"]["nickname_color"] != ""
uname = user_profile["info"]["uname"]
timestamp = desc["timestamp"]
await cls.__draw_header(pic, face, pendant, official, vip, uname, timestamp)
# 动态主体
pic.set_pos(x=text_margin)
pic.set_row_space(10)
await cls.__draw_by_type(pic, dynamic_type, card, dynamic_id, display,
text_margin, img_margin, False, origin_dynamic_id)
# 底部版权信息,请务必保留此处
pic.move_pos(0, 15)
pic.draw_text_right(25, "Designed By StarBot", Color.GRAY)
pic.draw_text_right(25, "https://github.com/Starlwr/StarBot", Color.LINK)
pic.crop_and_paste_bottom()
return pic.base64()
@classmethod
def remove_illegal_char(cls, s: str) -> str:
"""
移除动态中的非法字符
Args:
s: 源字符串
Returns:
移除非法字符后的字符串
"""
return s.replace(chr(8203), "").replace(chr(65039), "")
@classmethod
async def __draw_header(cls,
pic: PicGenerator,
face: Image.Image,
pendant: Image.Image,
official: int,
vip: bool,
uname: str,
timestamp: int) -> PicGenerator:
"""
绘制动态头部
Args:
pic: 绘图器实例
face: 头像图片
pendant: 头像挂件图片
official: 认证类型
vip: 是否为大会员
uname: 昵称
timestamp: 动态时间戳
"""
face_size = (100, 100)
face = face.resize(face_size, Resampling.LANCZOS).convert("RGBA")
face = mask_round(face)
pic.draw_img_alpha(face, (50, 50))
if pendant is not None:
pendant_size = (170, 170)
pendant = pendant.resize(pendant_size, Resampling.LANCZOS).convert('RGBA')
pic.draw_img_alpha(pendant, (15, 15))
pic.move_pos(15, 0)
if official == 0:
pic.draw_img_alpha(Image.open(f"{cls.__resource_base_path}/resource/personal.png"), (118, 118))
elif official == 1:
pic.draw_img_alpha(Image.open(f"{cls.__resource_base_path}/resource/business.png"), (118, 118))
elif vip:
pic.draw_img_alpha(Image.open(f"{cls.__resource_base_path}/resource/vip.png"), (118, 118))
if vip:
pic.draw_text(uname, Color.PINK)
else:
pic.draw_text(uname, Color.BLACK)
pic.draw_tip(timestamp_format(timestamp, "%Y-%m-%d %H:%M"))
pic.set_pos(y=200)
return pic
@classmethod
async def __draw_by_type(cls,
pic: PicGenerator,
dynamic_type: int,
card: Dict[str, Any],
dynamic_id: int,
display: Dict[str, Any],
text_margin: int,
img_margin: int,
forward: bool,
origin_dynamic_id: Optional[int] = None):
"""
根据动态类型绘制动态图片
Args:
pic: 绘图器实例
dynamic_type: 动态类型
card: 动态信息
dynamic_id: 动态 ID
display: 动态绘制附加信息
text_margin: 文字外边距
img_margin: 图片外边距
forward: 当前是否为转发动态的源动态
"""
async def download_img(mod: Dict[str, Any]):
"""
下载表情图片
Args:
mod: 表情区块字典
"""
mod["img"] = await open_url_image(mod["emoji"]["icon_url"])
modules_url = f"https://api.bilibili.com/x/polymer/web-dynamic/v1/detail?timezone_offset=-480&id={dynamic_id}"
modules = (await request("GET", modules_url))["item"]["modules"]["module_dynamic"]["desc"]
modules = modules["rich_text_nodes"] if modules else []
# 下载表情
download_picture_tasks = []
for module in modules:
if module["type"] == "RICH_TEXT_NODE_TYPE_EMOJI":
download_picture_tasks.append(download_img(module))
await asyncio.gather(*download_picture_tasks)
if dynamic_type == 1:
# 转发动态
await cls.__draw_content(pic, modules, text_margin, forward)
if "origin" in card:
origin = json.loads(card["origin"])
origin_type = card["item"]["orig_type"]
origin_display = display["origin"]
origin_name = card["origin_user"]["info"]["uname"]
origin_name_at_param = [{"type": "RICH_TEXT_NODE_TYPE_AT", "text": f"@{origin_name}"}]
await cls.__draw_content(pic, origin_name_at_param, text_margin, True)
await cls.__draw_by_type(pic, origin_type, origin, origin_dynamic_id, origin_display,
text_margin, img_margin, True)
elif dynamic_type == 2:
# 带图动态
await cls.__draw_content(pic, modules, text_margin, forward)
await cls.__draw_picture_area(pic, card["item"]["pictures"], img_margin, forward)
elif dynamic_type == 4:
# 纯文字动态
await cls.__draw_content(pic, modules, text_margin, forward)
elif dynamic_type == 8:
# 视频
await cls.__draw_content(pic, modules, text_margin, forward)
await cls.__draw_video_cover(pic, card["pic"], card["duration"], img_margin, forward)
title_param = [{"type": "RICH_TEXT_NODE_TYPE_TEXT", "text": card["title"]}]
await cls.__draw_content(pic, title_param, img_margin, forward)
elif dynamic_type == 64:
# 专栏
title_param = [{"type": "RICH_TEXT_NODE_TYPE_TEXT", "text": card["title"]}]
await cls.__draw_content(pic, title_param, text_margin, forward)
await cls.__draw_article_cover(pic, card["origin_image_urls"], img_margin, forward)
summary = limit_str_length(card['summary'].replace("\n", " "), 60)
summary_param = [{"type": "RICH_TEXT_NODE_TYPE_TEXT", "text": summary}]
await cls.__draw_content(pic, summary_param, text_margin, forward)
elif dynamic_type == 256:
# 音频
await cls.__draw_content(pic, modules, text_margin, forward)
title = limit_str_length(card["title"], 15)
await cls.__draw_audio_area(pic, card["cover"], title, card["typeInfo"], text_margin, forward)
elif dynamic_type == 2048:
# 分享
await cls.__draw_content(pic, modules, text_margin, forward)
title = limit_str_length(card["sketch"]["title"], 15)
desc = limit_str_length(card["sketch"]["desc_text"], 18)
await cls.__draw_share_area(pic, card["sketch"]["cover_url"], title, desc, text_margin, forward)
elif dynamic_type == 4200:
# 直播
title = limit_str_length(card["title"], 14)
desc = f"{card['area_v2_name']} · {card['watched_show']}"
await cls.__draw_live_area(pic, card["cover"], title, desc, text_margin, forward)
elif dynamic_type == 4308:
# 直播
base = card["live_play_info"]
title = limit_str_length(base["title"], 14)
desc = f"{base['area_name']} {base['online']}人气"
await cls.__draw_live_area(pic, base["cover"], title, desc, text_margin, forward)
else:
notice_param = [{"type": "RICH_TEXT_NODE_TYPE_TEXT", "text": "暂不支持的动态类型"}]
await cls.__draw_content(pic, notice_param, text_margin, forward)
# 附加卡片
add_on_card = display["add_on_card_info"] if "add_on_card_info" in display else []
await cls.__draw_add_on_card(pic, add_on_card, text_margin, forward)
@classmethod
async def __get_content_line_imgs(cls, modules: List[Dict[str, Any]], width: int) -> List[Image.Image]:
"""
逐行绘制动态内容主体
Args:
modules: 动态内容各区块信息列表
width: 每行最大宽度
Returns:
绘制好的每行文字的透明背景图片列表
"""
imgs = []
if not modules:
return imgs
line_height = 40
text_img_size = (40, 40)
img = Image.new("RGBA", (width, line_height))
draw = ImageDraw.Draw(img)
normal_font = config.get("PAINTER_NORMAL_FONT")
font = ImageFont.truetype(f"{cls.__resource_base_path}/resource/{normal_font}", 30)
emoji_font = ImageFont.truetype(f"{cls.__resource_base_path}/resource/emoji.ttf", 109)
x, y = 0, 0
def next_line():
"""
换行
"""
nonlocal img, draw, x, y
imgs.append(img)
img = Image.new("RGBA", (width, line_height))
draw = ImageDraw.Draw(img)
x, y = 0, 0
def auto_next_line(next_element_width: int):
"""
超出画布宽度自动换行
Args:
next_element_width: 下一绘制元素宽度
"""
if x + next_element_width > width:
next_line()
def draw_pic(pic: Image, size: Optional[Tuple[int, int]] = None):
"""
绘制图片
Args:
pic: 要绘制的图片
size: 图片尺寸
"""
nonlocal img, draw, x, y
if size is None:
size = pic.size
else:
pic = pic.resize(size, Resampling.LANCZOS).convert('RGBA')
auto_next_line(size[0])
img.paste(pic, (x, y), pic)
x = int(x + size[0])
def draw_char(c: str, color: Union[Color, Tuple[int, int, int]] = Color.BLACK):
"""
绘制字符
Args:
c: 要绘制的字符
color: 字符颜色
"""
nonlocal x
if isinstance(color, Color):
color = color.value
if c == "\n":
next_line()
else:
if is_emoji(c):
emoji_img = Image.new("RGBA", (130, 130))
emoji_draw = ImageDraw.Draw(emoji_img)
emoji_draw.text((0, 0), c, font=emoji_font, embedded_color=True)
emoji_img = emoji_img.resize((30, 30), Resampling.LANCZOS)
draw_pic(emoji_img)
else:
text_width = draw.textlength(c, font)
auto_next_line(text_width)
draw.text((x, y), c, color, font)
x = int(x + text_width)
for module in modules:
module_type = module["type"]
if module_type == "RICH_TEXT_NODE_TYPE_TEXT":
for char in cls.remove_illegal_char(module["text"]):
draw_char(char)
elif module_type == "RICH_TEXT_NODE_TYPE_EMOJI":
draw_pic(module["img"], text_img_size)
elif module_type in ["RICH_TEXT_NODE_TYPE_AT", "RICH_TEXT_NODE_TYPE_WEB", "RICH_TEXT_NODE_TYPE_BV",
"RICH_TEXT_NODE_TYPE_TOPIC", "RICH_TEXT_NODE_TYPE_LOTTERY",
"RICH_TEXT_NODE_TYPE_VOTE", "RICH_TEXT_NODE_TYPE_GOODS"]:
if module_type == "RICH_TEXT_NODE_TYPE_WEB":
draw_pic(Image.open(f"{cls.__resource_base_path}/resource/link.png"), text_img_size)
elif module_type == "RICH_TEXT_NODE_TYPE_BV":
draw_pic(Image.open(f"{cls.__resource_base_path}/resource/video.png"), text_img_size)
elif module_type == "RICH_TEXT_NODE_TYPE_LOTTERY":
draw_pic(Image.open(f"{cls.__resource_base_path}/resource/box.png"), text_img_size)
elif module_type == "RICH_TEXT_NODE_TYPE_VOTE":
draw_pic(Image.open(f"{cls.__resource_base_path}/resource/tick.png"), text_img_size)
elif module_type == "RICH_TEXT_NODE_TYPE_GOODS":
draw_pic(Image.open(f"{cls.__resource_base_path}/resource/tb.png"), text_img_size)
for char in cls.remove_illegal_char(module["text"]):
draw_char(char, Color.LINK)
imgs.append(img)
return imgs
@classmethod
async def __draw_content(cls,
pic: PicGenerator,
modules: List[Dict[str, Any]],
text_margin: int,
forward: bool) -> PicGenerator:
"""
绘制动态主体内容
Args:
pic: 绘图器实例
modules: @ 信息
text_margin: 文字外边距
forward: 当前是否为转发动态的源动态
"""
pic.set_pos(x=text_margin)
content_imgs = await cls.__get_content_line_imgs(modules, pic.width - (text_margin * 2))
if forward and content_imgs:
heights = (content_imgs[0].height + pic.row_space) * len(content_imgs)
pic.draw_rectangle(0, pic.y, pic.width, heights, Color.LIGHTGRAY)
for img in content_imgs:
pic.draw_img_alpha(img)
return pic
@classmethod
async def __draw_picture_area(cls,
pic: PicGenerator,
pictures: List[Dict[str, Any]],
img_margin: int,
forward: bool) -> PicGenerator:
"""
绘制动态主体下方图片区域
Args:
pic: 绘图器实例
pictures: 图片信息字典
img_margin: 图片外边距
forward: 当前是否为转发动态的源动态
"""
pic.set_pos(x=img_margin)
# 下载图片
picture_count = len(pictures)
if picture_count == 1:
line_count = 1
size = 0
elif picture_count == 2 or picture_count == 4:
line_count = 2
size = int((pic.width - (img_margin * 3)) / 2)
else:
line_count = 3
size = int((pic.width - (img_margin * 4)) / 3)
download_picture_tasks = []
for picture in pictures:
if line_count == 1:
download_picture_tasks.append(open_url_image(f"{picture['img_src']}@518w.webp"))
elif line_count == 2:
src = picture['img_src']
if picture["img_height"] / picture["img_width"] >= 3:
download_picture_tasks.append(open_url_image(f"{src}@{size}w_{size}h_!header.webp"))
else:
download_picture_tasks.append(open_url_image(f"{src}@{size}w_{size}h_1e_1c.webp"))
else:
src = picture['img_src']
if picture["img_height"] / picture["img_width"] >= 3:
download_picture_tasks.append(open_url_image(f"{src}@{size}w_{size}h_!header.webp"))
else:
download_picture_tasks.append(open_url_image(f"{src}@{size}w_{size}h_1e_1c.webp"))
imgs = await asyncio.gather(*download_picture_tasks)
img_list = []
for i, img in enumerate(imgs):
if size != 0 and (img.width != size or img.height != size):
if img.width == img.height:
img_list.append(img.resize((size, size)))
elif img.width > img.height:
img = img.resize((int(img.width * (size / img.height)), size))
crop_area = (int((img.width - size) / 2), 0, int((img.width - size) / 2) + size, size)
img_list.append(img.crop(crop_area))
else:
img = img.resize((size, int(img.height * (size / img.width))))
crop_area = (0, int((img.height - size) / 2), size, int((img.height - size) / 2) + size)
img_list.append(img.crop(crop_area))
else:
img_list.append(img)
imgs = tuple(img_list)
if picture_count == 1:
img = imgs[0]
img = img.resize(((pic.width - (img_margin * 2)),
int((pic.width - (img_margin * 4)) / (img.size[0] / img.size[1]))),
Resampling.LANCZOS)
imgs = [img]
# 绘制图片
imgs = split_list(imgs, line_count)
if forward:
heights = (imgs[0][0].height + pic.row_space) * len(imgs)
pic.draw_rectangle(0, pic.y, pic.width, heights, Color.LIGHTGRAY)
for line in imgs:
for index, img in enumerate(line):
if index == len(line) - 1:
pic.draw_img(img).set_pos(x=img_margin)
else:
pic.draw_img(img, (pic.x, pic.y)).move_pos(img.width + img_margin, 0)
return pic
@classmethod
async def __draw_video_cover(cls,
pic: PicGenerator,
url: str,
duration: int,
img_margin: int,
forward: bool) -> PicGenerator:
"""
绘制视频封面
Args:
pic: 绘图器实例
url: 视频封面 URL
duration: 视频时长
img_margin: 图片外边距
forward: 当前是否为转发动态的源动态
"""
pic.set_pos(x=img_margin)
cover = await open_url_image(f"{url}@480w.webp")
cover = cover.resize(((pic.width - (img_margin * 2)),
int((pic.width - (img_margin * 4)) / (cover.size[0] / cover.size[1]))),
Resampling.LANCZOS)
cover = mask_rounded_rectangle(cover)
mask = Image.open(f"{cls.__resource_base_path}/resource/mask.png")
mask = mask.crop((0, 0, cover.width, mask.height))
time = Image.open(f"{cls.__resource_base_path}/resource/time.png")
tv = Image.open(f"{cls.__resource_base_path}/resource/tv.png")
cover.paste(mask, (0, cover.height - mask.height - 1), mask)
cover.paste(time, (13, cover.height - time.height - 14), time)
cover.paste(tv, (cover.width - tv.width - 16, cover.height - tv.height - 5), tv)
cover_draw = ImageDraw.Draw(cover)
normal_font = config.get("PAINTER_NORMAL_FONT")
time_font = ImageFont.truetype(f"{cls.__resource_base_path}/resource/{normal_font}", 25)
cover_draw.text((21, cover.height - time.height - 11),
timestamp_format(duration + 57600, "%H:%M:%S"), Color.WHITE.value, time_font)
if forward:
cover_height = cover.height + pic.row_space
pic.draw_rectangle(0, pic.y, pic.width, cover_height, Color.LIGHTGRAY)
pic.draw_img_alpha(cover)
return pic
@classmethod
async def __draw_article_cover(cls,
pic: PicGenerator,
urls: List[str],
img_margin: int,
forward: bool) -> PicGenerator:
"""
绘制专栏封面
Args:
pic: 绘图器实例
urls: 专栏封面 URL 列表
img_margin: 图片外边距
forward: 当前是否为转发动态的源动态
"""
pic.set_pos(x=img_margin)
img_count = len(urls)
download_picture_tasks = []
for url in urls:
if img_count == 1:
download_picture_tasks.append(open_url_image(f"{url}@518w.webp"))
else:
size = int((pic.width - (img_margin * 4)) / 3)
download_picture_tasks.append(open_url_image(f"{url}@{size}w_{size}h_1e_1c.webp"))
imgs = await asyncio.gather(*download_picture_tasks)
if img_count == 1:
img = imgs[0]
img = img.resize(((pic.width - (img_margin * 2)),
int((pic.width - (img_margin * 4)) / (img.size[0] / img.size[1]))),
Resampling.LANCZOS)
imgs = [img]
if forward:
heights = imgs[0].height + pic.row_space
pic.draw_rectangle(0, pic.y, pic.width, heights, Color.LIGHTGRAY)
for index, img in enumerate(imgs):
if index == len(imgs) - 1:
pic.draw_img(img).set_pos(x=img_margin)
else:
pic.draw_img(img, (pic.x, pic.y)).move_pos(img.width + img_margin, 0)
return pic
@classmethod
async def __draw_audio_area(cls,
pic: PicGenerator,
cover_url: str,
title: str,
audio_type: str,
margin: int,
forward: bool) -> PicGenerator:
"""
绘制音频卡片
Args:
pic: 绘图器实例
cover_url: 音频封面 URL
title: 音频标题
audio_type: 音频类型
margin: 外边距
forward: 当前是否为转发动态的源动态
"""
return await cls.__draw_share_area(pic, cover_url, title, audio_type, margin, forward)
@classmethod
async def __draw_share_area(cls,
pic: PicGenerator,
cover_url: str,
title: str,
desc: str,
margin: int,
forward: bool) -> PicGenerator:
"""
绘制分享卡片
Args:
pic: 绘图器实例
cover_url: 封面 URL
title: 标题
desc: 描述
margin: 外边距
forward: 当前是否为转发动态的源动态
"""
pic.set_pos(x=margin)
cover = await open_url_image(cover_url)
cover_size = int(pic.width / 4)
cover = cover.resize((cover_size, cover_size), Resampling.LANCZOS)
cover = mask_rounded_rectangle(cover)
if forward:
heights = cover.height + pic.row_space
pic.draw_rectangle(0, pic.y, pic.width, heights, Color.LIGHTGRAY)
border = Image.new("RGBA", (pic.width - (margin * 2) + 2, cover_size + 2))
ImageDraw.Draw(border).rounded_rectangle((0, 0, border.width - 1, border.height - 1),
10, (0, 0, 0, 0), Color.GRAY.value, 1)
pic.draw_img_alpha(border, (pic.x - 1, pic.y - 1))
x, y = pic.xy
pic.draw_img_alpha(cover)
pic.draw_text(title, Color.BLACK, (x + cover_size + margin, y + int(cover_size / 5)))
pic.draw_tip(desc, xy=(x + cover_size + margin, y + int(cover_size / 5 * 3)))
return pic
@classmethod
async def __draw_live_area(cls,
pic: PicGenerator,
cover_url: str,
title: str,
area: str,
margin: int,
forward: bool) -> PicGenerator:
"""
绘制直播卡片
Args:
pic: 绘图器实例
cover_url: 直播封面 URL
title: 直播标题
area: 直播分区
margin: 外边距
forward: 当前是否为转发动态的源动态
"""
pic.set_pos(x=margin)
cover = await open_url_image(f"{cover_url}@203w_127h_1e_1c.webp")
if forward:
heights = cover.height + pic.row_space
pic.draw_rectangle(0, pic.y, pic.width, heights, Color.LIGHTGRAY)
back = Image.new("RGBA", (pic.width - (margin * 2), cover.height))
ImageDraw.Draw(back).rectangle((0, 0, back.width, back.height), Color.WHITE.value)
pic.draw_img_alpha(back, (pic.x, pic.y))
x, y = pic.xy
pic.draw_img(cover)
pic.draw_text(title, Color.BLACK, (x + cover.width + margin, y + int(cover.height / 5)))
pic.draw_tip(area, xy=(x + cover.width + margin, y + int(cover.height / 5 * 3)))
return pic
@classmethod
async def __draw_add_on_card(cls,
pic: PicGenerator,
infos: List[Dict[str, Any]],
margin: int,
forward: bool) -> PicGenerator:
"""
绘制附加卡片
Args:
pic: 绘图器实例
infos: 附加卡片信息
margin: 外边距
forward: 当前是否为转发动态的源动态
"""
pic.set_pos(x=margin)
card_height = int(pic.width / 4)
padding = 10
img_height = card_height - padding * 2
edge = pic.width - margin - padding
for info in infos:
# 绘制背景
back_color = Color.LIGHTGRAY
if forward:
pic.draw_rectangle(0, pic.y, pic.width, card_height + pic.row_space + padding, Color.LIGHTGRAY)
back_color = Color.WHITE
pic.move_pos(0, padding)
back = Image.new("RGBA", (pic.width - (margin * 2), card_height))
ImageDraw.Draw(back).rectangle((0, 0, back.width, back.height), back_color.value)
back = mask_rounded_rectangle(back)
pic.draw_img_alpha(back, (pic.x, pic.y))
pic.move_pos(padding, padding)
# 根据类型绘制卡片
card_type = info["add_on_card_show_type"]
if card_type == 1:
# 淘宝
goods = json.loads(info["goods_card"])["list"]
download_picture_tasks = []
for good in goods:
url = good["img"]
download_picture_tasks.append(open_url_image(f"{url}@{img_height}w_{img_height}h_1e_1c.webp"))
imgs = await asyncio.gather(*download_picture_tasks)
if len(goods) == 1:
img = mask_rounded_rectangle(imgs[0])
x, y = pic.xy
pic.draw_img(img)
name = limit_str_length(goods[0]["name"], 15)
price = goods[0]["priceStr"]
price_length = pic.get_text_length(price)
pic.draw_text(name, Color.BLACK, (x + img.width + margin, y + int(img_height / 5)))
pic.draw_text(price, Color.LINK, (x + img.width + margin, y + int(img_height / 5 * 3)))
pic.draw_tip("", xy=(x + img.width + margin + price_length, y + int(img_height / 5 * 3) + 5))
else:
for index, img in enumerate(imgs):
overflow = False
img = mask_rounded_rectangle(img)
if pic.x + img.width > edge:
overflow = True
img = img.crop((0, 0, edge - pic.x, img.height))
if index == len(imgs) - 1 or overflow:
pic.draw_img(img).set_pos(x=margin)
if overflow:
break
else:
pic.draw_img(img, pic.xy).move_pos(img.height + margin, 0)
elif card_type == 2:
# 充电、相关装扮、相关游戏
base = info["attach_card"]
if base["type"] in ["decoration", "game"]:
url = base["cover_url"]
title = base["title"]
desc_first = base["desc_first"]
desc_second = base["desc_second"]
cover = await open_url_image(f"{url}@{img_height}w_{img_height}h_1e_1c.webp")
cover = mask_rounded_rectangle(cover)
x, y = pic.xy
pic.draw_img(cover)
pic.draw_text(title, Color.BLACK, (x + cover.width + margin, y + int(img_height / 10)))
pic.draw_tip(desc_first, xy=(x + cover.width + margin, y + int(img_height / 7 * 3)))
pic.draw_tip(desc_second, xy=(x + cover.width + margin, y + int(img_height / 7 * 5)))
elif base["type"] == "lottery":
title = base["title"]
desc_first = limit_str_length(base["desc_first"], 24)
icon = Image.open(f"{cls.__resource_base_path}/resource/box.png")
x, y = pic.xy
pic.draw_text(title, Color.BLACK, (x, y + int(img_height / 5)))
pic.draw_img_alpha(icon, (x, y + int(img_height / 5 * 3)))
pic.draw_tip(desc_first, Color.LINK, (x + icon.width + padding, y + int(img_height / 5 * 3)))
pic.set_pos(x=margin).move_pos(0, img_height + pic.row_space)
elif card_type == 3:
# 投票
vote = json.loads(info["vote_card"])
desc = limit_str_length(vote["desc"], 15)
join_num = vote["join_num"]
icon = Image.open(f"{cls.__resource_base_path}/resource/tick_big.png")
icon = mask_rounded_rectangle(icon.resize((img_height, img_height), Resampling.LANCZOS))
x, y = pic.xy
pic.draw_img_alpha(icon)
pic.draw_text(desc, Color.BLACK, (x + img_height + margin, y + int(img_height / 5)))
pic.draw_tip(f"{join_num}人参与", xy=(x + img_height + margin, y + int(img_height / 5 * 3)))
elif card_type == 5:
# 视频分享
base = info["ugc_attach_card"]
url = base["image_url"]
title = limit_str_length(base["title"], 12)
desc_second = base["desc_second"]
cover = await open_url_image(f"{url}@480w.webp")
cover = cover.resize((int(img_height / cover.height * cover.width), img_height), Resampling.LANCZOS)
cover = mask_rounded_rectangle(cover)
x, y = pic.xy
pic.draw_img(cover)
pic.draw_text(title, Color.BLACK, (x + cover.width + margin, y + int(img_height / 5)))
pic.draw_tip(desc_second, xy=(x + cover.width + margin, y + int(img_height / 5 * 3)))
elif card_type == 6:
# 视频、直播预约
base = info["reserve_attach_card"]
title = cls.remove_illegal_char(base["title"])
desc_first = base["desc_first"]["text"]
desc_second = base["desc_second"]
desc = f"{desc_first} {desc_second}"
lottery = limit_str_length(base["reserve_lottery"]["text"], 24) if "reserve_lottery" in base else None
if lottery:
pic.draw_text(title, Color.BLACK, (pic.x, pic.y + int(img_height / 10)))
pic.draw_tip(desc, xy=(pic.x, pic.y + int(img_height / 7 * 3)))
icon = Image.open(f"{cls.__resource_base_path}/resource/box.png")
pic.draw_img_alpha(icon, (pic.x, pic.y + int(img_height / 7 * 5)))
pic.draw_tip(lottery, Color.LINK, (pic.x + icon.width + padding, pic.y + int(img_height / 7 * 5)))
else:
pic.draw_text(title, Color.BLACK, (pic.x, pic.y + int(img_height / 5)))
pic.draw_tip(desc, xy=(pic.x, pic.y + int(img_height / 5 * 3)))
pic.set_pos(x=margin).move_pos(0, img_height + pic.row_space)
return pic

View File

@ -0,0 +1,580 @@
import base64
import os
from io import BytesIO
from typing import Union, Tuple, List, Dict, Any
from PIL import Image, ImageDraw
from .PicGenerator import Color, PicGenerator
from ..core.model import LiveReport
from ..utils.utils import split_list, limit_str_length, mask_round
class LiveReportGenerator:
"""
直播报告生成器
"""
@classmethod
def generate(cls, param: Dict[str, Any], model: LiveReport) -> str:
"""
根据传入直播报告参数生成直播报告图片
Args:
param: 直播报告参数
model: 直播报告配置实例
Returns:
直播报告图片的 Base64 字符串
"""
width = 1000
height = 10000
top_blank = 75
margin = 50
generator = PicGenerator(width, height)
pic = (generator.set_pos(margin, top_blank + margin)
.draw_rounded_rectangle(0, top_blank, width, height - top_blank, 35, Color.WHITE)
.copy_bottom(35))
# 标题
pic.draw_chapter("直播报告")
# 防止后续绘图覆盖主播立绘
logo_limit = (0, 0)
# 主播立绘
if model.logo or model.logo_base64:
logo = cls.__get_logo(model)
base_left = 650
logo_left = base_left + int((width - base_left - logo.width) / 2)
if logo_left < base_left:
logo_left = base_left
pic.draw_img_alpha(logo, (logo_left, 0))
logo_limit = (logo_left, logo.height)
# 主播信息
uname = param.get('uname', '')
room_id = param.get('room_id', 0)
pic.draw_tip(f"{uname} ({room_id})")
# 直播时长
if model.time:
start_time = param.get('start_time', '')
end_time = param.get('end_time', '')
hour = param.get('hour', 0)
minute = param.get('minute', 0)
second = param.get('second', 0)
live_time_str = ""
if hour != 0:
live_time_str += f"{hour}"
if minute != 0:
live_time_str += f"{minute}"
if second != 0:
live_time_str += f"{second}"
if live_time_str == "":
live_time_str = "0 秒"
pic.draw_tip(f"{start_time} ~ {end_time} ({live_time_str.strip()})")
# 基础数据
if model.fans_change or model.fans_medal_change or model.guard_change:
pic.draw_section("基础数据")
if model.fans_change:
fans_before = param.get('fans_before', 0)
fans_after = param.get('fans_after', 0)
if fans_before == -1:
fans_before = "?"
diff = 0
else:
diff = fans_after - fans_before
if diff > 0:
pic.draw_text([f"粉丝: {fans_before}{fans_after} ", f"(+{diff})"], [Color.BLACK, Color.RED])
elif diff < 0:
pic.draw_text([f"粉丝: {fans_before}{fans_after} ", f"({diff})"], [Color.BLACK, Color.GREEN])
else:
pic.draw_text([f"粉丝: {fans_before}{fans_after} ", f"(+0)"], [Color.BLACK, Color.GRAY])
if model.fans_medal_change:
medal_before = param.get('fans_medal_before', 0)
medal_after = param.get('fans_medal_after', 0)
if medal_before == -1:
medal_before = "?"
diff = 0
else:
diff = medal_after - medal_before
if diff > 0:
pic.draw_text([f"粉丝团: {medal_before}{medal_after} ", f"(+{diff})"], [Color.BLACK, Color.RED])
elif diff < 0:
pic.draw_text([f"粉丝团: {medal_before}{medal_after} ", f"({diff})"], [Color.BLACK, Color.GREEN])
else:
pic.draw_text([f"粉丝团: {medal_before}{medal_after} ", f"(+0)"], [Color.BLACK, Color.GRAY])
if model.guard_change:
guard_before = param.get('guard_before', 0)
guard_after = param.get('guard_after', 0)
if guard_before == -1:
guard_before = "?"
diff = 0
else:
diff = guard_after - guard_before
if diff > 0:
pic.draw_text([f"大航海: {guard_before}{guard_after} ", f"(+{diff})"], [Color.BLACK, Color.RED])
elif diff < 0:
pic.draw_text([f"大航海: {guard_before}{guard_after} ", f"({diff})"], [Color.BLACK, Color.GREEN])
else:
pic.draw_text([f"大航海: {guard_before}{guard_after} ", f"(+0)"], [Color.BLACK, Color.GRAY])
# 直播数据
if model.danmu or model.box or model.gift or model.sc or model.guard:
# 弹幕相关
danmu_count = param.get('danmu_count', 0)
danmu_person_count = param.get('danmu_person_count', 0)
# 盲盒相关
box_count = param.get('box_count', 0)
box_person_count = param.get('box_person_count', 0)
box_profit = param.get('box_profit', 0.0)
box_beat_percent = param.get('box_beat_percent', 0.0)
# 礼物相关
gift_profit = param.get('gift_profit', 0.0)
gift_person_count = param.get('gift_person_count', 0)
# SC醒目留言相关
sc_profit = param.get('sc_profit', 0)
sc_person_count = param.get('sc_person_count', 0)
# 大航海相关
captain_count = param.get('captain_count', 0)
commander_count = param.get('commander_count', 0)
governor_count = param.get('governor_count', 0)
if any([danmu_count > 0, box_count > 0, gift_profit > 0, sc_profit > 0,
captain_count > 0, commander_count > 0, governor_count > 0]):
pic.draw_section("直播数据")
if model.danmu and danmu_count > 0:
pic.draw_text(f"弹幕: {danmu_count} 条 ({danmu_person_count} 人)")
if model.box and box_count > 0:
pic.draw_text(f"盲盒: {box_count} 个 ({box_person_count} 人)")
if box_profit > 0:
pic.draw_text(["盲盒赚了: ", f"{box_profit}", " 元 (击败了 ", f"{box_beat_percent}% ", "的直播间)"],
[Color.BLACK, Color.RED, Color.BLACK, Color.RED, Color.BLACK])
elif box_profit < 0:
pic.draw_text(["盲盒亏了: ", f"{abs(box_profit)}", " 元 (击败了 ", f"{box_beat_percent}% ", "的直播间)"],
[Color.BLACK, Color.GREEN, Color.BLACK, Color.GREEN, Color.BLACK])
else:
pic.draw_text(["盲盒不赚不亏 (击败了 ", f"{box_beat_percent}% ", "的直播间)"],
[Color.BLACK, Color.GRAY, Color.BLACK])
if model.gift and gift_profit > 0:
pic.draw_text(f"礼物: {gift_profit} 元 ({gift_person_count} 人)")
if model.sc and sc_profit > 0:
pic.draw_text(f"SC (醒目留言): {sc_profit} 元 ({sc_person_count} 人)")
if model.guard and any([captain_count > 0, commander_count > 0, governor_count > 0]):
texts = ["大航海: "]
colors = [Color.BLACK]
if captain_count > 0:
texts.append(f"舰长 × {captain_count} ")
colors.append(Color.DEEPSKYBLUE)
if commander_count > 0:
texts.append(f"提督 × {commander_count} ")
colors.append(Color.FUCHSIA)
if governor_count > 0:
texts.append(f"总督 × {governor_count} ")
colors.append(Color.CRIMSON)
pic.draw_text(texts, colors)
# 弹幕排行
if model.danmu_ranking:
faces = param.get("danmu_ranking_faces", [])
unames = param.get("danmu_ranking_unames", [])
counts = param.get("danmu_ranking_counts", [])
if counts:
pic.draw_section(f"弹幕排行 (Top {len(counts)})")
ranking_img = cls.__get_ranking(pic, faces, unames, counts, pic.width - (margin * 2))
pic.draw_img_alpha(pic.auto_size_img_by_limit(ranking_img, logo_limit))
# 盲盒数量排行
if model.box_ranking:
faces = param.get("box_ranking_faces", [])
unames = param.get("box_ranking_unames", [])
counts = param.get("box_ranking_counts", [])
if counts:
pic.draw_section(f"盲盒数量排行 (Top {len(counts)})")
ranking_img = cls.__get_ranking(pic, faces, unames, counts, pic.width - (margin * 2))
pic.draw_img_alpha(pic.auto_size_img_by_limit(ranking_img, logo_limit))
# 盲盒盈亏排行
if model.box_profit_ranking:
faces = param.get("box_profit_ranking_faces", [])
unames = param.get("box_profit_ranking_unames", [])
counts = param.get("box_profit_ranking_counts", [])
if counts:
pic.draw_section(f"盲盒盈亏排行 (Top {len(counts)})")
ranking_img = cls.__get_double_ranking(pic, faces, unames, counts, pic.width - (margin * 2))
pic.draw_img_alpha(pic.auto_size_img_by_limit(ranking_img, logo_limit))
# 礼物排行
if model.gift_ranking:
faces = param.get("gift_ranking_faces", [])
unames = param.get("gift_ranking_unames", [])
counts = param.get("gift_ranking_counts", [])
if counts:
pic.draw_section(f"礼物排行 (Top {len(counts)})")
ranking_img = cls.__get_ranking(pic, faces, unames, counts, pic.width - (margin * 2))
pic.draw_img_alpha(pic.auto_size_img_by_limit(ranking_img, logo_limit))
# SC醒目留言排行
if model.sc_ranking:
faces = param.get("sc_ranking_faces", [])
unames = param.get("sc_ranking_unames", [])
counts = param.get("sc_ranking_counts", [])
if counts:
pic.draw_section(f"SC(醒目留言)排行 (Top {len(counts)})")
ranking_img = cls.__get_ranking(pic, faces, unames, counts, pic.width - (margin * 2))
pic.draw_img_alpha(pic.auto_size_img_by_limit(ranking_img, logo_limit))
# 开通大航海观众列表
if model.guard_list:
captain_infos = param.get("captain_infos", [])
commander_infos = param.get("commander_infos", [])
governor_infos = param.get("governor_infos", [])
if any([captain_infos, commander_infos, governor_infos]):
pic.draw_section("本场开通大航海观众")
guard_list_img = cls.__get_guard_list(
pic, captain_infos, commander_infos, governor_infos, pic.width - (margin * 2)
)
pic.draw_img_alpha(pic.auto_size_img_by_limit(guard_list_img, logo_limit))
# 弹幕词云
if model.danmu_cloud:
base64_str = param.get('danmu_cloud', "")
if base64_str != "":
pic.draw_section("弹幕词云")
img_bytes = BytesIO(base64.b64decode(base64_str))
img = pic.auto_size_img_by_limit(Image.open(img_bytes), logo_limit)
pic.draw_img_with_border(img)
# 底部版权信息,请务必保留此处
pic.set_row_space(10)
pic.draw_text_right(50, "Designed By StarBot", Color.GRAY, logo_limit)
pic.draw_text_right(50, "https://github.com/Starlwr/StarBot", Color.LINK, logo_limit)
pic.crop_and_paste_bottom()
return pic.base64()
@classmethod
def __get_logo(cls, model: LiveReport) -> Image:
"""
从直播报告实例中读取主播立绘图片
Args:
model: 直播报告配置实例
Returns:
主播立绘图片
"""
if model.logo:
logo = Image.open(model.logo)
else:
logo_bytes = BytesIO(base64.b64decode(model.logo_base64))
logo = Image.open(logo_bytes)
logo = logo.crop(logo.getbbox())
logo_height = 800
logo_width = int(logo.width * (logo_height / logo.height))
logo = logo.resize((logo_width, logo_height))
return logo
@classmethod
def __get_rank_bar_pic(cls,
width: int,
height: int,
start_color: Union[Color, Tuple[int, int, int]] = Color.DEEPBLUE,
end_color: Union[Color, Tuple[int, int, int]] = Color.LIGHTBLUE,
reverse: bool = False) -> Image:
"""
生成排行榜中排行条图片
Args:
width: 排行条长度
height: 排行条宽度
start_color: 排行条渐变起始颜色默认深蓝色 (57, 119, 230)
end_color: 排行条渐变终止颜色默认浅蓝色 (55, 187, 248)
reverse: 是否生成反向排行条用于双向排行榜的负数排行条默认False
"""
if isinstance(start_color, Color):
start_color = start_color.value
if isinstance(end_color, Color):
end_color = end_color.value
if reverse:
start_color, end_color = end_color, start_color
r_step = (end_color[0] - start_color[0]) / width
g_step = (end_color[1] - start_color[1]) / width
b_step = (end_color[2] - start_color[2]) / width
now_color = [start_color[0], start_color[1], start_color[2]]
bar = Image.new("RGBA", (width, 1))
draw = ImageDraw.Draw(bar)
for i in range(width):
draw.point((i, 0), (int(now_color[0]), int(now_color[1]), int(now_color[2])))
now_color[0] += r_step
now_color[1] += g_step
now_color[2] += b_step
bar = bar.resize((width, height))
mask = Image.new("L", (width, height), 255)
mask_draw = ImageDraw.Draw(mask)
if not reverse:
mask_draw.polygon(((width - height, height), (width, 0), (width, height)), 0)
else:
mask_draw.polygon(((0, 0), (0, height), (height, height)), 0)
bar.putalpha(mask)
return bar
@classmethod
def __get_ranking(cls,
pic: PicGenerator,
faces: List[Image.Image],
unames: List[str],
counts: Union[List[int], List[float]],
width: int,
start_color: Union[Color, Tuple[int, int, int]] = Color.DEEPBLUE,
end_color: Union[Color, Tuple[int, int, int]] = Color.LIGHTBLUE) -> Image:
"""
绘制排行榜
Args:
pic: 绘图器实例
faces: 头像图片列表按照数量列表降序排序
unames: 昵称列表按照数量列表降序排序
counts: 数量列表降序排序
width: 排行榜图片宽度
start_color: 排行条渐变起始颜色默认深蓝色 (57, 119, 230)
end_color: 排行条渐变终止颜色默认浅蓝色 (55, 187, 248)
"""
count = len(counts)
if count == 0 or len(faces) != len(unames) or len(unames) != len(counts):
raise ValueError
face_size = 100
offset = 10
bar_height = 30
bar_x = face_size - offset
top_bar_width = width - face_size + offset
top_count = counts[0]
chart = PicGenerator(width, (face_size * count) + (pic.row_space * (count - 1)))
chart.set_row_space(pic.row_space)
for i in range(count):
bar_width = int(counts[i] / top_count * top_bar_width)
if bar_width != 0:
bar = cls.__get_rank_bar_pic(bar_width, bar_height, start_color, end_color)
chart.draw_img_alpha(bar, (bar_x, chart.y + int((face_size - bar_height) / 2)))
chart.draw_tip(unames[i], Color.BLACK, (bar_x + (offset * 2), chart.y))
count_pos = (max(chart.x + bar_width, bar_x + (offset * 3) + chart.get_tip_length(unames[i])), chart.y)
chart.draw_tip(str(counts[i]), xy=count_pos)
chart.draw_img_alpha(mask_round(faces[i].resize((face_size, face_size)).convert("RGBA")))
return chart.img
@classmethod
def __get_double_ranking(cls,
pic: PicGenerator,
faces: List[Image.Image],
unames: List[str],
counts: Union[List[int], List[float]],
width: int,
start_color: Union[Color, Tuple[int, int, int]] = Color.DEEPRED,
end_color: Union[Color, Tuple[int, int, int]] = Color.LIGHTRED,
reverse_start_color: Union[Color, Tuple[int, int, int]] = Color.DEEPGREEN,
reverse_end_color: Union[Color, Tuple[int, int, int]] = Color.LIGHTGREEN) -> Image:
"""
绘制双向排行榜
Args:
pic: 绘图器实例
faces: 头像图片列表按照数量列表降序排序
unames: 昵称列表按照数量列表降序排序
counts: 数量列表降序排序
width: 排行榜图片宽度
start_color: 正向排行条渐变起始颜色数量为正时使用默认深红色 (57, 119, 230)
end_color: 正向排行条渐变终止颜色数量为正时使用默认浅红色 (55, 187, 248)
reverse_start_color: 反向排行条渐变起始颜色数量为负时使用默认深绿色 (57, 119, 230)
reverse_end_color: 反向排行条渐变终止颜色数量为负时使用默认浅绿色 (55, 187, 248)
"""
count = len(counts)
if count == 0 or len(faces) != len(unames) or len(unames) != len(counts):
raise ValueError
face_size = 100
offset = 10
bar_height = 30
face_x = int((width - face_size) / 2)
bar_x = face_x + face_size - offset
reverse_bar_x = face_x + offset
top_bar_width = (width - face_size) / 2 + offset
top_count = max(max(counts), abs(min(counts)))
chart = PicGenerator(width, (face_size * count) + (pic.row_space * (count - 1)))
chart.set_row_space(pic.row_space)
for i in range(count):
bar_width = int(abs(counts[i]) / top_count * top_bar_width)
if counts[i] > 0:
bar = cls.__get_rank_bar_pic(bar_width, bar_height, start_color, end_color)
chart.draw_img_alpha(bar, (bar_x, chart.y + int((face_size - bar_height) / 2)))
elif counts[i] < 0:
bar = cls.__get_rank_bar_pic(bar_width, bar_height, reverse_start_color, reverse_end_color, True)
chart.draw_img_alpha(bar, (reverse_bar_x - bar_width, chart.y + int((face_size - bar_height) / 2)))
if counts[i] >= 0:
chart.draw_tip(unames[i], Color.BLACK, (bar_x + (offset * 2), chart.y))
count_pos = (max(face_x + bar_width, bar_x + (offset * 3) + chart.get_tip_length(unames[i])), chart.y)
chart.draw_tip(str(counts[i]), xy=count_pos)
else:
uname_length = chart.get_tip_length(unames[i])
count_length = chart.get_tip_length(str(counts[i]))
chart.draw_tip(unames[i], Color.BLACK, (reverse_bar_x - (offset * 2) - uname_length, chart.y))
count_pos = (min(face_x + face_size - bar_width - count_length,
reverse_bar_x - (offset * 3) - uname_length - count_length), chart.y)
chart.draw_tip(str(counts[i]), xy=count_pos)
chart.set_pos(x=face_x).draw_img_alpha(mask_round(faces[i].resize((face_size, face_size)).convert("RGBA")))
chart.set_pos(x=0)
return chart.img
@classmethod
def __get_guard_line_pic(cls,
pic: PicGenerator,
width: int,
face_size: int,
faces: List[Image.Image],
unames: List[str],
counts: List[int],
icon: Image.Image,
color: Union[Color, Tuple[int, int, int]]) -> Image:
"""
生成大航海列表中每行图片
Args:
pic: 绘图器实例
width: 大航海列表中每行图片长度
face_size: 头像尺寸
faces: 头像图片列表按照数量列表降序排序
unames: 昵称列表按照数量列表降序排序
counts: 数量列表降序排序
icon: 大航海图标
"""
count = len(counts)
if count == 0 or len(faces) != len(unames) or len(unames) != len(counts):
raise ValueError
text_size = 30
icon_size = int(face_size * 1.5)
face_padding = int((icon_size - face_size) / 2)
margin = int((width - (icon_size * count)) / (count + 1))
xs = [margin + (i * (icon_size + margin)) for i in range(count)]
line = PicGenerator(width, icon_size + int(pic.row_space * 2.5) + (text_size * 2))
line.set_row_space(pic.row_space)
icon = icon.resize((icon_size, icon_size))
for i, x in enumerate(xs):
line.draw_img_alpha(mask_round(faces[i].resize((face_size, face_size))), (x + face_padding, face_padding))
if i != count - 1:
line.draw_img_alpha(icon, (x, 0))
else:
line.set_pos(x=x).draw_img_alpha(icon).set_pos(x=0)
for i, x in enumerate(xs):
uname = limit_str_length(unames[i], 8)
uname_length = line.get_text_length(uname)
uname_x_offset = int((icon_size - uname_length) / 2)
count = f"{counts[i]}"
count_length = line.get_text_length(count)
count_x_offset = int((icon_size - count_length) / 2)
line.draw_text(uname, color, (x + uname_x_offset, line.y))
line.draw_text(count, Color.BLACK, (x + count_x_offset, line.y + text_size + int(line.row_space / 2)))
return line.img
@classmethod
def __get_guard_list(cls,
pic: PicGenerator,
captains: List[List[Union[Image.Image, str, int]]],
commanders: List[List[Union[Image.Image, str, int]]],
governors: List[List[Union[Image.Image, str, int]]],
width: int) -> Image:
"""
绘制大航海列表
Args:
pic: 绘图器实例
captains: 舰长信息
commanders: 提督信息
governors: 总督信息
width: 大航海列表图片宽度
"""
face_size = 150
icon_size = int(face_size * 1.5)
text_size = 30
line_count = 3
line_height = icon_size + int(pic.row_space * 2.5) + (text_size * 2)
resource_base_path = os.path.dirname(os.path.dirname(__file__))
icon_map = {
0: Image.open(f"{resource_base_path}/resource/governor.png").convert("RGBA"),
1: Image.open(f"{resource_base_path}/resource/commander.png").convert("RGBA"),
2: Image.open(f"{resource_base_path}/resource/captain.png").convert("RGBA")
}
color_map = {
0: Color.CRIMSON,
1: Color.FUCHSIA,
2: Color.DEEPSKYBLUE
}
captains = split_list(captains, line_count)
commanders = split_list(commanders, line_count)
governors = split_list(governors, line_count)
img = PicGenerator(width, (len(governors) + len(commanders) + len(captains)) * line_height)
img.set_row_space(pic.row_space)
for i, lst in enumerate([governors, commanders, captains]):
if not lst:
continue
for line in lst:
faces = [x[0] for x in line]
unames = [x[1] for x in line]
counts = [x[2] for x in line]
img.draw_img_alpha(
cls.__get_guard_line_pic(pic, width, face_size, faces, unames, counts, icon_map[i], color_map[i])
).move_pos(0, -pic.row_space)
return img.img

View File

@ -0,0 +1,536 @@
import base64
import os
from enum import Enum
from io import BytesIO
from typing import Optional, Union, Tuple, List
from PIL import Image, ImageDraw, ImageFont
from ..utils import config
class Color(Enum):
"""
常用颜色 RGB 枚举
+ BLACK: 黑色
+ WHITE: 白色
+ GRAY: 灰色
+ LIGHTGRAY: 淡灰色
+ RED: 红色
+ GREEN: 绿色
+ DEEPBLUE: 深蓝色
+ LIGHTBLUE: 浅蓝色
+ DEEPRED: 深红色
+ LIGHTRED: 浅红色
+ DEEPGREEN: 深绿色
+ LIGHTGREEN: 浅绿色
+ CRIMSON: 总督红
+ FUCHSIA: 提督紫
+ DEEPSKYBLUE: 舰长蓝
+ LINK: 超链接蓝
+ PINK: 大会员粉
"""
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
GRAY = (169, 169, 169)
LIGHTGRAY = (244, 244, 244)
RED = (150, 0, 0)
GREEN = (0, 150, 0)
DEEPBLUE = (55, 187, 248)
LIGHTBLUE = (175, 238, 238)
DEEPRED = (240, 128, 128)
LIGHTRED = (255, 220, 220)
DEEPGREEN = (0, 255, 0)
LIGHTGREEN = (184, 255, 184)
CRIMSON = (220, 20, 60)
FUCHSIA = (255, 0, 255)
DEEPSKYBLUE = (0, 191, 255)
LINK = (23, 139, 207)
PINK = (251, 114, 153)
class PicGenerator:
"""
基于 Pillow 的绘图器可使用链式调用方法
"""
def __init__(self,
width: int,
height: int,
normal_font: str = config.get("PAINTER_NORMAL_FONT"),
bold_font: str = config.get("PAINTER_BOLD_FONT")):
"""
初始化绘图器
Args:
width: 画布宽度
height: 画布高度
normal_font: 普通字体路径默认config.get("PAINTER_NORMAL_FONT") = "normal.ttf"
bold_font: 粗体字体路径默认config.get("PAINTER_BOLD_FONT") = "bold.ttf"
"""
self.__width = width
self.__height = height
self.__canvas = Image.new("RGBA", (self.width, self.height))
self.__draw = ImageDraw.Draw(self.__canvas)
resource_base_path = os.path.dirname(os.path.dirname(__file__))
self.__chapter_font = ImageFont.truetype(f"{resource_base_path}/resource/{bold_font}", 50)
self.__section_font = ImageFont.truetype(f"{resource_base_path}/resource/{bold_font}", 40)
self.__tip_font = ImageFont.truetype(f"{resource_base_path}/resource/{normal_font}", 25)
self.__text_font = ImageFont.truetype(f"{resource_base_path}/resource/{normal_font}", 30)
self.__xy = 0, 0
self.__ROW_SPACE = 25
self.__bottom_pic = None
@property
def width(self):
return self.__width
@property
def height(self):
return self.__height
@property
def x(self):
return self.__xy[0]
@property
def y(self):
return self.__xy[1]
@property
def xy(self):
return self.__xy
@property
def row_space(self):
return self.__ROW_SPACE
@property
def img(self):
return self.__canvas
def set_row_space(self, row_space: int):
"""
设置默认行距
Args:
row_space: 行距
"""
self.__ROW_SPACE = row_space
return self
def set_pos(self, x: Optional[int] = None, y: Optional[int] = None):
"""
设置绘图坐标
Args:
x: X 坐标
y: Y 坐标
"""
x = x if x is not None else self.x
y = y if y is not None else self.y
self.__xy = x, y
return self
def move_pos(self, x: int, y: int):
"""
移动绘图坐标
Args:
x: X 偏移量
y: Y 偏移量
"""
self.__xy = self.x + x, self.y + y
return self
def copy_bottom(self, height: int):
"""
拷贝指定高度的画布底部图片
crop_and_paste_bottom() 函数配合
在事先不能确定画布高度时先创建一个高度极大的画布内容绘制结束时剪裁底部多余区域并将底部图片粘贴回原位
Args:
height: 拷贝高度从图片底部计算
"""
self.__bottom_pic = self.__canvas.crop((0, self.height - height, self.width, self.height))
return self
def crop_and_paste_bottom(self):
"""
内容绘图结束后根据当前绘图坐标剪裁多余的底部区域并将事先拷贝的底部图片粘贴回底部
"""
if self.__bottom_pic is None:
self.__canvas = self.__canvas.crop((0, 0, self.width, self.y))
return self
self.__canvas = self.__canvas.crop((0, 0, self.width, self.y + self.__bottom_pic.height))
self.draw_img(self.__bottom_pic, (0, self.y))
return self
def draw_rectangle(self,
x: int,
y: int,
width: int,
height: int,
color: Union[Color, Tuple[int, int, int]]):
"""
绘制一个矩形此方法不会移动绘图坐标
Args:
x: 矩形左上角的 x 坐标
y: 矩形左上角的 y 坐标
width: 矩形的宽度
height: 矩形的高度
color: 矩形的背景颜色
"""
if isinstance(color, Color):
color = color.value
self.__draw.rectangle(((x, y), (x + width, y + height)), color)
return self
def draw_rounded_rectangle(self,
x: int,
y: int,
width: int,
height: int,
radius: int,
color: Union[Color, Tuple[int, int, int]]):
"""
绘制一个圆角矩形此方法不会移动绘图坐标
Args:
x: 圆角矩形左上角的 x 坐标
y: 圆角矩形左上角的 y 坐标
width: 圆角矩形的宽度
height: 圆角矩形的高度
radius: 圆角矩形的圆角半径
color: 圆角矩形的背景颜色
"""
if isinstance(color, Color):
color = color.value
self.__draw.rounded_rectangle(((x, y), (x + width, y + height)), radius, color)
return self
def auto_size_img_by_limit(self,
img: Image.Image,
xy_limit: Tuple[int, int],
xy: Optional[Tuple[int, int]] = None) -> Image:
"""
将指定的图片限制为不可覆盖指定的点若其将要覆盖指定的点会自适应缩小图片至不会覆盖指定的点
Args:
img: 要限制的图片
xy_limit: 指定不可被覆盖的点
xy: 图片将要被绘制到的坐标默认自适应的当前绘图坐标
"""
if xy is None:
xy = self.__xy
return self.auto_size_img_by_limit_cls(img, xy_limit, xy)
@classmethod
def auto_size_img_by_limit_cls(cls, img: Image.Image, xy_limit: Tuple[int, int], xy: Tuple[int, int]) -> Image:
"""
将指定的图片限制为不可覆盖指定的点若其将要覆盖指定的点会自适应缩小图片至不会覆盖指定的点
Args:
img: 要限制的图片
xy_limit: 指定不可被覆盖的点
xy: 图片将要被绘制到的坐标
"""
cover_margin = config.get("PAINTER_AUTO_SIZE_BY_LIMIT_MARGIN")
xy_limit = xy_limit[0] - cover_margin, xy_limit[1] + cover_margin
x_cover = xy[0] + img.width - xy_limit[0]
if xy[1] >= xy_limit[1] or x_cover <= 0:
return img
width = img.width - x_cover
height = int(img.height * (width / img.width))
return img.resize((width, height))
def draw_img(self, img: Union[str, Image.Image], xy: Optional[Tuple[int, int]] = None):
"""
在当前绘图坐标绘制一张图片会自动移动绘图坐标至下次绘图适合位置
也可手动传入绘图坐标手动传入时不会移动绘图坐标
Args:
img: 图片路径或 Image 图片实例
xy: 绘图坐标默认自适应绘图坐标
"""
if isinstance(img, str):
img = Image.open(img)
if xy is None:
self.__canvas.paste(img, self.__xy)
self.move_pos(0, img.height + self.__ROW_SPACE)
else:
self.__canvas.paste(img, xy)
return self
def draw_img_alpha(self, img: Union[str, Image.Image], xy: Optional[Tuple[int, int]] = None):
"""
在当前绘图坐标绘制一张透明背景图片会自动移动绘图坐标至下次绘图适合位置
也可手动传入绘图坐标手动传入时不会移动绘图坐标
Args:
img: 透明背景图片路径或 Image 图片实例
xy: 绘图坐标默认自适应绘图坐标
"""
if isinstance(img, str):
img = Image.open(img)
if xy is None:
self.__canvas.paste(img, self.__xy, img)
self.move_pos(0, img.height + self.__ROW_SPACE)
else:
self.__canvas.paste(img, xy, img)
return self
def draw_img_with_border(self,
img: Union[str, Image.Image],
xy: Optional[Tuple[int, int]] = None,
color: Optional[Union[Color, Tuple[int, int, int]]] = Color.BLACK,
radius: int = 10,
width: int = 1):
"""
在当前绘图坐标绘制一张图片并附带圆角矩形边框会自动移动绘图坐标至下次绘图适合位置
也可手动传入绘图坐标手动传入时不会移动绘图坐标
Args:
img: 图片路径或 Image 图片实例
xy: 绘图坐标默认自适应绘图坐标
color: 边框颜色默认黑色 (0, 0, 0)
radius: 边框圆角半径默认10
width: 边框粗细默认1
"""
if isinstance(img, str):
img = Image.open(img)
if isinstance(color, Color):
color = color.value
if xy is None:
xy = self.__xy
self.draw_img(img)
else:
self.draw_img(img, xy)
border = Image.new("RGBA", (img.width + (width * 2), img.height + (width * 2)))
ImageDraw.Draw(border).rounded_rectangle((0, 0, img.width, img.height), radius, (0, 0, 0, 0), color, width)
self.draw_img_alpha(border, (xy[0] - width, xy[1] - width))
return self
def draw_chapter(self,
chapter: str,
color: Union[Color, Tuple[int, int, int]] = Color.BLACK,
xy: Optional[Tuple[int, int]] = None):
"""
在当前绘图坐标绘制一个章节标题会自动移动绘图坐标至下次绘图适合位置
也可手动传入绘图坐标手动传入时不会移动绘图坐标
Args:
chapter: 章节名称
color: 字体颜色默认黑色 (0, 0, 0)
xy: 绘图坐标默认自适应绘图坐标
"""
if isinstance(color, Color):
color = color.value
if xy is None:
self.__draw.text(self.__xy, chapter, color, self.__chapter_font)
self.move_pos(0, self.__chapter_font.size + self.__ROW_SPACE)
else:
self.__draw.text(xy, chapter, color, self.__chapter_font)
return self
def get_chapter_length(self, s: str) -> int:
"""
获取绘制指定字符串的章节标题所需长度像素数
Args:
s: 要绘制的字符串
"""
return int(self.__draw.textlength(s, self.__chapter_font))
def draw_section(self,
section: str,
color: Union[Color, Tuple[int, int, int]] = Color.BLACK,
xy: Optional[Tuple[int, int]] = None):
"""
在当前绘图坐标绘制一个小节标题会自动移动绘图坐标至下次绘图适合位置
也可手动传入绘图坐标手动传入时不会移动绘图坐标
Args:
section: 小节名称
color: 字体颜色默认黑色 (0, 0, 0)
xy: 绘图坐标默认自适应绘图坐标
"""
if isinstance(color, Color):
color = color.value
if xy is None:
self.__draw.text(self.__xy, section, color, self.__section_font)
self.move_pos(0, self.__section_font.size + self.__ROW_SPACE)
else:
self.__draw.text(xy, section, color, self.__section_font)
return self
def get_section_length(self, s: str) -> int:
"""
获取绘制指定字符串的小节标题所需长度像素数
Args:
s: 要绘制的字符串
"""
return int(self.__draw.textlength(s, self.__section_font))
def draw_tip(self,
tip: str,
color: Union[Color, Tuple[int, int, int]] = Color.GRAY,
xy: Optional[Tuple[int, int]] = None):
"""
在当前绘图坐标绘制一个提示会自动移动绘图坐标至下次绘图适合位置
也可手动传入绘图坐标手动传入时不会移动绘图坐标
Args:
tip: 提示内容
color: 字体颜色默认灰色 (169, 169, 169)
xy: 绘图坐标默认自适应绘图坐标
"""
if isinstance(color, Color):
color = color.value
if xy is None:
self.__draw.text(self.__xy, tip, color, self.__tip_font)
self.move_pos(0, self.__tip_font.size + self.__ROW_SPACE)
else:
self.__draw.text(xy, tip, color, self.__tip_font)
return self
def get_tip_length(self, s: str) -> int:
"""
获取绘制指定字符串的提示所需长度像素数
Args:
s: 要绘制的字符串
"""
return int(self.__draw.textlength(s, self.__tip_font))
def draw_text(self,
texts: Union[str, List[str]],
colors: Optional[Union[Color, Tuple[int, int, int], List[Union[Color, Tuple[int, int, int]]]]] = None,
xy: Optional[Tuple[int, int]] = None):
"""
在当前绘图坐标绘制一行文本会自动移动绘图坐标至下次绘图适合位置
也可手动传入绘图坐标手动传入时不会移动绘图坐标
传入文本列表和颜色列表可将一行文本绘制为不同颜色文本列表和颜色列表需一一对应
颜色列表少于文本列表时将使用默认黑色 (0, 0, 0)颜色列表多于文本列表时将舍弃多余颜色
Args:
texts: 文本内容
colors: 字体颜色默认黑色 (0, 0, 0)
xy: 绘图坐标默认自适应绘图坐标
"""
if colors is None:
colors = []
if isinstance(texts, str):
texts = [texts]
if isinstance(colors, (Color, tuple)):
colors = [colors]
for i in range(len(texts) - len(colors)):
colors.append(Color.BLACK)
for i in range(len(colors)):
if isinstance(colors[i], Color):
colors[i] = colors[i].value
if xy is None:
x = self.x
for i in range(len(texts)):
self.__draw.text(self.__xy, texts[i], colors[i], self.__text_font)
self.move_pos(int(self.__draw.textlength(texts[i], self.__text_font)), 0)
self.move_pos(x - self.x, self.__text_font.size + self.__ROW_SPACE)
else:
for i in range(len(texts)):
self.__draw.text(xy, texts[i], colors[i], self.__text_font)
xy = xy[0] + self.__draw.textlength(texts[i], self.__text_font), xy[1]
return self
def draw_text_right(self,
margin_right: int,
texts: Union[str, List[str]],
colors: Optional[Union[Color, Tuple[int, int, int],
List[Union[Color, Tuple[int, int, int]]]]] = None,
xy_limit: Optional[Tuple[int, int]] = (0, 0)):
"""
在当前绘图坐标绘制一行右对齐文本会自动移动绘图坐标保证不会覆盖指定的点会自动移动绘图坐标至下次绘图适合位置
传入文本列表和颜色列表可将一行文本绘制为不同颜色文本列表和颜色列表需一一对应
颜色列表少于文本列表时将使用默认黑色 (0, 0, 0)颜色列表多于文本列表时将舍弃多余颜色
Args:
margin_right: 右边距
texts: 文本内容
colors: 字体颜色默认黑色 (0, 0, 0)
xy_limit: 指定不可被覆盖的点默认(0, 0)
"""
xy = self.__xy
x = self.width - self.__draw.textlength("".join(texts), self.__text_font) - margin_right
cover_margin = config.get("PAINTER_AUTO_SIZE_BY_LIMIT_MARGIN")
xy_limit = xy_limit[0] - cover_margin, xy_limit[1] + cover_margin
y = max(xy[1], xy_limit[1])
self.draw_text(texts, colors, (x, y))
self.set_pos(xy[0], y + self.__text_font.size + self.__ROW_SPACE)
return self
def get_text_length(self, s: str) -> int:
"""
获取绘制指定字符串的文本所需长度像素数
Args:
s: 要绘制的字符串
"""
return int(self.__draw.textlength(s, self.__text_font))
def show(self):
"""
显示图片
"""
self.__canvas.show()
return self
def save(self, path: str):
"""
保存图片
Args:
path: 保存路径
"""
self.__canvas.save(path)
return self
def base64(self) -> str:
"""
结束绘图获取 Base64 字符串
Returns:
Base64 字符串
"""
io = BytesIO()
self.__canvas.save(io, format="PNG")
return base64.b64encode(io.getvalue()).decode()

View File

File diff suppressed because it is too large Load Diff