import os import uuid from typing import Tuple, Union, List import nonebot from PIL import Image, ImageFont, ImageDraw default_color = (255, 255, 255, 255) default_font = "resources/fonts/MiSans-Semibold.ttf" def render_canvas_from_json(file: str, background: Image) -> "Canvas": pass class BasePanel: def __init__(self, uv_size: Tuple[Union[int, float], Union[int, float]] = (1.0, 1.0), box_size: Tuple[Union[int, float], Union[int, float]] = (1.0, 1.0), parent_point: Tuple[float, float] = (0.5, 0.5), point: Tuple[float, float] = (0.5, 0.5)): """ :param uv_size: 底面板大小 :param box_size: 子(自身)面板大小 :param parent_point: 底面板锚点 :param point: 子(自身)面板锚点 """ self.canvas: Canvas | None = None self.uv_size = uv_size self.box_size = box_size self.parent_point = parent_point self.point = point self.parent: BasePanel | None = None self.canvas_box: Tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0) # 此节点在父节点上的盒子 self.box = ( self.parent_point[0] - self.point[0] * self.box_size[0] / self.uv_size[0], self.parent_point[1] - self.point[1] * self.box_size[1] / self.uv_size[1], self.parent_point[0] + (1 - self.point[0]) * self.box_size[0] / self.uv_size[0], self.parent_point[1] + (1 - self.point[1]) * self.box_size[1] / self.uv_size[1] ) def load(self, only_calculate=False): """ 将对象写入画布 此处仅作声明 由各子类重写 :return: """ self.actual_pos = self.canvas_box def save_as(self, canvas_box, only_calculate=False): """ 此函数执行时间较长,建议异步运行 :param only_calculate: :param canvas_box 此节点在画布上的盒子,并不是在父节点上的盒子 :return: """ for name, child in self.__dict__.items(): # 此节点在画布上的盒子 if isinstance(child, BasePanel) and name not in ["canvas", "parent"]: child.parent = self if isinstance(self, Canvas): child.canvas = self else: child.canvas = self.canvas dxc = canvas_box[2] - canvas_box[0] dyc = canvas_box[3] - canvas_box[1] child.canvas_box = ( canvas_box[0] + dxc * child.box[0], canvas_box[1] + dyc * child.box[1], canvas_box[0] + dxc * child.box[2], canvas_box[1] + dyc * child.box[3] ) child.load(only_calculate) child.save_as(child.canvas_box, only_calculate) class Canvas(BasePanel): def __init__(self, base_img: Image.Image): self.base_img = base_img self.canvas = self super(Canvas, self).__init__() self.draw_line_list = [] def export(self, file, alpha=False): self.base_img = self.base_img.convert("RGBA") self.save_as((0, 0, 1, 1)) draw = ImageDraw.Draw(self.base_img) for line in self.draw_line_list: draw.line(*line) if not alpha: self.base_img = self.base_img.convert("RGB") self.base_img.save(file) def delete(self): os.remove(self.file) def get_actual_box(self, path: str) -> Union[None, Tuple[float, float, float, float]]: """ 获取控件实际相对大小 函数执行时间较长 :param path: 控件路径 :return: """ sub_obj = self self.save_as((0, 0, 1, 1), True) control_path = "" for i, seq in enumerate(path.split(".")): if seq not in sub_obj.__dict__: raise KeyError(f"在{control_path}中找不到控件:{seq}") control_path += f".{seq}" sub_obj = sub_obj.__dict__[seq] return sub_obj.actual_pos def get_actual_pixel_size(self, path: str) -> Union[None, Tuple[int, int]]: """ 获取控件实际像素长宽 函数执行时间较长 :param path: 控件路径 :return: """ sub_obj = self self.save_as((0, 0, 1, 1), True) control_path = "" for i, seq in enumerate(path.split(".")): if seq not in sub_obj.__dict__: raise KeyError(f"在{control_path}中找不到控件:{seq}") control_path += f".{seq}" sub_obj = sub_obj.__dict__[seq] dx = int(sub_obj.canvas.base_img.size[0] * (sub_obj.actual_pos[2] - sub_obj.actual_pos[0])) dy = int(sub_obj.canvas.base_img.size[1] * (sub_obj.actual_pos[3] - sub_obj.actual_pos[1])) return dx, dy def get_actual_pixel_box(self, path: str) -> Union[None, Tuple[int, int, int, int]]: """ 获取控件实际像素大小盒子 函数执行时间较长 :param path: 控件路径 :return: """ sub_obj = self self.save_as((0, 0, 1, 1), True) control_path = "" for i, seq in enumerate(path.split(".")): if seq not in sub_obj.__dict__: raise KeyError(f"在{control_path}中找不到控件:{seq}") control_path += f".{seq}" sub_obj = sub_obj.__dict__[seq] x1 = int(sub_obj.canvas.base_img.size[0] * sub_obj.actual_pos[0]) y1 = int(sub_obj.canvas.base_img.size[1] * sub_obj.actual_pos[1]) x2 = int(sub_obj.canvas.base_img.size[2] * sub_obj.actual_pos[2]) y2 = int(sub_obj.canvas.base_img.size[3] * sub_obj.actual_pos[3]) return x1, y1, x2, y2 def get_parent_box(self, path: str) -> Union[None, Tuple[float, float, float, float]]: """ 获取控件在父节点的大小 函数执行时间较长 :param path: 控件路径 :return: """ sub_obj = self.get_control_by_path(path) on_parent_pos = ( (sub_obj.actual_pos[0] - sub_obj.parent.actual_pos[0]) / (sub_obj.parent.actual_pos[2] - sub_obj.parent.actual_pos[0]), (sub_obj.actual_pos[1] - sub_obj.parent.actual_pos[1]) / (sub_obj.parent.actual_pos[3] - sub_obj.parent.actual_pos[1]), (sub_obj.actual_pos[2] - sub_obj.parent.actual_pos[0]) / (sub_obj.parent.actual_pos[2] - sub_obj.parent.actual_pos[0]), (sub_obj.actual_pos[3] - sub_obj.parent.actual_pos[1]) / (sub_obj.parent.actual_pos[3] - sub_obj.parent.actual_pos[1]) ) return on_parent_pos def get_control_by_path(self, path: str) -> Union[BasePanel, "Img", "Rectangle", "Text"]: sub_obj = self self.save_as((0, 0, 1, 1), True) control_path = "" for i, seq in enumerate(path.split(".")): if seq not in sub_obj.__dict__: raise KeyError(f"在{control_path}中找不到控件:{seq}") control_path += f".{seq}" sub_obj = sub_obj.__dict__[seq] return sub_obj def draw_line(self, path: str, p1: Tuple[float, float], p2: Tuple[float, float], color, width): """ 画线 :param color: :param width: :param path: :param p1: :param p2: :return: """ ac_pos = self.get_actual_box(path) control = self.get_control_by_path(path) dx = ac_pos[2] - ac_pos[0] dy = ac_pos[3] - ac_pos[1] xy_box = int((ac_pos[0] + dx * p1[0]) * control.canvas.base_img.size[0]), int((ac_pos[1] + dy * p1[1]) * control.canvas.base_img.size[1]), int( (ac_pos[0] + dx * p2[0]) * control.canvas.base_img.size[0]), int((ac_pos[1] + dy * p2[1]) * control.canvas.base_img.size[1]) self.draw_line_list.append((xy_box, color, width)) class Panel(BasePanel): def __init__(self, uv_size, box_size, parent_point, point): super(Panel, self).__init__(uv_size, box_size, parent_point, point) class TextSegment: def __init__(self, text, **kwargs): if not isinstance(text, str): raise TypeError("请输入字符串") self.text = text self.color = kwargs.get("color", None) self.font = kwargs.get("font", None) @staticmethod def text2text_segment_list(text: str): """ 暂时没写好 :param text: %FFFFFFFF%1123%FFFFFFFF%21323 :return: """ pass class Text(BasePanel): def __init__(self, uv_size, box_size, parent_point, point, text: Union[str, list], font=default_font, color=(255, 255, 255, 255), vertical=False, line_feed=False, force_size=False, fill=(0, 0, 0, 0), fillet=0, outline=(0, 0, 0, 0), outline_width=0, rectangle_side=0, font_size=None, dp: int = 5, anchor: str = "la"): """ :param uv_size: :param box_size: :param parent_point: :param point: :param text: list[TextSegment] | str :param font: :param color: :param vertical: 是否竖直 :param line_feed: 是否换行 :param force_size: 强制大小 :param dp: 字体大小递减精度 :param anchor : https://www.zhihu.com/question/474216280 :param fill: 底部填充颜色 :param fillet: 填充圆角 :param rectangle_side: 边框宽度 :param outline: 填充矩形边框颜色 :param outline_width: 填充矩形边框宽度 """ self.actual_pos = None self.outline_width = outline_width self.outline = outline self.fill = fill self.fillet = fillet self.font = font self.text = text self.color = color self.force_size = force_size self.vertical = vertical self.line_feed = line_feed self.dp = dp self.font_size = font_size self.rectangle_side = rectangle_side self.anchor = anchor super(Text, self).__init__(uv_size, box_size, parent_point, point) def load(self, only_calculate=False): """限制区域像素大小""" if isinstance(self.text, str): self.text = [ TextSegment(text=self.text, color=self.color, font=self.font) ] all_text = str() for text in self.text: all_text += text.text limited_size = int((self.canvas_box[2] - self.canvas_box[0]) * self.canvas.base_img.size[0]), int((self.canvas_box[3] - self.canvas_box[1]) * self.canvas.base_img.size[1]) font_size = limited_size[1] if self.font_size is None else self.font_size image_font = ImageFont.truetype(self.font, font_size) actual_size = image_font.getsize(all_text) while (actual_size[0] > limited_size[0] or actual_size[1] > limited_size[1]) and not self.force_size: font_size -= self.dp image_font = ImageFont.truetype(self.font, font_size) actual_size = image_font.getsize(all_text) draw = ImageDraw.Draw(self.canvas.base_img) if isinstance(self.parent, Img) or isinstance(self.parent, Text): self.parent.canvas_box = self.parent.actual_pos dx0 = self.parent.canvas_box[2] - self.parent.canvas_box[0] dy0 = self.parent.canvas_box[3] - self.parent.canvas_box[1] dx1 = actual_size[0] / self.canvas.base_img.size[0] dy1 = actual_size[1] / self.canvas.base_img.size[1] start_point = [ int((self.parent.canvas_box[0] + dx0 * self.parent_point[0] - dx1 * self.point[0]) * self.canvas.base_img.size[0]), int((self.parent.canvas_box[1] + dy0 * self.parent_point[1] - dy1 * self.point[1]) * self.canvas.base_img.size[1]) ] self.actual_pos = ( start_point[0] / self.canvas.base_img.size[0], start_point[1] / self.canvas.base_img.size[1], (start_point[0] + actual_size[0]) / self.canvas.base_img.size[0], (start_point[1] + actual_size[1]) / self.canvas.base_img.size[1], ) self.font_size = font_size if not only_calculate: for text_segment in self.text: if text_segment.color is None: text_segment.color = self.color if text_segment.font is None: text_segment.font = self.font image_font = ImageFont.truetype(font=text_segment.font, size=font_size) if self.fill[-1] > 0: rectangle = Shape.rectangle(size=(actual_size[0] + 2 * self.rectangle_side, actual_size[1] + 2 * self.rectangle_side), fillet=self.fillet, fill=self.fill, width=self.outline_width, outline=self.outline) self.canvas.base_img.paste(im=rectangle, box=(start_point[0] - self.rectangle_side, start_point[1] - self.rectangle_side, start_point[0] + actual_size[0] + self.rectangle_side, start_point[1] + actual_size[1] + self.rectangle_side), mask=rectangle.split()[-1]) draw.text((start_point[0] - self.rectangle_side, start_point[1] - self.rectangle_side), text_segment.text, text_segment.color, font=image_font, anchor=self.anchor) text_width = image_font.getsize(text_segment.text) start_point[0] += text_width[0] class Img(BasePanel): def __init__(self, uv_size, box_size, parent_point, point, img: Image.Image, keep_ratio=True): self.img_base_img = img self.keep_ratio = keep_ratio super(Img, self).__init__(uv_size, box_size, parent_point, point) def load(self, only_calculate=False): self.preprocess() self.img_base_img = self.img_base_img.convert("RGBA") limited_size = int((self.canvas_box[2] - self.canvas_box[0]) * self.canvas.base_img.size[0]), \ int((self.canvas_box[3] - self.canvas_box[1]) * self.canvas.base_img.size[1]) if self.keep_ratio: """保持比例""" actual_ratio = self.img_base_img.size[0] / self.img_base_img.size[1] limited_ratio = limited_size[0] / limited_size[1] if actual_ratio >= limited_ratio: # 图片过长 self.img_base_img = self.img_base_img.resize( (int(self.img_base_img.size[0] * limited_size[0] / self.img_base_img.size[0]), int(self.img_base_img.size[1] * limited_size[0] / self.img_base_img.size[0])) ) else: self.img_base_img = self.img_base_img.resize( (int(self.img_base_img.size[0] * limited_size[1] / self.img_base_img.size[1]), int(self.img_base_img.size[1] * limited_size[1] / self.img_base_img.size[1])) ) else: """不保持比例""" self.img_base_img = self.img_base_img.resize(limited_size) # 占比长度 if isinstance(self.parent, Img) or isinstance(self.parent, Text): self.parent.canvas_box = self.parent.actual_pos dx0 = self.parent.canvas_box[2] - self.parent.canvas_box[0] dy0 = self.parent.canvas_box[3] - self.parent.canvas_box[1] dx1 = self.img_base_img.size[0] / self.canvas.base_img.size[0] dy1 = self.img_base_img.size[1] / self.canvas.base_img.size[1] start_point = ( int((self.parent.canvas_box[0] + dx0 * self.parent_point[0] - dx1 * self.point[0]) * self.canvas.base_img.size[0]), int((self.parent.canvas_box[1] + dy0 * self.parent_point[1] - dy1 * self.point[1]) * self.canvas.base_img.size[1]) ) alpha = self.img_base_img.split()[3] self.actual_pos = ( start_point[0] / self.canvas.base_img.size[0], start_point[1] / self.canvas.base_img.size[1], (start_point[0] + self.img_base_img.size[0]) / self.canvas.base_img.size[0], (start_point[1] + self.img_base_img.size[1]) / self.canvas.base_img.size[1], ) if not only_calculate: self.canvas.base_img.paste(self.img_base_img, start_point, alpha) def preprocess(self): pass class Rectangle(Img): def __init__(self, uv_size, box_size, parent_point, point, fillet: Union[int, float] = 0, img: Union[Image.Image] = None, keep_ratio=True, color=default_color, outline_width=0, outline_color=default_color): """ 圆角图 :param uv_size: :param box_size: :param parent_point: :param point: :param fillet: 圆角半径浮点或整数 :param img: :param keep_ratio: """ self.fillet = fillet self.color = color self.outline_width = outline_width self.outline_color = outline_color super(Rectangle, self).__init__(uv_size, box_size, parent_point, point, img, keep_ratio) def preprocess(self): limited_size = (int(self.canvas.base_img.size[0] * (self.canvas_box[2] - self.canvas_box[0])), int(self.canvas.base_img.size[1] * (self.canvas_box[3] - self.canvas_box[1]))) if not self.keep_ratio and self.img_base_img is not None and self.img_base_img.size[0] / self.img_base_img.size[1] != limited_size[0] / limited_size[1]: self.img_base_img = self.img_base_img.resize(limited_size) self.img_base_img = Shape.rectangle(size=limited_size, fillet=self.fillet, fill=self.color, width=self.outline_width, outline=self.outline_color) class Color: GREY = (128, 128, 128, 255) RED = (255, 0, 0, 255) GREEN = (0, 255, 0, 255) BLUE = (0, 0, 255, 255) YELLOW = (255, 255, 0, 255) PURPLE = (255, 0, 255, 255) CYAN = (0, 255, 255, 255) WHITE = (255, 255, 255, 255) BLACK = (0, 0, 0, 255) @staticmethod def hex2dec(colorHex: str) -> Tuple[int, int, int, int]: """ :param colorHex: FFFFFFFF (ARGB)-> (R, G, B, A) :return: """ return int(colorHex[2:4], 16), int(colorHex[4:6], 16), int(colorHex[6:8], 16), int(colorHex[0:2], 16) class Shape: @staticmethod def circular(radius: int, fill: tuple, width: int = 0, outline: tuple = Color.BLACK) -> Image.Image: """ :param radius: 半径(像素) :param fill: 填充颜色 :param width: 轮廓粗细(像素) :param outline: 轮廓颜色 :return: 圆形Image对象 """ img = Image.new("RGBA", (radius * 2, radius * 2), color=radius) draw = ImageDraw.Draw(img) draw.ellipse(xy=(0, 0, radius * 2, radius * 2), fill=fill, outline=outline, width=width) return img @staticmethod def rectangle(size: Tuple[int, int], fill: tuple, width: int = 0, outline: tuple = Color.BLACK, fillet: int = 0) -> Image.Image: """ :param fillet: 圆角半径(像素) :param size: 长宽(像素) :param fill: 填充颜色 :param width: 轮廓粗细(像素) :param outline: 轮廓颜色 :return: 矩形Image对象 """ img = Image.new("RGBA", size, color=fill) draw = ImageDraw.Draw(img) draw.rounded_rectangle(xy=(0, 0, size[0], size[1]), fill=fill, outline=outline, width=width, radius=fillet) return img @staticmethod def ellipse(size: Tuple[int, int], fill: tuple, outline: int = 0, outline_color: tuple = Color.BLACK) -> Image.Image: """ :param size: 长宽(像素) :param fill: 填充颜色 :param outline: 轮廓粗细(像素) :param outline_color: 轮廓颜色 :return: 椭圆Image对象 """ img = Image.new("RGBA", size, color=fill) draw = ImageDraw.Draw(img) draw.ellipse(xy=(0, 0, size[0], size[1]), fill=fill, outline=outline_color, width=outline) return img @staticmethod def polygon(points: List[Tuple[int, int]], fill: tuple, outline: int, outline_color: tuple) -> Image.Image: """ :param points: 多边形顶点列表 :param fill: 填充颜色 :param outline: 轮廓粗细(像素) :param outline_color: 轮廓颜色 :return: 多边形Image对象 """ img = Image.new("RGBA", (max(points)[0], max(points)[1]), color=fill) draw = ImageDraw.Draw(img) draw.polygon(xy=points, fill=fill, outline=outline_color, width=outline) return img @staticmethod def line(points: List[Tuple[int, int]], fill: tuple, width: int) -> Image: """ :param points: 线段顶点列表 :param fill: 填充颜色 :param width: 线段粗细(像素) :return: 线段Image对象 """ img = Image.new("RGBA", (max(points)[0], max(points)[1]), color=fill) draw = ImageDraw.Draw(img) draw.line(xy=points, fill=fill, width=width) return img class Utils: @staticmethod def central_clip_by_ratio(img: Image.Image, size: Tuple, use_cache=True): """ :param use_cache: 是否使用缓存,剪切过一次后默认生成缓存 :param img: :param size: 仅为比例,满填充裁剪 :return: """ cache_file_path = str() if use_cache: filename_without_end = ".".join(os.path.basename(img.fp.name).split(".")[0:-1]) + f"_{size[0]}x{size[1]}" + ".png" cache_file_path = os.path.join(".cache", filename_without_end) if os.path.exists(cache_file_path): nonebot.logger.info("本次使用缓存加载图片,不裁剪") return Image.open(os.path.join(".cache", filename_without_end)) img_ratio = img.size[0] / img.size[1] limited_ratio = size[0] / size[1] if limited_ratio > img_ratio: actual_size = ( img.size[0], img.size[0] / size[0] * size[1] ) box = ( 0, (img.size[1] - actual_size[1]) // 2, img.size[0], img.size[1] - (img.size[1] - actual_size[1]) // 2 ) else: actual_size = ( img.size[1] / size[1] * size[0], img.size[1], ) box = ( (img.size[0] - actual_size[0]) // 2, 0, img.size[0] - (img.size[0] - actual_size[0]) // 2, img.size[1] ) img = img.crop(box).resize(size) if use_cache: img.save(cache_file_path) return img @staticmethod def circular_clip(img: Image.Image): """ 裁剪为alpha圆形 :param img: :return: """ length = min(img.size) alpha_cover = Image.new("RGBA", (length, length), color=(0, 0, 0, 0)) if img.size[0] > img.size[1]: box = ( (img.size[0] - img[1]) // 2, 0, (img.size[0] - img[1]) // 2 + img.size[1], img.size[1] ) else: box = ( 0, (img.size[1] - img.size[0]) // 2, img.size[0], (img.size[1] - img.size[0]) // 2 + img.size[0] ) img = img.crop(box).resize((length, length)) draw = ImageDraw.Draw(alpha_cover) draw.ellipse(xy=(0, 0, length, length), fill=(255, 255, 255, 255)) alpha = alpha_cover.split()[-1] img.putalpha(alpha) return img @staticmethod def open_img(path) -> Image.Image: return Image.open(path, "RGBA")