Files
zhenxun_bot/zhenxun/utils/_build_image.py
T
4e33bf3a50 版本更新 (#1666)
* ✨ 父级插件加载

* ✅ 添加测试:更新与添加插件 (#1594)

* ✅ 测试更新与添加插件

* ✅ Sourcery建议

* 👷 添加pytest

* 🎨 优化代码

* 🐛 bug修复

* 🐛修复添加插件返回403的问题 (#1595)

* 完善测试方法
* vscode测试配置
* 重构插件安装过程

* 🎨 修改readme

* Update README.md

* 🐛 修改bug与版本锁定

* 🐛 修复超级用户对群组功能开关

* 🐛 修复插件商店检查插件更新问题 (#1597)

* 🐛 修复插件商店检查插件更新问题

* 🐛 恶意命令检测问题

* 🐛 增加插件状态检查 (#1598)

* ✅ 优化测试用例

* 🐛 更改插件更新与安装逻辑

* 🐛 修复更新群组成员信息

* 🎨 代码优化

* 🚀 更新Dockerfile (#1599)

* 🎨 更新requirements

* ➕ 添加依赖aiocache

* ⚡ 添加github镜像

* ✨ 添加仓库目录多获取渠道

* 🐛 修复测试用例

* ✨ 添加API缓存

* 🎨 采取Sourcery建议

* 🐛 文件下载逻辑修改

* 🎨 优化代码

* 🐛 修复插件开关有时出现错误

* ✨ 重构自检ui

* 🐛 自检html修正

* 修复签到逻辑bug,并使代码更灵活以适应签到好感度等级配置 (#1606)

* 修复签到功能已知问题

* 修复签到功能已知问题

* 修改参数名称

* 修改uid判断

---------

Co-authored-by: HibiKier <45528451+HibiKier@users.noreply.github.com>

* 🎨 代码结构优化

* 🐛 私聊时修改插件时删除私聊帮助

* 🐛 过滤父插件

* 🐛 修复自检在ARM上的问题 (#1607)

* 🐛 修复自检在ARM上的问题

* ✅ 优化测试

* ✨ 支持mysql,psql,sqlite随机函数

* 🔧 VSCode配置修改

* 🔧 VSCode配置修改

* ✨ 添加金币排行

Co-Authored-By: HibiKier <45528451+HibiKier@users.noreply.github.com>

* 📝 修改README

Co-Authored-By: HibiKier <45528451+HibiKier@users.noreply.github.com>

* 🔨 提取GitHub相关操作 (#1609)

* 🔨 提取GitHub相关操作

* 🔨 重构API策略

* ✨ 签到/金币排行限制最大数量 (#1616)

* ✨ 签到/金币排行限制最大数量

* 🐛 修复超级用户id获取问题

* 🐛 修复路径解压与挂载 (#1619)

* 🐛 修复功能少时zhenxun帮助图片排序问题 (#1620)

* 🐛 签到文本适应 (#1622)

* 🐛 好感度排行提供默认值 (#1624)

* 🎈 优先使用github api (#1625)

* ✨ 重构帮助,限制普通用户查询管理插件 (#1626)

* 🐛 修复群权限与插件等级匹配 (#1627)

* ✨ 当管理员尝试ban真寻时将被反杀 (#1628)

* ✨ 群组发言时间检测提供开关配置 (#1630)

* 🐳 chore: 支持自动修改版本号 (#1629)

* 🎈 perf(github_utils): 支持github url下载遍历 (#1632)

* 🎈 perf(github_utils): 支持github url下载遍历

* 🐞 fix(http_utils): 修复一些下载问题

* 🦄 refactor(http_utils): 部分重构

* chore(version): Update version to v0.2.2-e6f17c4

---------

Co-authored-by: AkashiCoin <AkashiCoin@users.noreply.github.com>

* 🧪 test(auto_update): 修复测试用例 (#1633)

* 🐛 修复商店商品为空时报错 (#1634)

* 🐛 修复群权限与插件等级匹配 (#1635)

* ✨ message_build支持AtAll (#1639)

* 🎈 perf: 使用commit号下载插件 (#1641)

* 🎈 perf: 使用commit号下载插件

* chore(version): Update version to v0.2.2-f9c7360

---------

Co-authored-by: AkashiCoin <AkashiCoin@users.noreply.github.com>

* 🐳 chore: 修改运行检查触发路径 (#1642)

* 🐳 chore: 修改运行检查触发路径

* 🐳 chore: 添加tests目录

* ✨ 重构qq群事件处理 (#1643)

* 🐛 签到名称自适应 (#1644)

* 🎨  更新README (#1645)

* 🐛 fix(http_utils): 流式下载Content-Length错误 (#1647)

* 🐛 修复群组中帮助功能状态显示问题 (#1650)

* 🐛 修复群欢迎消息设置 (#1651)

* 🐛 修复webui下载后首次启动错误 (#1652)

* 🐛 修复webui下载后首次启动错误

* chore(version): Update version to v0.2.2-4a8ef85

---------

Co-authored-by: HibiKier <HibiKier@users.noreply.github.com>

* ✨ 移除默认图片文件夹:爬 (#1653)

* ✨ 安装/移除插件提供插件安装/卸载方法用于插件初始化 (#1654)

* ✨ 新增超级用户与管理员帮助模板 (#1655)

* ✨ 新增个人信息命令 (#1657)

* ✨ 修改个人信息菜单名称 (#1658)

* ✨ 新增插件商店api (#1659)

* ✨ 新增插件商店api

* chore(version): Update version to v0.2.2-7e15f20

---------

Co-authored-by: HibiKier <HibiKier@users.noreply.github.com>

* ✨ 将cd,block,count限制复原配置文件 (#1662)

* 🎨 修改README (#1663)

* 🎨 修改版本号 (#1664)

* 🎨 修改requirements (#1665)

---------

Co-authored-by: AkashiCoin <l1040186796@gmail.com>
Co-authored-by: fanyinrumeng <42991257+fanyinrumeng@users.noreply.github.com>
Co-authored-by: AkashiCoin <i@loli.vet>
Co-authored-by: Elaga <1728903318@qq.com>
Co-authored-by: AkashiCoin <AkashiCoin@users.noreply.github.com>
Co-authored-by: HibiKier <HibiKier@users.noreply.github.com>
2024-10-01 00:42:23 +08:00

722 lines
20 KiB
Python

import math
import uuid
import base64
import itertools
import contextlib
from io import BytesIO
from pathlib import Path
from typing_extensions import Self
from typing import Literal, TypeAlias, overload
from nonebot.utils import run_sync
from PIL.Image import Image as tImage
from PIL.ImageFont import FreeTypeFont
from PIL import Image, ImageDraw, ImageFont, ImageFilter
from zhenxun.configs.path_config import FONT_PATH
ModeType = Literal[
"1", "CMYK", "F", "HSV", "I", "L", "LAB", "P", "RGB", "RGBA", "RGBX", "YCbCr"
]
"""图片类型"""
ColorAlias: TypeAlias = str | tuple[int, int, int] | tuple[int, int, int, int] | None
CenterType = Literal["center", "height", "width"]
"""
粘贴居中
center: 水平垂直居中
height: 垂直居中
width: 水平居中
"""
class BuildImage:
"""
快捷生成图片与操作图片的工具类
"""
def __init__(
self,
width: int = 0,
height: int = 0,
color: ColorAlias = (255, 255, 255),
mode: ModeType = "RGBA",
font: str | Path | FreeTypeFont = "HYWenHei-85W.ttf",
font_size: int = 20,
background: str | BytesIO | Path | bytes | None = None,
) -> None:
self.uid = uuid.uuid1()
self.width = width
self.height = height
self.color = color
self.font = (
font if isinstance(font, FreeTypeFont) else self.load_font(font, font_size)
)
if background:
if isinstance(background, bytes):
self.markImg = Image.open(BytesIO(background))
else:
self.markImg = Image.open(background)
if width and height:
self.markImg = self.markImg.resize((width, height), Image.LANCZOS)
else:
self.width = self.markImg.width
self.height = self.markImg.height
elif width and height:
self.markImg = Image.new(mode, (width, height), color) # type: ignore
else:
raise ValueError("长度和宽度不能为空...")
self.draw = ImageDraw.Draw(self.markImg)
@property
def size(self) -> tuple[int, int]:
return self.markImg.size
@classmethod
def open(cls, path: str | Path | bytes) -> Self:
"""打开图片
参数:
path: 图片路径
返回:
Self: BuildImage
"""
return cls(background=path)
@classmethod
async def build_text_image(
cls,
text: str,
font: str | FreeTypeFont | Path = "HYWenHei-85W.ttf",
size: int = 10,
font_color: str | tuple[int, int, int] = (0, 0, 0),
color: ColorAlias = None,
padding: int | tuple[int, int, int, int] | None = None,
) -> Self:
"""构建文本图片
参数:
text: 文本
font: 字体路径
size: 字体大小
font_color: 字体颜色.
color: 背景颜色
padding: 外边距
返回:
Self: Self
"""
if not text.strip():
return cls(1, 1)
_font = None
if isinstance(font, FreeTypeFont):
_font = font
elif isinstance(font, str | Path):
_font = cls.load_font(font, size)
width, height = cls.get_text_size(text, _font)
if isinstance(padding, int):
width += padding * 2
height += padding * 2
elif isinstance(padding, tuple):
width += padding[1] + padding[3]
height += padding[0] + padding[2]
markImg = cls(width, height, color, font=_font)
await markImg.text(
(0, 0), text, fill=font_color, font=_font, center_type="center"
)
return markImg
@classmethod
async def auto_paste(
cls,
img_list: list[Self | tImage],
row: int,
space: int = 10,
padding: int = 50,
color: ColorAlias = (255, 255, 255),
background: str | BytesIO | Path | None = None,
) -> Self:
"""自动贴图
参数:
img_list: 图片列表
row: 一行图片的数量
space: 图片之间的间距.
padding: 外边距.
color: 图片背景颜色.
background: 图片背景图片.
返回:
Self: Self
"""
if not img_list:
raise ValueError("贴图类别为空...")
width, height = img_list[0].size
background_width = width * row + space * (row - 1) + padding * 2
row_count = math.ceil(len(img_list) / row)
if row_count == 1:
background_width = (
sum(img.width for img in img_list) + space * (row - 1) + padding * 2
)
background_height = height * row_count + space * (row_count - 1) + padding * 2
background_image = cls(
background_width, background_height, color=color, background=background
)
_cur_width, _cur_height = padding, padding
for img in img_list:
await background_image.paste(img, (_cur_width, _cur_height))
_cur_width += space + img.width
if _cur_width + padding >= background_image.width:
_cur_height += space + img.height
_cur_width = padding
return background_image
@classmethod
def load_font(
cls, font: str | Path = "HYWenHei-85W.ttf", font_size: int = 10
) -> FreeTypeFont:
"""加载字体
参数:
font: 字体名称
font_size: 字体大小
返回:
FreeTypeFont: 字体
"""
path = FONT_PATH / font if type(font) is str else font
return ImageFont.truetype(str(path), font_size)
@overload
@classmethod
def get_text_size(
cls, text: str, font: FreeTypeFont | None = None
) -> tuple[int, int]: ...
@overload
@classmethod
def get_text_size(
cls, text: str, font: str | None = None, font_size: int = 10
) -> tuple[int, int]: ...
@classmethod
def get_text_size(
cls,
text: str,
font: str | FreeTypeFont | None = "HYWenHei-85W.ttf",
font_size: int = 10,
) -> tuple[int, int]: # sourcery skip: remove-unnecessary-cast
"""获取该字体下文本需要的长宽
参数:
text: 文本内容
font: 字体名称或FreeTypeFont
font_size: 字体大小
返回:
tuple[int, int]: 长宽
"""
_font = font
if font and type(font) is str:
_font = cls.load_font(font, font_size)
temp_image = Image.new("RGB", (1, 1), (255, 255, 255))
draw = ImageDraw.Draw(temp_image)
text_box = draw.textbbox((0, 0), str(text), font=_font) # type: ignore
text_width = text_box[2] - text_box[0]
text_height = text_box[3] - text_box[1]
return text_width, text_height + 10
# return _font.getsize(str(text)) # type: ignore
def getsize(self, msg: str) -> tuple[int, int]:
# sourcery skip: remove-unnecessary-cast
"""
获取文字在该图片 font_size 下所需要的空间
参数:
msg: 文本
返回:
tuple[int, int]: 长宽
"""
temp_image = Image.new("RGB", (1, 1), (255, 255, 255))
draw = ImageDraw.Draw(temp_image)
text_box = draw.textbbox((0, 0), str(msg), font=self.font)
text_width = text_box[2] - text_box[0]
text_height = text_box[3] - text_box[1]
return text_width, text_height + 10
# return self.font.getsize(msg) # type: ignore
def __center_xy(
self,
pos: tuple[int, int],
width: int,
height: int,
center_type: CenterType | None,
) -> tuple[int, int]:
"""
根据居中类型定位xy
参数:
pos: 定位
image: image
center_type: 居中类型
返回:
tuple[int, int]: 定位
"""
# _width, _height = pos
if self.width and self.height:
if center_type == "center":
width = int((self.width - width) / 2)
height = int((self.height - height) / 2)
elif center_type == "width":
width = int((self.width - width) / 2)
height = pos[1]
elif center_type == "height":
width = pos[0]
height = int((self.height - height) / 2)
return width, height
@run_sync
def paste(
self,
image: Self | tImage,
pos: tuple[int, int] = (0, 0),
center_type: CenterType | None = None,
) -> Self:
"""贴图
参数:
image: BuildImage 或 Image
pos: 定位.
center_type: 居中.
返回:
BuildImage: Self
异常:
ValueError: 居中类型错误
"""
if center_type and center_type not in ["center", "height", "width"]:
raise ValueError("center_type must be 'center', 'width' or 'height'")
_image = image
if isinstance(image, BuildImage):
_image = image.markImg
if _image.width and _image.height and center_type:
pos = self.__center_xy(pos, _image.width, _image.height, center_type)
try:
self.markImg.paste(_image, pos, _image) # type: ignore
except ValueError:
self.markImg.paste(_image, pos) # type: ignore
return self
@run_sync
def point(
self, pos: tuple[int, int], fill: tuple[int, int, int] | None = None
) -> Self:
"""
绘制多个或单独的像素
参数:
pos: 坐标
fill: 填充颜色.
返回:
BuildImage: Self
"""
self.draw.point(pos, fill=fill)
return self
@run_sync
def ellipse(
self,
pos: tuple[int, int, int, int],
fill: tuple[int, int, int] | None = None,
outline: tuple[int, int, int] | None = None,
width: int = 1,
) -> Self:
"""
绘制圆
参数:
pos: 坐标范围
fill: 填充颜色.
outline: 描线颜色.
width: 描线宽度.
返回:
BuildImage: Self
"""
self.draw.ellipse(pos, fill, outline, width)
return self
@run_sync
def text(
self,
pos: tuple[int, int],
text: str,
fill: str | tuple[int, int, int] = (0, 0, 0),
center_type: CenterType | None = None,
font: FreeTypeFont | str | Path | None = None,
font_size: int = 10,
) -> Self: # sourcery skip: remove-unnecessary-cast
"""
在图片上添加文字
参数:
pos: 文字位置
text: 文字内容
fill: 文字颜色.
center_type: 居中类型.
font: 字体.
font_size: 字体大小.
返回:
BuildImage: Self
异常:
ValueError: 居中类型错误
"""
if center_type and center_type not in ["center", "height", "width"]:
raise ValueError("center_type must be 'center', 'width' or 'height'")
max_length_text = ""
sentence = str(text).split("\n")
for x in sentence:
max_length_text = x if len(x) > len(max_length_text) else max_length_text
if font:
if not isinstance(font, FreeTypeFont):
font = self.load_font(font, font_size)
else:
font = self.font
if center_type:
ttf_w, ttf_h = self.getsize(max_length_text) # type: ignore
# ttf_h = ttf_h * len(sentence)
pos = self.__center_xy(pos, ttf_w, ttf_h, center_type)
self.draw.text(pos, str(text), fill=fill, font=font)
return self
@run_sync
def save(self, path: str | Path):
"""
保存图片
参数:
path: 图片路径
"""
self.markImg.save(path) # type: ignore
def show(self):
"""
说明:
显示图片
"""
self.markImg.show()
@run_sync
def resize(self, ratio: float = 0, width: int = 0, height: int = 0) -> Self:
"""
压缩图片
参数:
ratio: 压缩倍率.
width: 压缩图片宽度至 width.
height: 压缩图片高度至 height.
返回:
BuildImage: Self
异常:
ValueError: 缺少参数
"""
if not width and not height and not ratio:
raise ValueError("缺少参数...")
if self.width and self.height:
if not width and not height:
width = int(self.width * ratio)
height = int(self.height * ratio)
self.markImg = self.markImg.resize((width, height), Image.LANCZOS) # type: ignore
self.width, self.height = self.markImg.size
self.draw = ImageDraw.Draw(self.markImg)
return self
@run_sync
def crop(self, box: tuple[int, int, int, int]) -> Self:
"""
裁剪图片
参数:
box: 左上角坐标,右下角坐标 (left, upper, right, lower)
返回:
BuildImage: Self
"""
self.markImg = self.markImg.crop(box)
self.width, self.height = self.markImg.size
self.draw = ImageDraw.Draw(self.markImg)
return self
@run_sync
def transparent(self, alpha_ratio: float = 1, n: int = 0) -> Self:
"""
图片透明化
参数:
alpha_ratio: 透明化程度.
n: 透明化大小内边距.
返回:
BuildImage: Self
"""
self.markImg = self.markImg.convert("RGBA")
x, y = self.markImg.size
for i, k in itertools.product(range(n, x - n), range(n, y - n)):
color = self.markImg.getpixel((i, k))
color = color[:-1] + (int(100 * alpha_ratio),)
self.markImg.putpixel((i, k), color)
self.draw = ImageDraw.Draw(self.markImg)
return self
def pic2bs4(self) -> str:
"""BuildImage 转 base64
返回:
str: base64
"""
buf = BytesIO()
self.markImg.save(buf, format="PNG")
base64_str = base64.b64encode(buf.getvalue()).decode()
return f"base64://{base64_str}"
def pic2bytes(self) -> bytes:
"""获取bytes
返回:
bytes: bytes
"""
buf = BytesIO()
self.markImg.save(buf, format="PNG")
return buf.getvalue()
def convert(self, type_: ModeType) -> Self:
"""
修改图片类型
参数:
type_: ModeType
返回:
BuildImage: Self
"""
self.markImg = self.markImg.convert(type_)
return self
@run_sync
def rectangle(
self,
xy: tuple[int, int, int, int],
fill: tuple[int, int, int] | None = None,
outline: str | None = None,
width: int = 1,
) -> Self:
"""
画框
参数:
xy: 坐标
fill: 填充颜色.
outline: 轮廓颜色.
width: 线宽.
返回:
BuildImage: Self
"""
self.draw.rectangle(xy, fill, outline, width)
return self
@run_sync
def polygon(
self,
xy: list[tuple[int, int]],
fill: tuple[int, int, int] = (0, 0, 0),
outline: int = 1,
) -> Self:
"""
画多边形
参数:
xy: 坐标
fill: 颜色.
outline: 线宽.
返回:
BuildImage: Self
"""
self.draw.polygon(xy, fill, outline)
return self
@run_sync
def line(
self,
xy: tuple[int, int, int, int],
fill: tuple[int, int, int] | str = "#D8DEE4",
width: int = 1,
) -> Self:
"""
画线
参数:
xy: 坐标
fill: 填充.
width: 线宽.
返回:
BuildImage: Self
"""
self.draw.line(xy, fill, width)
return self
@run_sync
def circle(self) -> Self:
"""
图像变圆
返回:
BuildImage: Self
"""
self.markImg.convert("RGBA")
size = self.markImg.size
r2 = min(size[0], size[1])
if size[0] != size[1]:
self.markImg = self.markImg.resize((r2, r2), Image.LANCZOS) # type: ignore
width = 1
antialias = 4
ellipse_box = [0, 0, r2 - 2, r2 - 2]
mask = Image.new(
size=[int(dim * antialias) for dim in self.markImg.size], # type: ignore
mode="L",
color="black",
)
draw = ImageDraw.Draw(mask)
for offset, fill in (width / -2.0, "black"), (width / 2.0, "white"):
left, top = ((value + offset) * antialias for value in ellipse_box[:2])
right, bottom = ((value - offset) * antialias for value in ellipse_box[2:])
draw.ellipse([left, top, right, bottom], fill=fill)
mask = mask.resize(self.markImg.size, Image.LANCZOS)
with contextlib.suppress(ValueError):
self.markImg.putalpha(mask)
return self
@run_sync
def circle_corner(
self,
radii: int = 30,
point_list: list[Literal["lt", "rt", "lb", "rb"]] | None = None,
) -> Self:
"""
矩形四角变圆
参数:
radii: 半径.
point_list: 需要变化的角.
返回:
BuildImage: Self
"""
if point_list is None:
point_list = ["lt", "rt", "lb", "rb"]
# 画圆(用于分离4个角)
img = self.markImg.convert("RGBA")
alpha = img.split()[-1]
circle = Image.new("L", (radii * 2, radii * 2), 0)
draw = ImageDraw.Draw(circle)
draw.ellipse((0, 0, radii * 2, radii * 2), fill=255) # 黑色方形内切白色圆形
w, h = img.size
if "lt" in point_list:
alpha.paste(circle.crop((0, 0, radii, radii)), (0, 0))
if "rt" in point_list:
alpha.paste(circle.crop((radii, 0, radii * 2, radii)), (w - radii, 0))
if "lb" in point_list:
alpha.paste(circle.crop((0, radii, radii, radii * 2)), (0, h - radii))
if "rb" in point_list:
alpha.paste(
circle.crop((radii, radii, radii * 2, radii * 2)),
(w - radii, h - radii),
)
img.putalpha(alpha)
self.markImg = img
self.draw = ImageDraw.Draw(self.markImg)
return self
@run_sync
def rotate(self, angle: int, expand: bool = False) -> Self:
"""
旋转图片
参数:
angle: 角度
expand: 放大图片适应角度.
返回:
BuildImage: Self
"""
self.markImg = self.markImg.rotate(angle, expand=expand)
return self
@run_sync
def transpose(self, angle: Literal[0, 1, 2, 3, 4, 5, 6]) -> Self:
"""
旋转图片(包括边框)
参数:
angle: 角度
返回:
BuildImage: Self
"""
self.markImg.transpose(angle)
return self
@run_sync
def filter(self, filter_: str, aud: int | None = None) -> Self:
"""
图片变化
参数:
filter_: 变化效果
aud: 利率.
返回:
BuildImage: Self
"""
_type = None
if filter_ == "GaussianBlur": # 高斯模糊
_type = ImageFilter.GaussianBlur
elif filter_ == "EDGE_ENHANCE": # 锐化效果
_type = ImageFilter.EDGE_ENHANCE
elif filter_ == "BLUR": # 模糊效果
_type = ImageFilter.BLUR
elif filter_ == "CONTOUR": # 铅笔滤镜
_type = ImageFilter.CONTOUR
elif filter_ == "FIND_EDGES": # 边缘检测
_type = ImageFilter.FIND_EDGES
if _type:
if aud:
self.markImg = self.markImg.filter(_type(aud)) # type: ignore
else:
self.markImg = self.markImg.filter(_type)
self.draw = ImageDraw.Draw(self.markImg)
return self
def tobytes(self) -> bytes:
"""转换为bytes
返回:
bytes: bytes
"""
return self.markImg.tobytes()