├── .github └── workflows │ └── publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── pil_utils ├── __init__.py ├── build_image.py ├── gradient.py ├── text2image.py ├── typing.py └── utils.py ├── poetry.lock └── pyproject.toml /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | 14 | - name: Publish python package 15 | uses: JRubics/poetry-publish@v1.16 16 | with: 17 | pypi_token: ${{ secrets.PYPI_TOKEN }} 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *__pycache__/ 2 | dist/ 3 | .vscode/ 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 MeetWq 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## pil-utils 2 | 3 | ### 功能 4 | 5 | - 提供 `BuildImage` 类,方便图片尺寸修改、添加文字等操作 6 | - 提供 `Text2Image` 类,方便实现文字转图,支持少量 `BBCode` 标签 7 | 8 | 9 | ### 安装 10 | 11 | 使用 pip 安装: 12 | ``` 13 | pip install pil-utils 14 | ``` 15 | 16 | 插件依赖 [skia-python](https://github.com/kyamagu/skia-python) 来绘制文字,对于 Linux 平台,需要安装 OpenGL 和 fontconfig: 17 | ``` 18 | apt-get install libfontconfig1 libgl1-mesa-glx libgl1-mesa-dri 19 | ``` 20 | 或: 21 | ``` 22 | yum install fontconfig mesa-libGL mesa-dri-drivers 23 | ``` 24 | 25 | 具体安装说明请参考 [skia-python 文档](https://kyamagu.github.io/skia-python/install.html) 26 | 27 | ### 已知问题 28 | 29 | - Windows 上 `SkIcuLoader: datafile missing` 30 | 31 | 由于 skia 在 Windows 上需要加载 `icudtl.dat` 文件,临时解决办法是手动将缺失的 `icudtl.dat` 文件放到 Python 环境里 32 | 33 | `icudtl.dat` 文件下载:https://github.com/MeetWq/pil-utils/releases/download/v0.2.0/icudtl.dat 34 | 35 | 请放置到 Python 包目录下,即 `Lib\site-packages` 文件夹下 36 | 37 | 相关 Issue:https://github.com/kyamagu/skia-python/issues/268 38 | 39 | - Windows 上运行时程序直接退出 40 | 41 | skia 使用了 C++17 的特性,需要安装 [Visual C++ 运行时](https://visualstudio.microsoft.com/zh-hans/downloads/?q=redistributable#microsoft-visual-c-redistributable-for-visual-studio-2022) 2017 以上版本 42 | 43 | - Linux 下字体异常 44 | 45 | 可能是 skia 的 bug,在 Linux 上当 locate 设置为中文时,字体选择会出现异常 46 | 47 | 临时解决办法是设置为英文 locate: 48 | ``` 49 | export LANG=en_US.UTF-8 50 | ``` 51 | 52 | 相关 Issue:https://github.com/rust-skia/rust-skia/issues/963 53 | 54 | 55 | ### 使用示例 56 | 57 | 58 | - `BuildImage` 59 | 60 | ```python 61 | from pil_utils import BuildImage 62 | 63 | # output: BytesIO 64 | output = BuildImage.new("RGBA", (200, 200), "grey").circle().draw_text((0, 0, 200, 200), "测试test😂").save_png() 65 | ``` 66 | 67 | ![](https://s2.loli.net/2024/11/01/MDIXRSlag3Ue1rQ.png) 68 | 69 | 70 | - `Text2Image` 71 | 72 | ```python 73 | from pil_utils import Text2Image 74 | 75 | # img: PIL.Image.Image 76 | img = Text2Image.from_text("@mnixry 🤗", 50).to_image(bg_color="white") 77 | ``` 78 | 79 | ![](https://s2.loli.net/2024/11/01/wv52WbyTqJRsadP.png) 80 | 81 | 82 | - 使用 `BBCode` 83 | 84 | ```python 85 | from pil_utils import text2image 86 | 87 | # img: PIL.Image.Image 88 | img = text2image("N[size=40][color=red]O[/color][/size]neBo[size=40][color=blue]T[/color][/size][align=center]太强啦[/align]") 89 | ``` 90 | 91 | ![](https://s2.loli.net/2024/11/01/wf7CtAa1WYuJRsQ.png) 92 | 93 | 94 | 目前支持的 `BBCode` 标签: 95 | - `[align=left|right|center][/align]`: 文字对齐方式 96 | - `[color=#66CCFF|red|black][/color]`: 字体颜色 97 | - `[stroke=#66CCFF|red|black][/stroke]`: 描边颜色 98 | - `[font=Microsoft YaHei][/font]`: 文字字体 99 | - `[size=30][/size]`: 文字大小 100 | - `[b][/b]`: 文字加粗 101 | - `[i][/i]`: 文字斜体 102 | - `[u][/u]`: 文字下划线 103 | - `[del][/del]`: 文字删除线 104 | 105 | ### 特别感谢 106 | 107 | - [HibiKier/zhenxun_bot](https://github.com/HibiKier/zhenxun_bot) 基于 Nonebot2 开发,非常可爱的绪山真寻bot 108 | - [kyamagu/skia-python](https://github.com/kyamagu/skia-python) Python binding to Skia Graphics Library 109 | -------------------------------------------------------------------------------- /pil_utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .build_image import BuildImage as BuildImage 2 | from .text2image import Text2Image as Text2Image 3 | from .text2image import text2image as text2image 4 | -------------------------------------------------------------------------------- /pil_utils/build_image.py: -------------------------------------------------------------------------------- 1 | import math 2 | from io import BytesIO 3 | from pathlib import Path 4 | from typing import Optional, Union 5 | 6 | import cv2 7 | import numpy as np 8 | from PIL import Image, ImageDraw 9 | from PIL.Image import Image as IMG 10 | from PIL.Image import Resampling, Transform, Transpose 11 | from PIL.ImageColor import getrgb 12 | from PIL.ImageDraw import ImageDraw as Draw 13 | from PIL.ImageFilter import Filter 14 | 15 | import skia 16 | 17 | from .gradient import Gradient 18 | from .text2image import DEFAULT_FALLBACK_FONTS, Text2Image 19 | from .typing import ( 20 | BoxType, 21 | ColorType, 22 | DirectionType, 23 | DistortType, 24 | FontStyle, 25 | HAlignType, 26 | ModeType, 27 | PointsType, 28 | PosTypeFloat, 29 | PosTypeInt, 30 | SizeType, 31 | SkiaFontStyle, 32 | SkiaPaint, 33 | SkiaTextAlign, 34 | VAlignType, 35 | XYType, 36 | ) 37 | 38 | 39 | class BuildImage: 40 | def __init__(self, image: IMG): 41 | self.image = image 42 | 43 | @property 44 | def width(self) -> int: 45 | return self.image.width 46 | 47 | @property 48 | def height(self) -> int: 49 | return self.image.height 50 | 51 | @property 52 | def size(self) -> SizeType: 53 | return self.image.size 54 | 55 | @property 56 | def mode(self) -> ModeType: 57 | return self.image.mode # type: ignore 58 | 59 | @property 60 | def draw(self) -> Draw: 61 | return ImageDraw.Draw(self.image) 62 | 63 | @classmethod 64 | def new( 65 | cls, mode: ModeType, size: SizeType, color: Optional[ColorType] = None 66 | ) -> "BuildImage": 67 | return cls(Image.new(mode, size, color)) # type: ignore 68 | 69 | @classmethod 70 | def open(cls, file: Union[str, BytesIO, Path]) -> "BuildImage": 71 | return cls(Image.open(file)) 72 | 73 | def copy(self) -> "BuildImage": 74 | return BuildImage(self.image.copy()) 75 | 76 | def resize( 77 | self, 78 | size: SizeType, 79 | resample: Resampling = Resampling.LANCZOS, 80 | keep_ratio: bool = False, 81 | inside: bool = False, 82 | direction: DirectionType = "center", 83 | bg_color: Optional[ColorType] = None, 84 | **kwargs, 85 | ) -> "BuildImage": 86 | """ 87 | 调整图片尺寸 88 | 89 | :参数: 90 | * ``size``: 期望图片大小 91 | * ``keep_ratio``: 是否保持长宽比,默认为 `False` 92 | * ``inside``: `keep_ratio` 为 `True` 时, 93 | 若 `inside` 为 `True`, 94 | 则调整图片大小至包含于期望尺寸,不足部分设为指定颜色; 95 | 若 `inside` 为 `False`, 96 | 则调整图片大小至包含期望尺寸,超出部分裁剪 97 | * ``direction``: 调整图片大小时图片的方位;默认为居中 98 | * ``bg_color``: 不足部分设置的颜色 99 | """ 100 | width, height = size 101 | if keep_ratio: 102 | if inside: 103 | ratio = min(width / self.width, height / self.height) 104 | else: 105 | ratio = max(width / self.width, height / self.height) 106 | width = int(self.width * ratio) 107 | height = int(self.height * ratio) 108 | 109 | image = BuildImage( 110 | self.image.resize((width, height), resample=resample, **kwargs) 111 | ) 112 | 113 | if keep_ratio: 114 | image = image.resize_canvas(size, direction, bg_color) 115 | return image 116 | 117 | def resize_canvas( 118 | self, 119 | size: SizeType, 120 | direction: DirectionType = "center", 121 | bg_color: Optional[ColorType] = None, 122 | ) -> "BuildImage": 123 | """ 124 | 调整“画布”大小,超出部分裁剪,不足部分设为指定颜色 125 | 126 | :参数: 127 | * ``size``: 期望图片大小 128 | * ``direction``: 调整图片大小时图片的方位;默认为居中 129 | * ``bg_color``: 不足部分设置的颜色 130 | """ 131 | w, h = size 132 | x = int((w - self.width) / 2) 133 | y = int((h - self.height) / 2) 134 | if direction in ["north", "northwest", "northeast"]: 135 | y = 0 136 | elif direction in ["south", "southwest", "southeast"]: 137 | y = h - self.height 138 | if direction in ["west", "northwest", "southwest"]: 139 | x = 0 140 | elif direction in ["east", "northeast", "southeast"]: 141 | x = w - self.width 142 | image = BuildImage.new(self.mode, size, bg_color) 143 | image.paste(self.image, (x, y)) 144 | return image 145 | 146 | def resize_width(self, width: int, **kwargs) -> "BuildImage": 147 | """调整图片宽度,不改变长宽比""" 148 | return self.resize((width, int(self.height * width / self.width)), **kwargs) 149 | 150 | def resize_height(self, height: int, **kwargs) -> "BuildImage": 151 | """调整图片高度,不改变长宽比""" 152 | return self.resize((int(self.width * height / self.height), height), **kwargs) 153 | 154 | def rotate( 155 | self, 156 | angle: float, 157 | resample: Resampling = Resampling.BICUBIC, 158 | expand: bool = False, 159 | **kwargs, 160 | ) -> "BuildImage": 161 | """旋转图片""" 162 | image = BuildImage( 163 | self.image.rotate(angle, resample=resample, expand=expand, **kwargs) 164 | ) 165 | return image 166 | 167 | def square(self) -> "BuildImage": 168 | """将图片裁剪为方形""" 169 | length = min(self.width, self.height) 170 | return self.resize_canvas((length, length)) 171 | 172 | def circle(self) -> "BuildImage": 173 | """将图片裁剪为圆形""" 174 | image = self.square().image.convert("RGBA") 175 | skia_image = skia.Image.frombytes( 176 | image.convert("RGBA").tobytes(), 177 | image.size, # type: ignore 178 | skia.kRGBA_8888_ColorType, 179 | ) 180 | surface = skia.Surfaces.MakeRasterN32Premul(image.width, image.height) 181 | canvas = surface.getCanvas() 182 | canvas.clear(skia.Color4f.kTransparent) 183 | path = skia.Path() 184 | radius = image.width / 2 185 | path.addCircle(radius, radius, radius) 186 | canvas.clipPath(path, doAntiAlias=True) 187 | canvas.drawImage(skia_image, 0, 0) 188 | surface.flushAndSubmit() 189 | skia_image = surface.makeImageSnapshot() 190 | pil_image = Image.fromarray( 191 | skia_image.convert( 192 | colorType=skia.kRGBA_8888_ColorType, alphaType=skia.kUnpremul_AlphaType 193 | ) 194 | ).convert("RGBA") 195 | return BuildImage(pil_image) 196 | 197 | def circle_corner(self, r: float) -> "BuildImage": 198 | """将图片裁剪为圆角矩形""" 199 | image = self.image.convert("RGBA") 200 | skia_image = skia.Image.frombytes( 201 | image.convert("RGBA").tobytes(), 202 | image.size, # type: ignore 203 | skia.kRGBA_8888_ColorType, 204 | ) 205 | surface = skia.Surfaces.MakeRasterN32Premul(image.width, image.height) 206 | canvas = surface.getCanvas() 207 | canvas.clear(skia.Color4f.kTransparent) 208 | path = skia.Path() 209 | path.addRoundRect(skia.Rect.MakeWH(image.width, image.height), r, r) 210 | canvas.clipPath(path, doAntiAlias=True) 211 | canvas.drawImage(skia_image, 0, 0) 212 | surface.flushAndSubmit() 213 | skia_image = surface.makeImageSnapshot() 214 | pil_image = Image.fromarray( 215 | skia_image.convert( 216 | colorType=skia.kRGBA_8888_ColorType, alphaType=skia.kUnpremul_AlphaType 217 | ) 218 | ).convert("RGBA") 219 | return BuildImage(pil_image) 220 | 221 | def crop(self, box: BoxType) -> "BuildImage": 222 | """裁剪图片""" 223 | return BuildImage(self.image.crop(box)) 224 | 225 | def convert(self, mode: ModeType, **kwargs) -> "BuildImage": 226 | return BuildImage(self.image.convert(mode, **kwargs)) 227 | 228 | def paste( 229 | self, 230 | img: Union[IMG, "BuildImage"], 231 | pos: PosTypeInt = (0, 0), 232 | alpha: bool = False, 233 | below: bool = False, 234 | ) -> "BuildImage": 235 | """ 236 | 粘贴图片 237 | 238 | :参数: 239 | * ``img``: 待粘贴的图片 240 | * ``pos``: 粘贴位置 241 | * ``alpha``: 图片背景是否为透明 242 | * ``below``: 是否粘贴到底层 243 | """ 244 | if isinstance(img, BuildImage): 245 | img = img.image 246 | new_img = Image.new(self.mode, self.size) if below else self.image.copy() 247 | if alpha: 248 | img = img.convert("RGBA") 249 | new_img.paste(img, pos, mask=img) 250 | else: 251 | new_img.paste(img, pos) 252 | if below: 253 | new_img.paste(self.image, mask=self.image if self.mode == "RGBA" else None) 254 | self.image = new_img 255 | return self 256 | 257 | def alpha_composite( 258 | self, 259 | img: Union[IMG, "BuildImage"], 260 | dest: PosTypeInt = (0, 0), 261 | source: Union[PosTypeInt, BoxType] = (0, 0), 262 | ) -> "BuildImage": 263 | if isinstance(img, BuildImage): 264 | img = img.image 265 | return BuildImage(self.image.alpha_composite(img, dest=dest, source=source)) # type: ignore 266 | 267 | def filter(self, filter: Union[Filter, type[Filter]]) -> "BuildImage": 268 | """滤波""" 269 | return BuildImage(self.image.filter(filter)) 270 | 271 | def transpose(self, method: Transpose) -> "BuildImage": 272 | """变换""" 273 | return BuildImage(self.image.transpose(method)) 274 | 275 | def perspective(self, points: PointsType) -> "BuildImage": 276 | """ 277 | 透视变换 278 | 279 | :参数: 280 | * ``points``: 变换后点的位置,顺序依次为:左上->右上->右下->左下 281 | """ 282 | 283 | def find_coeffs(pa: PointsType, pb: PointsType): 284 | matrix = [] 285 | for p1, p2 in zip(pa, pb): 286 | matrix.append( 287 | [p1[0], p1[1], 1, 0, 0, 0, -p2[0] * p1[0], -p2[0] * p1[1]] 288 | ) 289 | matrix.append( 290 | [0, 0, 0, p1[0], p1[1], 1, -p2[1] * p1[0], -p2[1] * p1[1]] 291 | ) 292 | A = np.matrix(matrix, dtype=np.float32) 293 | B = np.array(pb).reshape(8) 294 | res = np.dot(np.linalg.inv(A.T * A) * A.T, B) 295 | return np.array(res).reshape(8) 296 | 297 | img_w, img_h = self.size 298 | points_w = [p[0] for p in points] 299 | points_h = [p[1] for p in points] 300 | new_w = int(max(points_w) - min(points_w)) 301 | new_h = int(max(points_h) - min(points_h)) 302 | p = ((0, 0), (img_w, 0), (img_w, img_h), (0, img_h)) 303 | coeffs = list(find_coeffs(points, p)) 304 | return BuildImage( 305 | self.image.transform( 306 | (new_w, new_h), Transform.PERSPECTIVE, coeffs, Resampling.BICUBIC 307 | ) 308 | ) 309 | 310 | def gradient_color(self, gradient: Gradient) -> "BuildImage": 311 | """ 312 | 渐变色 313 | 314 | :参数: 315 | * ``gradient``: 渐变对象 316 | """ 317 | return BuildImage(gradient.create_image(self.size)) 318 | 319 | def motion_blur(self, angle: float = 0, degree: int = 0) -> "BuildImage": 320 | """ 321 | 运动模糊 322 | 323 | :参数: 324 | * ``angle``: 运动方向 325 | * ``degree``: 模糊程度 326 | """ 327 | if degree == 0: 328 | return self.copy() 329 | matrix = cv2.getRotationMatrix2D((degree / 2, degree / 2), angle + 45, 1) 330 | kernel = np.diag(np.ones(degree)) 331 | kernel = cv2.warpAffine(kernel, matrix, (degree, degree)) / degree # type: ignore 332 | blurred = cv2.filter2D(np.asarray(self.image), -1, kernel) 333 | cv2.normalize(blurred, blurred, 0, 255, cv2.NORM_MINMAX) 334 | return BuildImage(Image.fromarray(np.array(blurred, dtype=np.uint8))) 335 | 336 | def distort(self, coefficients: DistortType) -> "BuildImage": 337 | """ 338 | 畸变 339 | 340 | :参数: 341 | * ``coefficients``: 畸变参数 342 | """ 343 | res = cv2.undistort( 344 | np.asarray(self.image), 345 | np.array([[100, 0, self.width / 2], [0, 100, self.height / 2], [0, 0, 1]]), 346 | np.asarray(coefficients), 347 | ) 348 | return BuildImage(Image.fromarray(np.array(res, dtype=np.uint8))) 349 | 350 | def color_mask(self, color: ColorType) -> "BuildImage": 351 | """ 352 | 颜色滤镜,改变图片色调 353 | 354 | :参数: 355 | * ``color``: 目标颜色 356 | """ 357 | img = self.image.convert("RGB") 358 | w, h = img.size 359 | img_array = np.asarray(img) 360 | img_gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY) 361 | img_hsl = cv2.cvtColor(img_array, cv2.COLOR_RGB2HLS) 362 | img_new = np.zeros((h, w, 3), np.uint8) 363 | 364 | if isinstance(color, str): 365 | color = getrgb(color) 366 | r = color[0] 367 | g = color[1] 368 | b = color[2] 369 | rgb_sum = sum(color) 370 | for i in range(h): 371 | for j in range(w): 372 | value = int(img_gray[i, j]) 373 | new_color = ( 374 | [ 375 | int(value * r / rgb_sum), 376 | int(value * g / rgb_sum), 377 | int(value * b / rgb_sum), 378 | ] 379 | if rgb_sum 380 | else [0, 0, 0] 381 | ) 382 | img_new[i, j] = new_color 383 | img_new_hsl = cv2.cvtColor(img_new, cv2.COLOR_RGB2HLS) 384 | result = np.dstack( 385 | (img_new_hsl[:, :, 0], img_hsl[:, :, 1], img_new_hsl[:, :, 2]) 386 | ) 387 | result = cv2.cvtColor(result, cv2.COLOR_HLS2RGB) 388 | return BuildImage(Image.fromarray(result)) 389 | 390 | def draw_point( 391 | self, pos: PosTypeFloat, fill: Optional[ColorType] = None 392 | ) -> "BuildImage": 393 | """在图片上画点""" 394 | self.draw.point(pos, fill=fill) 395 | return self 396 | 397 | def draw_line( 398 | self, 399 | xy: XYType, 400 | fill: Optional[ColorType] = None, 401 | width: int = 1, 402 | ) -> "BuildImage": 403 | """在图片上画直线""" 404 | self.draw.line(xy, fill=fill, width=width) 405 | return self 406 | 407 | def draw_rectangle( 408 | self, 409 | xy: XYType, 410 | fill: Optional[ColorType] = None, 411 | outline: Optional[ColorType] = None, 412 | width: int = 1, 413 | ) -> "BuildImage": 414 | """在图片上画矩形""" 415 | self.draw.rectangle(xy, fill, outline, width) 416 | return self 417 | 418 | def draw_rounded_rectangle( 419 | self, 420 | xy: XYType, 421 | radius: int = 0, 422 | fill: Optional[ColorType] = None, 423 | outline: Optional[ColorType] = None, 424 | width: int = 1, 425 | ) -> "BuildImage": 426 | """在图片上画圆角矩形""" 427 | self.draw.rounded_rectangle(xy, radius, fill, outline, width) 428 | return self 429 | 430 | def draw_polygon( 431 | self, 432 | xy: list[PosTypeFloat], 433 | fill: Optional[ColorType] = None, 434 | outline: Optional[ColorType] = None, 435 | width: int = 1, 436 | ) -> "BuildImage": 437 | """在图片上画多边形""" 438 | self.draw.polygon(xy, fill, outline, width) 439 | return self 440 | 441 | def draw_arc( 442 | self, 443 | xy: XYType, 444 | start: float, 445 | end: float, 446 | fill: Optional[ColorType] = None, 447 | width: int = 1, 448 | ) -> "BuildImage": 449 | """在图片上画圆弧""" 450 | self.draw.arc(xy, start, end, fill, width) 451 | return self 452 | 453 | def draw_ellipse( 454 | self, 455 | xy: XYType, 456 | fill: Optional[ColorType] = None, 457 | outline: Optional[ColorType] = None, 458 | width: int = 1, 459 | ) -> "BuildImage": 460 | """在图片上画圆""" 461 | self.draw.ellipse(xy, fill, outline, width) 462 | return self 463 | 464 | def draw_text( 465 | self, 466 | xy: Union[PosTypeFloat, XYType], 467 | text: str, 468 | *, 469 | font_size: int = 16, 470 | max_fontsize: int = 30, 471 | min_fontsize: int = 12, 472 | allow_wrap: bool = False, 473 | font_style: Union[FontStyle, SkiaFontStyle] = "normal", 474 | fill: Union[ColorType, SkiaPaint] = "black", 475 | halign: HAlignType = "center", 476 | valign: VAlignType = "center", 477 | lines_align: Union[HAlignType, SkiaTextAlign] = "left", 478 | stroke_ratio: float = 0.02, 479 | stroke_fill: Optional[ColorType] = None, 480 | font_families: list[str] = [], 481 | fallback_fonts_families: list[str] = DEFAULT_FALLBACK_FONTS, 482 | ) -> "BuildImage": 483 | """ 484 | 在图片上指定区域画文字 485 | 486 | :参数: 487 | * ``xy``: 文字位置或文字区域; 488 | 传入 4 个参数时为文字区域,顺序依次为 左,上,右,下 489 | * ``text``: 文字,支持多行 490 | * ``font_size``: 字体大小 491 | * ``max_fontsize``: 允许的最大字体大小 492 | * ``min_fontsize``: 允许的最小字体大小 493 | * ``allow_wrap``: 是否允许折行 494 | * ``font_style``: 字体样式,默认为 "normal" 495 | * ``fill``: 文字颜色,默认为 `black` 496 | * ``halign``: 横向对齐方式,默认为 `center` 497 | * ``valign``: 纵向对齐方式,默认为 `center` 498 | * ``lines_align``: 多行文字对齐方式,默认为 `left` 499 | * ``stroke_ratio``: 文字描边的比例,即 描边宽度 / 字体大小 500 | * ``stroke_fill``: 描边颜色 501 | * ``font_families``: 指定首选字体 502 | * ``fallback_fonts_families``: 指定备选字体 503 | """ 504 | 505 | if len(xy) == 2: 506 | text2img = Text2Image.from_text( 507 | text, 508 | font_size, 509 | font_style=font_style, 510 | fill=fill, 511 | align=lines_align, 512 | stroke_width=round(font_size * stroke_ratio), 513 | stroke_fill=stroke_fill, 514 | font_families=font_families, 515 | fallback_fonts_families=fallback_fonts_families, 516 | ) 517 | text2img.draw_on_image(self.image, xy) 518 | return self 519 | 520 | left = xy[0] 521 | top = xy[1] 522 | width = xy[2] - xy[0] 523 | height = xy[3] - xy[1] 524 | font_size = max_fontsize 525 | while True: 526 | text2img = Text2Image.from_text( 527 | text, 528 | font_size, 529 | font_style=font_style, 530 | fill=fill, 531 | align=lines_align, 532 | stroke_width=round(font_size * stroke_ratio), 533 | stroke_fill=stroke_fill, 534 | font_families=font_families, 535 | fallback_fonts_families=fallback_fonts_families, 536 | ) 537 | text_w = text2img.longest_line 538 | text2img.wrap(math.ceil(text_w)) 539 | text_h = text2img.height 540 | if text_w > width and allow_wrap: 541 | text2img.wrap(width) 542 | text_w = text2img.longest_line 543 | text_h = text2img.height 544 | if text_w > width or text_h > height: 545 | font_size -= 1 546 | if font_size < min_fontsize: 547 | raise ValueError("在指定的区域内画不下这段文字") 548 | else: 549 | x = left # "left" 550 | if halign == "center": 551 | x += (width - text_w) / 2 552 | elif halign == "right": 553 | x += width - text_w 554 | 555 | y = top # "top" 556 | if valign == "center": 557 | y += (height - text_h) / 2 558 | elif valign == "bottom": 559 | y += height - text_h 560 | 561 | text2img.draw_on_image(self.image, (x, y)) 562 | return self 563 | 564 | def draw_bbcode_text( 565 | self, 566 | xy: Union[PosTypeFloat, XYType], 567 | text: str, 568 | *, 569 | font_size: int = 16, 570 | max_fontsize: int = 30, 571 | min_fontsize: int = 12, 572 | allow_wrap: bool = False, 573 | fill: ColorType = "black", 574 | halign: HAlignType = "center", 575 | valign: VAlignType = "center", 576 | lines_align: HAlignType = "left", 577 | stroke_ratio: float = 0.02, 578 | stroke_fill: Optional[ColorType] = None, 579 | font_families: list[str] = [], 580 | fallback_fonts_families: list[str] = DEFAULT_FALLBACK_FONTS, 581 | ) -> "BuildImage": 582 | """ 583 | 在图片上指定区域画文字 584 | 585 | :参数: 586 | * ``xy``: 文字位置或文字区域; 587 | 传入 4 个参数时为文字区域,顺序依次为 左,上,右,下 588 | * ``text``: 文字,支持多行 589 | * ``font_size``: 字体大小 590 | * ``max_fontsize``: 允许的最大字体大小 591 | * ``min_fontsize``: 允许的最小字体大小 592 | * ``allow_wrap``: 是否允许折行 593 | * ``fill``: 文字颜色,默认为 `black` 594 | * ``halign``: 横向对齐方式,默认为 `center` 595 | * ``valign``: 纵向对齐方式,默认为 `center` 596 | * ``lines_align``: 多行文字对齐方式,默认为 `left` 597 | * ``stroke_ratio``: 文字描边的比例,即 描边宽度 / 字体大小 598 | * ``stroke_fill``: 描边颜色 599 | * ``font_families``: 指定首选字体 600 | * ``fallback_fonts_families``: 指定备选字体 601 | """ 602 | 603 | if len(xy) == 2: 604 | text2img = Text2Image.from_bbcode_text( 605 | text, 606 | font_size, 607 | fill=fill, 608 | align=lines_align, 609 | stroke_ratio=stroke_ratio, 610 | stroke_fill=stroke_fill, 611 | font_families=font_families, 612 | fallback_fonts_families=fallback_fonts_families, 613 | ) 614 | text2img.draw_on_image(self.image, xy) 615 | return self 616 | 617 | left = xy[0] 618 | top = xy[1] 619 | width = xy[2] - xy[0] 620 | height = xy[3] - xy[1] 621 | font_size = max_fontsize 622 | while True: 623 | text2img = Text2Image.from_bbcode_text( 624 | text, 625 | font_size, 626 | fill=fill, 627 | align=lines_align, 628 | stroke_ratio=stroke_ratio, 629 | stroke_fill=stroke_fill, 630 | font_families=font_families, 631 | fallback_fonts_families=fallback_fonts_families, 632 | ) 633 | text_w = text2img.longest_line 634 | text2img.wrap(math.ceil(text_w)) 635 | text_h = text2img.height 636 | if text_w > width and allow_wrap: 637 | text2img.wrap(width) 638 | text_w = text2img.longest_line 639 | text_h = text2img.height 640 | if text_w > width or text_h > height: 641 | font_size -= 1 642 | if font_size < min_fontsize: 643 | raise ValueError("在指定的区域内画不下这段文字") 644 | else: 645 | x = left # "left" 646 | if halign == "center": 647 | x += (width - text_w) / 2 648 | elif halign == "right": 649 | x += width - text_w 650 | 651 | y = top # "top" 652 | if valign == "center": 653 | y += (height - text_h) / 2 654 | elif valign == "bottom": 655 | y += height - text_h 656 | 657 | text2img.draw_on_image(self.image, (x, y)) 658 | return self 659 | 660 | def save(self, format: str, **params) -> BytesIO: 661 | output = BytesIO() 662 | self.image.save(output, format, **params) 663 | return output 664 | 665 | def save_jpg(self, bg_color: ColorType = "white") -> BytesIO: 666 | """ 667 | 保存图片为 jpg 格式 668 | 669 | :参数: 670 | * ``bg_color``: 由 png 转为 jpg 时的背景颜色,默认为白色 671 | """ 672 | if self.mode == "RGBA": 673 | img = self.new("RGBA", self.size, bg_color) 674 | img.paste(self.image, alpha=True) 675 | else: 676 | img = self 677 | return img.convert("RGB").save("jpeg") 678 | 679 | def save_png(self) -> BytesIO: 680 | """保存图片为 png 格式""" 681 | return self.convert("RGBA").save("png") 682 | -------------------------------------------------------------------------------- /pil_utils/gradient.py: -------------------------------------------------------------------------------- 1 | from PIL import Image 2 | from PIL.Image import Image as IMG 3 | 4 | import skia 5 | 6 | from .typing import ColorType, SizeType, SkiaPaint, XYType 7 | from .utils import to_skia_color 8 | 9 | 10 | class ColorStop: 11 | def __init__(self, stop: float, color: "ColorType"): 12 | self.stop = stop 13 | """介于 0.0 与 1.0 之间的值,表示渐变中开始与结束之间的位置""" 14 | self.color = color 15 | """在 stop 位置显示的颜色值""" 16 | 17 | def __lt__(self, other: "ColorStop"): 18 | return self.stop < other.stop 19 | 20 | 21 | class Gradient: 22 | def __init__(self, color_stops: list[ColorStop] = []): 23 | self.color_stops = color_stops 24 | self.color_stops.sort() 25 | 26 | def add_color_stop(self, stop: float, color: "ColorType"): 27 | self.color_stops.append(ColorStop(stop, color)) 28 | self.color_stops.sort() 29 | 30 | def create_image(self, size: "SizeType") -> IMG: 31 | raise NotImplementedError 32 | 33 | def create_paint(self) -> SkiaPaint: 34 | raise NotImplementedError 35 | 36 | 37 | class LinearGradient(Gradient): 38 | def __init__(self, xy: "XYType", color_stops: list[ColorStop] = []): 39 | self.xy = xy 40 | self.x0 = xy[0] 41 | """渐变开始点的 x 坐标""" 42 | self.y0 = xy[1] 43 | """渐变开始点的 y 坐标""" 44 | self.x1 = xy[2] 45 | """渐变结束点的 x 坐标""" 46 | self.y1 = xy[3] 47 | """渐变结束点的 y 坐标""" 48 | super().__init__(color_stops) 49 | 50 | def create_image(self, size: "SizeType") -> IMG: 51 | surface = skia.Surfaces.MakeRasterN32Premul(size[0], size[1]) 52 | canvas = surface.getCanvas() 53 | canvas.clear(skia.Color4f.kTransparent) 54 | paint = self.create_paint() 55 | canvas.drawPaint(paint) 56 | surface.flushAndSubmit() 57 | skia_image = surface.makeImageSnapshot() 58 | pil_image = Image.fromarray( 59 | skia_image.convert( 60 | colorType=skia.kRGBA_8888_ColorType, alphaType=skia.kUnpremul_AlphaType 61 | ) 62 | ).convert("RGBA") 63 | return pil_image 64 | 65 | def create_paint(self) -> SkiaPaint: 66 | paint = skia.Paint() 67 | paint.setShader( 68 | skia.GradientShader.MakeLinear( 69 | points=[skia.Point(self.x0, self.y0), skia.Point(self.x1, self.y1)], 70 | colors=[int(to_skia_color(stop.color)) for stop in self.color_stops], 71 | positions=[stop.stop for stop in self.color_stops], 72 | ) 73 | ) 74 | return paint 75 | 76 | 77 | if __name__ == "__main__": 78 | img = LinearGradient( 79 | (0, 0, 255, 255), 80 | [ 81 | ColorStop(0, "red"), 82 | ColorStop(0.1, "orange"), 83 | ColorStop(0.25, "yellow"), 84 | ColorStop(0.4, "green"), 85 | ColorStop(0.6, "blue"), 86 | ColorStop(0.8, "cyan"), 87 | ColorStop(1, "purple"), 88 | ], 89 | ).create_image((255, 255)) 90 | img.save("test.png") 91 | -------------------------------------------------------------------------------- /pil_utils/text2image.py: -------------------------------------------------------------------------------- 1 | import math 2 | import re 3 | from dataclasses import dataclass 4 | from typing import Optional, Union 5 | 6 | from bbcode import Parser 7 | from PIL import Image 8 | from PIL.Image import Image as IMG 9 | from PIL.ImageColor import colormap 10 | 11 | import skia 12 | from skia import textlayout 13 | 14 | from .typing import ( 15 | BoxType, 16 | ColorType, 17 | FontStyle, 18 | HAlignType, 19 | PosTypeFloat, 20 | SizeType, 21 | SkiaFontStyle, 22 | SkiaPaint, 23 | SkiaParagraph, 24 | SkiaTextAlign, 25 | ) 26 | from .utils import to_skia_color, to_skia_font_style, to_skia_text_align 27 | 28 | DEFAULT_FALLBACK_FONTS: list[str] = [ 29 | "Arial", 30 | "Tahoma", 31 | "Helvetica Neue", 32 | "Segoe UI", 33 | "PingFang SC", 34 | "Hiragino Sans GB", 35 | "Microsoft YaHei", 36 | "Source Han Sans SC", 37 | "Noto Sans SC", 38 | "Noto Sans CJK SC", 39 | "WenQuanYi Micro Hei", 40 | "Apple Color Emoji", 41 | "Noto Color Emoji", 42 | "Segoe UI Emoji", 43 | "Segoe UI Symbol", 44 | ] 45 | 46 | font_collection = textlayout.FontCollection() 47 | font_collection.setDefaultFontManager(skia.FontMgr()) 48 | 49 | ALIGN_PATTERN = re.compile(r"left|right|center") 50 | css_colors = "|".join(colormap.keys()) 51 | COLOR_PATTERN = re.compile(rf"#[a-fA-F0-9]{{6}}|{css_colors}") 52 | STROKE_PATTERN = COLOR_PATTERN 53 | FONT_PATTERN = re.compile(r".+") 54 | SIZE_PATTERN = re.compile(r"\d+") 55 | 56 | 57 | @dataclass 58 | class Paragraph: 59 | paragraph: SkiaParagraph 60 | stroke_paragraph: Optional[SkiaParagraph] 61 | align: HAlignType 62 | 63 | @property 64 | def longest_line(self) -> float: 65 | return self.paragraph.LongestLine 66 | 67 | @property 68 | def height(self) -> float: 69 | return self.paragraph.Height 70 | 71 | def wrap(self, width: float): 72 | self.paragraph.layout(width) 73 | if self.stroke_paragraph: 74 | self.stroke_paragraph.layout(width) 75 | return self 76 | 77 | 78 | class Text2Image: 79 | def __init__(self, paragraphs: list[Paragraph]): 80 | self.paragraphs = paragraphs 81 | 82 | @classmethod 83 | def from_text( 84 | cls, 85 | text: str, 86 | font_size: float, 87 | *, 88 | font_style: Union[FontStyle, SkiaFontStyle] = "normal", 89 | fill: Union[ColorType, SkiaPaint] = "black", 90 | align: Union[HAlignType, SkiaTextAlign] = "left", 91 | stroke_width: float = 0, 92 | stroke_fill: Optional[Union[ColorType, SkiaPaint]] = None, 93 | font_families: list[str] = [], 94 | fallback_fonts_families: list[str] = DEFAULT_FALLBACK_FONTS, 95 | ) -> "Text2Image": 96 | """ 97 | 从文本构建 `Text2Image` 对象 98 | 99 | :参数: 100 | * ``text``: 文本 101 | * ``font_size``: 字体大小 102 | * ``font_style``: 字体样式,默认为 `normal` 103 | * ``fill``: 文字颜色,默认为 `black` 104 | * ``align``: 多行文字对齐方式,默认为 `left` 105 | * ``stroke_width``: 文字描边宽度 106 | * ``stroke_fill``: 描边颜色 107 | * ``font_families``: 指定首选字体 108 | * ``fallback_fonts_families``: 指定备选字体 109 | """ 110 | 111 | para_style = textlayout.ParagraphStyle() 112 | if not isinstance(align, textlayout.TextAlign): 113 | align = to_skia_text_align(align) 114 | para_style.setTextAlign(align) 115 | 116 | if isinstance(fill, skia.Paint): 117 | paint = fill 118 | else: 119 | paint = skia.Paint() 120 | paint.setColor4f(to_skia_color(fill)) 121 | paint.setAntiAlias(True) 122 | 123 | style = textlayout.TextStyle() 124 | style.setFontSize(font_size) 125 | style.setForegroundPaint(paint) 126 | style.setFontFamilies(font_families + fallback_fonts_families) 127 | if not isinstance(font_style, skia.FontStyle): 128 | font_style = to_skia_font_style(font_style) 129 | style.setFontStyle(font_style) 130 | style.setLocale("en") 131 | 132 | builder = textlayout.ParagraphBuilder.make( 133 | para_style, font_collection, skia.Unicodes.ICU.Make() 134 | ) 135 | builder.pushStyle(style) 136 | builder.addText(text) 137 | paragraph = builder.Build() 138 | paragraph.layout(math.inf) 139 | 140 | stroke_paragraph = None 141 | if stroke_width and stroke_fill: 142 | if isinstance(stroke_fill, skia.Paint): 143 | stroke_paint = stroke_fill 144 | else: 145 | stroke_paint = skia.Paint() 146 | stroke_paint.setColor4f(to_skia_color(stroke_fill)) 147 | stroke_paint.setAntiAlias(True) 148 | stroke_paint.setStyle(skia.Paint.kStroke_Style) 149 | stroke_paint.setStrokeJoin(skia.Paint.kRound_Join) 150 | stroke_paint.setStrokeWidth(stroke_width * 2) 151 | 152 | stroke_style = textlayout.TextStyle() 153 | stroke_style.setFontSize(font_size) 154 | stroke_style.setForegroundPaint(stroke_paint) 155 | stroke_style.setFontFamilies(font_families + fallback_fonts_families) 156 | stroke_style.setFontStyle(font_style) 157 | stroke_style.setLocale("en") 158 | 159 | stroke_builder = textlayout.ParagraphBuilder.make( 160 | para_style, font_collection, skia.Unicodes.ICU.Make() 161 | ) 162 | stroke_builder.pushStyle(stroke_style) 163 | stroke_builder.addText(text) 164 | stroke_paragraph = stroke_builder.Build() 165 | stroke_paragraph.layout(math.inf) 166 | 167 | return cls([Paragraph(paragraph, stroke_paragraph, align)]) 168 | 169 | @classmethod 170 | def from_bbcode_text( 171 | cls, 172 | text: str, 173 | font_size: float, 174 | *, 175 | fill: ColorType = "black", 176 | align: HAlignType = "left", 177 | stroke_ratio: float = 0.02, 178 | stroke_fill: Optional[ColorType] = None, 179 | font_families: list[str] = [], 180 | fallback_fonts_families: list[str] = DEFAULT_FALLBACK_FONTS, 181 | ) -> "Text2Image": 182 | """ 183 | 从含有 `BBCode` 的文本构建 `Text2Image` 对象 184 | 185 | 目前支持的 `BBCode` 标签: 186 | * ``[align=left|right|center][/align]``: 文字对齐方式 187 | * ``[color=#66CCFF|red|black][/color]``: 字体颜色 188 | * ``[stroke=#66CCFF|red|black][/stroke]``: 描边颜色 189 | * ``[font=Microsoft YaHei][/font]``: 文字字体 190 | * ``[size=30][/size]``: 文字大小 191 | * ``[b][/b]``: 文字加粗 192 | * ``[i][/i]``: 文字斜体 193 | * ``[u][/u]``: 文字下划线 194 | * ``[del][/del]``: 文字删除线 195 | 196 | :参数: 197 | * ``text``: 文本 198 | * ``fontsize``: 字体大小 199 | * ``font_style``: 字体样式,默认为 `normal` 200 | * ``fill``: 文字颜色,默认为 `black` 201 | * ``align``: 多行文字对齐方式,默认为 `left` 202 | * ``stroke_ratio``: 文字描边的比例,即 描边宽度 / 字体大小 203 | * ``stroke_fill``: 描边颜色 204 | * ``font_families``: 指定首选字体 205 | * ``fallback_fonts_families``: 指定备选字体 206 | """ 207 | 208 | def new_builder(text_align: HAlignType) -> textlayout.ParagraphBuilder: # type: ignore 209 | para_style = textlayout.ParagraphStyle() 210 | para_style.setTextAlign(to_skia_text_align(text_align)) 211 | builder = textlayout.ParagraphBuilder.make( 212 | para_style, font_collection, skia.Unicodes.ICU.Make() 213 | ) 214 | return builder 215 | 216 | def new_style( 217 | text_color: ColorType, 218 | text_font: Optional[str], 219 | text_size: float, 220 | text_bold: bool, 221 | text_italic: bool, 222 | text_underline: bool, 223 | text_linethrough: bool, 224 | ) -> textlayout.TextStyle: # type: ignore 225 | paint = skia.Paint() 226 | paint.setAntiAlias(True) 227 | paint.setColor4f(to_skia_color(text_color)) 228 | 229 | style = textlayout.TextStyle() 230 | style.setFontSize(text_size) 231 | style.setForegroundPaint(paint) 232 | 233 | fonts = font_families + fallback_fonts_families 234 | if text_font: 235 | fonts.insert(0, text_font) 236 | style.setFontFamilies(fonts) 237 | style.setLocale("en") 238 | 239 | if text_bold and text_italic: 240 | text_style = skia.FontStyle.BoldItalic() 241 | elif text_bold: 242 | text_style = skia.FontStyle.Bold() 243 | elif text_italic: 244 | text_style = skia.FontStyle.Italic() 245 | else: 246 | text_style = skia.FontStyle.Normal() 247 | style.setFontStyle(text_style) 248 | 249 | if text_underline and text_linethrough: 250 | text_decoration = textlayout.TextDecoration.kUnderlineLineThrough 251 | elif text_underline: 252 | text_decoration = textlayout.TextDecoration.kUnderline 253 | elif text_linethrough: 254 | text_decoration = textlayout.TextDecoration.kLineThrough 255 | else: 256 | text_decoration = textlayout.TextDecoration.kNoDecoration 257 | style.setDecoration(text_decoration) 258 | style.setDecorationMode(textlayout.TextDecorationMode.kThrough) 259 | style.setDecorationColor(to_skia_color(text_color)) 260 | style.setDecorationThicknessMultiplier(1.5) 261 | 262 | return style 263 | 264 | def new_stroke_paint(text_stroke: ColorType, text_size: float) -> skia.Paint: 265 | paint = skia.Paint() 266 | paint.setAntiAlias(True) 267 | paint.setColor4f(to_skia_color(text_stroke)) 268 | paint.setStyle(skia.Paint.kStroke_Style) 269 | paint.setStrokeJoin(skia.Paint.kRound_Join) 270 | width = text_size * stroke_ratio 271 | paint.setStrokeWidth(width * 2) 272 | return paint 273 | 274 | paragraphs: list[Paragraph] = [] 275 | builder: Optional[ 276 | tuple[textlayout.ParagraphBuilder, textlayout.ParagraphBuilder] # type: ignore 277 | ] = None 278 | 279 | default_style = new_style(fill, None, font_size, False, False, False, False) 280 | 281 | align_stack: list[HAlignType] = [] 282 | color_stack: list[ColorType] = [] 283 | stroke_stack: list[ColorType] = [] 284 | font_stack: list[str] = [] 285 | size_stack: list[int] = [] 286 | bold_stack: list[bool] = [] 287 | italic_stack: list[bool] = [] 288 | underline_stack: list[bool] = [] 289 | linethrough_stack: list[bool] = [] 290 | last_align: HAlignType = align 291 | has_stroke: bool = False 292 | 293 | def build(): 294 | nonlocal builder 295 | nonlocal has_stroke 296 | if builder: 297 | paragraph = builder[0].Build() 298 | paragraph.layout(math.inf) 299 | stroke_paragraph = None 300 | if has_stroke: 301 | stroke_paragraph = builder[1].Build() 302 | stroke_paragraph.layout(math.inf) 303 | has_stroke = False 304 | builder = None 305 | paragraphs.append(Paragraph(paragraph, stroke_paragraph, last_align)) 306 | 307 | parser = Parser() 308 | parser.recognized_tags = {} 309 | parser.add_formatter("align", None) 310 | parser.add_formatter("color", None) 311 | parser.add_formatter("stroke", None) 312 | parser.add_formatter("font", None) 313 | parser.add_formatter("size", None) 314 | parser.add_formatter("b", None) 315 | parser.add_formatter("i", None) 316 | parser.add_formatter("u", None) 317 | parser.add_formatter("del", None) 318 | 319 | tokens = parser.tokenize(text) 320 | for token_type, tag_name, tag_opts, token_text in tokens: 321 | if token_type == 1: 322 | if tag_name == "align": 323 | if re.fullmatch(ALIGN_PATTERN, tag_opts["align"]): 324 | align_stack.append(tag_opts["align"]) 325 | elif tag_name == "color": 326 | if re.fullmatch(COLOR_PATTERN, tag_opts["color"]): 327 | color_stack.append(tag_opts["color"]) 328 | elif tag_name == "stroke": 329 | if re.fullmatch(STROKE_PATTERN, tag_opts["stroke"]): 330 | stroke_stack.append(tag_opts["stroke"]) 331 | elif tag_name == "font": 332 | if re.fullmatch(FONT_PATTERN, tag_opts["font"]): 333 | font_stack.append(tag_opts["font"]) 334 | elif tag_name == "size": 335 | if re.fullmatch(SIZE_PATTERN, tag_opts["size"]): 336 | size_stack.append(int(tag_opts["size"])) 337 | elif tag_name == "b": 338 | bold_stack.append(True) 339 | elif tag_name == "i": 340 | italic_stack.append(True) 341 | elif tag_name == "u": 342 | underline_stack.append(True) 343 | elif tag_name == "del": 344 | linethrough_stack.append(True) 345 | elif token_type == 2: 346 | if tag_name == "align": 347 | if align_stack: 348 | align_stack.pop() 349 | elif tag_name == "color": 350 | if color_stack: 351 | color_stack.pop() 352 | elif tag_name == "stroke": 353 | if stroke_stack: 354 | stroke_stack.pop() 355 | elif tag_name == "font": 356 | if font_stack: 357 | font_stack.pop() 358 | elif tag_name == "size": 359 | if size_stack: 360 | size_stack.pop() 361 | elif tag_name == "b": 362 | if bold_stack: 363 | bold_stack.pop() 364 | elif tag_name == "i": 365 | if italic_stack: 366 | italic_stack.pop() 367 | elif tag_name == "u": 368 | if underline_stack: 369 | underline_stack.pop() 370 | elif tag_name == "del": 371 | if linethrough_stack: 372 | linethrough_stack.pop() 373 | elif token_type == 3: 374 | if not builder: 375 | builder = (new_builder(align), new_builder(align)) 376 | builder[0].pushStyle(default_style) 377 | builder[1].pushStyle(default_style) 378 | builder[0].addText("\n") 379 | builder[1].addText("\n") 380 | elif token_type == 4: 381 | text_align = align_stack[-1] if align_stack else align 382 | text_color = color_stack[-1] if color_stack else fill 383 | text_stroke = stroke_stack[-1] if stroke_stack else stroke_fill 384 | text_font = font_stack[-1] if font_stack else None 385 | text_size = size_stack[-1] if size_stack else font_size 386 | text_bold = bold_stack[-1] if bold_stack else False 387 | text_italic = italic_stack[-1] if italic_stack else False 388 | text_underline = underline_stack[-1] if underline_stack else False 389 | text_linethrough = linethrough_stack[-1] if linethrough_stack else False 390 | 391 | if text_align != last_align: 392 | build() 393 | last_align = text_align 394 | 395 | if not token_text: 396 | continue 397 | 398 | if not builder: 399 | builder = (new_builder(text_align), new_builder(text_align)) 400 | builder[0].pushStyle(default_style) 401 | builder[1].pushStyle(default_style) 402 | style = new_style( 403 | text_color, 404 | text_font, 405 | text_size, 406 | text_bold, 407 | text_italic, 408 | text_underline, 409 | text_linethrough, 410 | ) 411 | stroke_style = new_style( 412 | text_color, 413 | text_font, 414 | text_size, 415 | text_bold, 416 | text_italic, 417 | text_underline, 418 | text_linethrough, 419 | ) 420 | if stroke_ratio and text_stroke: 421 | has_stroke = True 422 | stroke_paint = new_stroke_paint(text_stroke, text_size) 423 | stroke_style.setForegroundPaint(stroke_paint) 424 | builder[0].pop() 425 | builder[0].pushStyle(style) 426 | builder[0].addText(token_text) 427 | builder[1].pop() 428 | builder[1].pushStyle(stroke_style) 429 | builder[1].addText(token_text) 430 | 431 | build() 432 | 433 | return cls(paragraphs) 434 | 435 | @property 436 | def longest_line(self) -> float: 437 | if not self.paragraphs: 438 | return 0 439 | return max([para.longest_line for para in self.paragraphs]) 440 | 441 | @property 442 | def height(self) -> float: 443 | if not self.paragraphs: 444 | return 0 445 | return sum([para.height for para in self.paragraphs]) 446 | 447 | def wrap(self, width: float) -> "Text2Image": 448 | for para in self.paragraphs: 449 | para.wrap(width) 450 | return self 451 | 452 | def to_image( 453 | self, 454 | max_width: Optional[int] = None, 455 | bg_color: Optional[ColorType] = None, 456 | padding: Union[SizeType, BoxType] = (0, 0), 457 | ) -> IMG: 458 | if len(padding) == 4: 459 | padding_left, padding_top, padding_right, padding_bottom = padding 460 | else: 461 | padding_left = padding_right = padding[0] 462 | padding_top = padding_bottom = padding[1] 463 | 464 | if not max_width: 465 | max_width = math.ceil(self.longest_line) 466 | self.wrap(max_width) 467 | image_width = max_width + padding_left + padding_right 468 | image_height = math.ceil(self.height + padding_top + padding_bottom) 469 | 470 | surface = skia.Surfaces.MakeRasterN32Premul(image_width, image_height) 471 | canvas = surface.getCanvas() 472 | canvas.clear(to_skia_color(bg_color) if bg_color else skia.Color4f.kTransparent) 473 | 474 | x = padding_left 475 | y = padding_top 476 | for para in self.paragraphs: 477 | if para.stroke_paragraph: 478 | para.stroke_paragraph.paint(canvas, x, y) 479 | para.paragraph.paint(canvas, x, y) 480 | y += para.height 481 | 482 | surface.flushAndSubmit() 483 | skia_image = surface.makeImageSnapshot() 484 | pil_image = Image.fromarray( 485 | skia_image.convert( 486 | colorType=skia.kRGBA_8888_ColorType, alphaType=skia.kUnpremul_AlphaType 487 | ) 488 | ) 489 | 490 | return pil_image 491 | 492 | def draw_on_image( 493 | self, img: IMG, pos: PosTypeFloat, max_width: Optional[int] = None 494 | ): 495 | mode = img.mode 496 | image = skia.Image.frombytes( 497 | img.convert("RGBA").tobytes(), 498 | img.size, # type: ignore 499 | skia.kRGBA_8888_ColorType, 500 | ) 501 | surface = skia.Surfaces.MakeRasterN32Premul(image.width(), image.height()) 502 | canvas = surface.getCanvas() 503 | canvas.drawImage(image, 0, 0) 504 | 505 | if not max_width: 506 | max_width = math.ceil(self.longest_line) 507 | self.wrap(max_width) 508 | 509 | x = pos[0] 510 | y = pos[1] 511 | for para in self.paragraphs: 512 | if para.stroke_paragraph: 513 | para.stroke_paragraph.paint(canvas, x, y) 514 | para.paragraph.paint(canvas, x, y) 515 | y += para.height 516 | 517 | surface.flushAndSubmit() 518 | skia_image = surface.makeImageSnapshot() 519 | pil_image = Image.fromarray( 520 | skia_image.convert( 521 | colorType=skia.kRGBA_8888_ColorType, alphaType=skia.kUnpremul_AlphaType 522 | ) 523 | ).convert(mode) 524 | img.im = pil_image.im.copy() # type: ignore 525 | 526 | 527 | def text2image( 528 | text: str, 529 | *, 530 | font_size: float = 30, 531 | max_width: Optional[int] = None, 532 | bg_color: ColorType = "white", 533 | padding: Union[SizeType, BoxType] = (10, 10), 534 | **kwargs, 535 | ) -> IMG: 536 | """ 537 | 文字转图片,支持少量 `BBCode` 标签,具体见 `Text2Image` 类的 `from_bbcode_text` 函数 538 | 539 | :参数: 540 | * ``text``: 文本 541 | * ``fontsize``: 字体大小 542 | * ``max_width``: 图片中文字的最大宽度,不设置则不限宽度 543 | * ``bg_color``: 图片背景颜色 544 | * ``padding``: 图片边距 545 | """ 546 | text2img = Text2Image.from_bbcode_text(text, font_size, **kwargs) 547 | return text2img.to_image(max_width, bg_color, padding) 548 | -------------------------------------------------------------------------------- /pil_utils/typing.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Union 2 | 3 | from typing_extensions import TypeAlias 4 | 5 | import skia 6 | from skia import textlayout 7 | 8 | ModeType = Literal[ 9 | "1", "CMYK", "F", "HSV", "I", "L", "LAB", "P", "RGB", "RGBA", "RGBX", "YCbCr" 10 | ] 11 | ColorType = Union[str, tuple[int, int, int], tuple[int, int, int, int]] 12 | PosTypeFloat = tuple[float, float] 13 | PosTypeInt = tuple[int, int] 14 | XYType = tuple[float, float, float, float] 15 | BoxType = tuple[int, int, int, int] 16 | PointsType = tuple[PosTypeFloat, PosTypeFloat, PosTypeFloat, PosTypeFloat] 17 | DistortType = tuple[float, float, float, float] 18 | SizeType = tuple[int, int] 19 | HAlignType = Literal["left", "right", "center"] 20 | VAlignType = Literal["top", "bottom", "center"] 21 | DirectionType = Literal[ 22 | "center", 23 | "north", 24 | "south", 25 | "west", 26 | "east", 27 | "northwest", 28 | "northeast", 29 | "southwest", 30 | "southeast", 31 | ] 32 | FontStyle = Literal["normal", "italic", "bold", "bold_italic"] 33 | 34 | SkiaParagraph: TypeAlias = textlayout.Paragraph # type: ignore 35 | SkiaTextAlign: TypeAlias = textlayout.TextAlign # type: ignore 36 | SkiaFontStyle: TypeAlias = skia.FontStyle 37 | SkiaPaint: TypeAlias = skia.Paint 38 | -------------------------------------------------------------------------------- /pil_utils/utils.py: -------------------------------------------------------------------------------- 1 | from PIL.ImageColor import getrgb 2 | 3 | import skia 4 | from skia import textlayout 5 | 6 | from .typing import ColorType, FontStyle, HAlignType 7 | 8 | 9 | def to_skia_text_align(align: HAlignType) -> textlayout.TextAlign: # type: ignore 10 | if align == "center": 11 | return textlayout.TextAlign.kCenter 12 | elif align == "right": 13 | return textlayout.TextAlign.kRight 14 | return textlayout.TextAlign.kLeft 15 | 16 | 17 | def to_skia_color(color: ColorType) -> skia.Color4f: 18 | if isinstance(color, str): 19 | color = getrgb(color) 20 | if len(color) == 3: 21 | color = (color[0], color[1], color[2], 255) 22 | return skia.Color4f(color[0] / 255, color[1] / 255, color[2] / 255, color[3] / 255) 23 | 24 | 25 | def to_skia_font_style(font_style: FontStyle) -> skia.FontStyle: 26 | if font_style == "bold": 27 | return skia.FontStyle.Bold() 28 | elif font_style == "italic": 29 | return skia.FontStyle.Italic() 30 | elif font_style == "bold_italic": 31 | return skia.FontStyle.BoldItalic() 32 | return skia.FontStyle.Normal() 33 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "bbcode" 5 | version = "1.1.0" 6 | description = "A pure python bbcode parser and formatter." 7 | optional = false 8 | python-versions = "*" 9 | files = [ 10 | {file = "bbcode-1.1.0-py2.py3-none-any.whl", hash = "sha256:83802f4b40c92426841a98350bd6ff9ea8fdf8f9b37df1968a88c5864fd225fa"}, 11 | {file = "bbcode-1.1.0.tar.gz", hash = "sha256:eac4fb1d0f6c7ce5c41e4b5c0522562b15a1ac036fb9131adc59e9a28c7dc1d0"}, 12 | ] 13 | 14 | [[package]] 15 | name = "msvc-runtime" 16 | version = "14.42.34433" 17 | description = "Install the Microsoft™ Visual C++™ runtime DLLs to the sys.prefix and Scripts directories" 18 | optional = false 19 | python-versions = ">=3.5" 20 | files = [ 21 | {file = "msvc_runtime-14.42.34433-cp310-cp310-win32.whl", hash = "sha256:7211342b24025fd0294f7920814c5a5dc95a22fd03c8bc729ca980fb21df3f2b"}, 22 | {file = "msvc_runtime-14.42.34433-cp310-cp310-win_amd64.whl", hash = "sha256:89574bc376a48e349f97b6dde289367b0a32dedaf3774ef64ed4330e9823e09c"}, 23 | {file = "msvc_runtime-14.42.34433-cp311-cp311-win32.whl", hash = "sha256:a54c001e058ca22d88c30dc348d06fa704708ca9369464e52ed80b130bb47b4e"}, 24 | {file = "msvc_runtime-14.42.34433-cp311-cp311-win_amd64.whl", hash = "sha256:60ab9e5ca8ef3e230ead1a3db66340628aafc25f0c10d0195ced8f5a2e24883f"}, 25 | {file = "msvc_runtime-14.42.34433-cp311-cp311-win_arm64.whl", hash = "sha256:3a339f6dd8c8830743ef250a46683cb77b40633aaf1e5cf4032753f2c46f5afd"}, 26 | {file = "msvc_runtime-14.42.34433-cp312-cp312-win32.whl", hash = "sha256:791f60e9d8479065c6e9c6b1ae1d31555a6bb41a2e649f705bd96566a79bd1df"}, 27 | {file = "msvc_runtime-14.42.34433-cp312-cp312-win_amd64.whl", hash = "sha256:0f1c2733bf16fee37ab5f48a4c1cdf8f8bd26a945679a111e4219396e89a9049"}, 28 | {file = "msvc_runtime-14.42.34433-cp312-cp312-win_arm64.whl", hash = "sha256:f4ffbb6bdde4870f4995234423a46ac650cc2535c7812605bd457d064a77345e"}, 29 | {file = "msvc_runtime-14.42.34433-cp313-cp313-win32.whl", hash = "sha256:7d1fc5675c3d9769b3f05b9a57156211a053415f0dab938493c2d490c84187a4"}, 30 | {file = "msvc_runtime-14.42.34433-cp313-cp313-win_amd64.whl", hash = "sha256:087e2a7906b953970d93f37ea740605688eff616fa7ad42cc46e15137797e7e6"}, 31 | {file = "msvc_runtime-14.42.34433-cp313-cp313-win_arm64.whl", hash = "sha256:0d873db88d3027e3a9b2abf8b2104af96199a317f940802bce2d234dd4aa915c"}, 32 | {file = "msvc_runtime-14.42.34433-cp39-cp39-win32.whl", hash = "sha256:2adaf04bc5a7290b669fcc404a96750c1c6d8ffb8fe1037c264371bf67bca210"}, 33 | {file = "msvc_runtime-14.42.34433-cp39-cp39-win_amd64.whl", hash = "sha256:d4da7241349e9bf7d79346680f180b29bcc8aff242bd3de075529be71c097271"}, 34 | {file = "msvc_runtime-14.42.34433-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e934840ebbef53f51febfacdb4f39965060a818cee3429da9fcfd396c61595cf"}, 35 | ] 36 | 37 | [[package]] 38 | name = "numpy" 39 | version = "2.0.2" 40 | description = "Fundamental package for array computing in Python" 41 | optional = false 42 | python-versions = ">=3.9" 43 | files = [ 44 | {file = "numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece"}, 45 | {file = "numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04"}, 46 | {file = "numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66"}, 47 | {file = "numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b"}, 48 | {file = "numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd"}, 49 | {file = "numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318"}, 50 | {file = "numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8"}, 51 | {file = "numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326"}, 52 | {file = "numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97"}, 53 | {file = "numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131"}, 54 | {file = "numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448"}, 55 | {file = "numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195"}, 56 | {file = "numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57"}, 57 | {file = "numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a"}, 58 | {file = "numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669"}, 59 | {file = "numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951"}, 60 | {file = "numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9"}, 61 | {file = "numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15"}, 62 | {file = "numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4"}, 63 | {file = "numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc"}, 64 | {file = "numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b"}, 65 | {file = "numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e"}, 66 | {file = "numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c"}, 67 | {file = "numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c"}, 68 | {file = "numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692"}, 69 | {file = "numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a"}, 70 | {file = "numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c"}, 71 | {file = "numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded"}, 72 | {file = "numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5"}, 73 | {file = "numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a"}, 74 | {file = "numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c"}, 75 | {file = "numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd"}, 76 | {file = "numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b"}, 77 | {file = "numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729"}, 78 | {file = "numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1"}, 79 | {file = "numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd"}, 80 | {file = "numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d"}, 81 | {file = "numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d"}, 82 | {file = "numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa"}, 83 | {file = "numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73"}, 84 | {file = "numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8"}, 85 | {file = "numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4"}, 86 | {file = "numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c"}, 87 | {file = "numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385"}, 88 | {file = "numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78"}, 89 | ] 90 | 91 | [[package]] 92 | name = "opencv-python-headless" 93 | version = "4.10.0.84" 94 | description = "Wrapper package for OpenCV python bindings." 95 | optional = false 96 | python-versions = ">=3.6" 97 | files = [ 98 | {file = "opencv-python-headless-4.10.0.84.tar.gz", hash = "sha256:f2017c6101d7c2ef8d7bc3b414c37ff7f54d64413a1847d89970b6b7069b4e1a"}, 99 | {file = "opencv_python_headless-4.10.0.84-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:a4f4bcb07d8f8a7704d9c8564c224c8b064c63f430e95b61ac0bffaa374d330e"}, 100 | {file = "opencv_python_headless-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl", hash = "sha256:5ae454ebac0eb0a0b932e3406370aaf4212e6a3fdb5038cc86c7aea15a6851da"}, 101 | {file = "opencv_python_headless-4.10.0.84-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46071015ff9ab40fccd8a163da0ee14ce9846349f06c6c8c0f2870856ffa45db"}, 102 | {file = "opencv_python_headless-4.10.0.84-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:377d08a7e48a1405b5e84afcbe4798464ce7ee17081c1c23619c8b398ff18295"}, 103 | {file = "opencv_python_headless-4.10.0.84-cp37-abi3-win32.whl", hash = "sha256:9092404b65458ed87ce932f613ffbb1106ed2c843577501e5768912360fc50ec"}, 104 | {file = "opencv_python_headless-4.10.0.84-cp37-abi3-win_amd64.whl", hash = "sha256:afcf28bd1209dd58810d33defb622b325d3cbe49dcd7a43a902982c33e5fad05"}, 105 | ] 106 | 107 | [package.dependencies] 108 | numpy = [ 109 | {version = ">=1.21.0", markers = "python_version == \"3.9\" and platform_system == \"Darwin\" and platform_machine == \"arm64\""}, 110 | {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, 111 | {version = ">=1.23.5", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, 112 | {version = ">=1.21.4", markers = "python_version >= \"3.10\" and platform_system == \"Darwin\" and python_version < \"3.11\""}, 113 | {version = ">=1.21.2", markers = "platform_system != \"Darwin\" and python_version >= \"3.10\" and python_version < \"3.11\""}, 114 | {version = ">=1.19.3", markers = "platform_system == \"Linux\" and platform_machine == \"aarch64\" and python_version >= \"3.8\" and python_version < \"3.10\" or python_version > \"3.9\" and python_version < \"3.10\" or python_version >= \"3.9\" and platform_system != \"Darwin\" and python_version < \"3.10\" or python_version >= \"3.9\" and platform_machine != \"arm64\" and python_version < \"3.10\""}, 115 | ] 116 | 117 | [[package]] 118 | name = "pillow" 119 | version = "11.0.0" 120 | description = "Python Imaging Library (Fork)" 121 | optional = false 122 | python-versions = ">=3.9" 123 | files = [ 124 | {file = "pillow-11.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6619654954dc4936fcff82db8eb6401d3159ec6be81e33c6000dfd76ae189947"}, 125 | {file = "pillow-11.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b3c5ac4bed7519088103d9450a1107f76308ecf91d6dabc8a33a2fcfb18d0fba"}, 126 | {file = "pillow-11.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a65149d8ada1055029fcb665452b2814fe7d7082fcb0c5bed6db851cb69b2086"}, 127 | {file = "pillow-11.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88a58d8ac0cc0e7f3a014509f0455248a76629ca9b604eca7dc5927cc593c5e9"}, 128 | {file = "pillow-11.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c26845094b1af3c91852745ae78e3ea47abf3dbcd1cf962f16b9a5fbe3ee8488"}, 129 | {file = "pillow-11.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1a61b54f87ab5786b8479f81c4b11f4d61702830354520837f8cc791ebba0f5f"}, 130 | {file = "pillow-11.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:674629ff60030d144b7bca2b8330225a9b11c482ed408813924619c6f302fdbb"}, 131 | {file = "pillow-11.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:598b4e238f13276e0008299bd2482003f48158e2b11826862b1eb2ad7c768b97"}, 132 | {file = "pillow-11.0.0-cp310-cp310-win32.whl", hash = "sha256:9a0f748eaa434a41fccf8e1ee7a3eed68af1b690e75328fd7a60af123c193b50"}, 133 | {file = "pillow-11.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:a5629742881bcbc1f42e840af185fd4d83a5edeb96475a575f4da50d6ede337c"}, 134 | {file = "pillow-11.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:ee217c198f2e41f184f3869f3e485557296d505b5195c513b2bfe0062dc537f1"}, 135 | {file = "pillow-11.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1c1d72714f429a521d8d2d018badc42414c3077eb187a59579f28e4270b4b0fc"}, 136 | {file = "pillow-11.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:499c3a1b0d6fc8213519e193796eb1a86a1be4b1877d678b30f83fd979811d1a"}, 137 | {file = "pillow-11.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8b2351c85d855293a299038e1f89db92a2f35e8d2f783489c6f0b2b5f3fe8a3"}, 138 | {file = "pillow-11.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4dba50cfa56f910241eb7f883c20f1e7b1d8f7d91c750cd0b318bad443f4d5"}, 139 | {file = "pillow-11.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5ddbfd761ee00c12ee1be86c9c0683ecf5bb14c9772ddbd782085779a63dd55b"}, 140 | {file = "pillow-11.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:45c566eb10b8967d71bf1ab8e4a525e5a93519e29ea071459ce517f6b903d7fa"}, 141 | {file = "pillow-11.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b4fd7bd29610a83a8c9b564d457cf5bd92b4e11e79a4ee4716a63c959699b306"}, 142 | {file = "pillow-11.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb929ca942d0ec4fac404cbf520ee6cac37bf35be479b970c4ffadf2b6a1cad9"}, 143 | {file = "pillow-11.0.0-cp311-cp311-win32.whl", hash = "sha256:006bcdd307cc47ba43e924099a038cbf9591062e6c50e570819743f5607404f5"}, 144 | {file = "pillow-11.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:52a2d8323a465f84faaba5236567d212c3668f2ab53e1c74c15583cf507a0291"}, 145 | {file = "pillow-11.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:16095692a253047fe3ec028e951fa4221a1f3ed3d80c397e83541a3037ff67c9"}, 146 | {file = "pillow-11.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2c0a187a92a1cb5ef2c8ed5412dd8d4334272617f532d4ad4de31e0495bd923"}, 147 | {file = "pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:084a07ef0821cfe4858fe86652fffac8e187b6ae677e9906e192aafcc1b69903"}, 148 | {file = "pillow-11.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8069c5179902dcdce0be9bfc8235347fdbac249d23bd90514b7a47a72d9fecf4"}, 149 | {file = "pillow-11.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f02541ef64077f22bf4924f225c0fd1248c168f86e4b7abdedd87d6ebaceab0f"}, 150 | {file = "pillow-11.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fcb4621042ac4b7865c179bb972ed0da0218a076dc1820ffc48b1d74c1e37fe9"}, 151 | {file = "pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:00177a63030d612148e659b55ba99527803288cea7c75fb05766ab7981a8c1b7"}, 152 | {file = "pillow-11.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8853a3bf12afddfdf15f57c4b02d7ded92c7a75a5d7331d19f4f9572a89c17e6"}, 153 | {file = "pillow-11.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3107c66e43bda25359d5ef446f59c497de2b5ed4c7fdba0894f8d6cf3822dafc"}, 154 | {file = "pillow-11.0.0-cp312-cp312-win32.whl", hash = "sha256:86510e3f5eca0ab87429dd77fafc04693195eec7fd6a137c389c3eeb4cfb77c6"}, 155 | {file = "pillow-11.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8ec4a89295cd6cd4d1058a5e6aec6bf51e0eaaf9714774e1bfac7cfc9051db47"}, 156 | {file = "pillow-11.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:27a7860107500d813fcd203b4ea19b04babe79448268403172782754870dac25"}, 157 | {file = "pillow-11.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcd1fb5bb7b07f64c15618c89efcc2cfa3e95f0e3bcdbaf4642509de1942a699"}, 158 | {file = "pillow-11.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e038b0745997c7dcaae350d35859c9715c71e92ffb7e0f4a8e8a16732150f38"}, 159 | {file = "pillow-11.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ae08bd8ffc41aebf578c2af2f9d8749d91f448b3bfd41d7d9ff573d74f2a6b2"}, 160 | {file = "pillow-11.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d69bfd8ec3219ae71bcde1f942b728903cad25fafe3100ba2258b973bd2bc1b2"}, 161 | {file = "pillow-11.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:61b887f9ddba63ddf62fd02a3ba7add935d053b6dd7d58998c630e6dbade8527"}, 162 | {file = "pillow-11.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c6a660307ca9d4867caa8d9ca2c2658ab685de83792d1876274991adec7b93fa"}, 163 | {file = "pillow-11.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73e3a0200cdda995c7e43dd47436c1548f87a30bb27fb871f352a22ab8dcf45f"}, 164 | {file = "pillow-11.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fba162b8872d30fea8c52b258a542c5dfd7b235fb5cb352240c8d63b414013eb"}, 165 | {file = "pillow-11.0.0-cp313-cp313-win32.whl", hash = "sha256:f1b82c27e89fffc6da125d5eb0ca6e68017faf5efc078128cfaa42cf5cb38798"}, 166 | {file = "pillow-11.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ba470552b48e5835f1d23ecb936bb7f71d206f9dfeee64245f30c3270b994de"}, 167 | {file = "pillow-11.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:846e193e103b41e984ac921b335df59195356ce3f71dcfd155aa79c603873b84"}, 168 | {file = "pillow-11.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4ad70c4214f67d7466bea6a08061eba35c01b1b89eaa098040a35272a8efb22b"}, 169 | {file = "pillow-11.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6ec0d5af64f2e3d64a165f490d96368bb5dea8b8f9ad04487f9ab60dc4bb6003"}, 170 | {file = "pillow-11.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c809a70e43c7977c4a42aefd62f0131823ebf7dd73556fa5d5950f5b354087e2"}, 171 | {file = "pillow-11.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4b60c9520f7207aaf2e1d94de026682fc227806c6e1f55bba7606d1c94dd623a"}, 172 | {file = "pillow-11.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1e2688958a840c822279fda0086fec1fdab2f95bf2b717b66871c4ad9859d7e8"}, 173 | {file = "pillow-11.0.0-cp313-cp313t-win32.whl", hash = "sha256:607bbe123c74e272e381a8d1957083a9463401f7bd01287f50521ecb05a313f8"}, 174 | {file = "pillow-11.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c39ed17edea3bc69c743a8dd3e9853b7509625c2462532e62baa0732163a904"}, 175 | {file = "pillow-11.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:75acbbeb05b86bc53cbe7b7e6fe00fbcf82ad7c684b3ad82e3d711da9ba287d3"}, 176 | {file = "pillow-11.0.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:2e46773dc9f35a1dd28bd6981332fd7f27bec001a918a72a79b4133cf5291dba"}, 177 | {file = "pillow-11.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2679d2258b7f1192b378e2893a8a0a0ca472234d4c2c0e6bdd3380e8dfa21b6a"}, 178 | {file = "pillow-11.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda2616eb2313cbb3eebbe51f19362eb434b18e3bb599466a1ffa76a033fb916"}, 179 | {file = "pillow-11.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20ec184af98a121fb2da42642dea8a29ec80fc3efbaefb86d8fdd2606619045d"}, 180 | {file = "pillow-11.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:8594f42df584e5b4bb9281799698403f7af489fba84c34d53d1c4bfb71b7c4e7"}, 181 | {file = "pillow-11.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:c12b5ae868897c7338519c03049a806af85b9b8c237b7d675b8c5e089e4a618e"}, 182 | {file = "pillow-11.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:70fbbdacd1d271b77b7721fe3cdd2d537bbbd75d29e6300c672ec6bb38d9672f"}, 183 | {file = "pillow-11.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5178952973e588b3f1360868847334e9e3bf49d19e169bbbdfaf8398002419ae"}, 184 | {file = "pillow-11.0.0-cp39-cp39-win32.whl", hash = "sha256:8c676b587da5673d3c75bd67dd2a8cdfeb282ca38a30f37950511766b26858c4"}, 185 | {file = "pillow-11.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:94f3e1780abb45062287b4614a5bc0874519c86a777d4a7ad34978e86428b8dd"}, 186 | {file = "pillow-11.0.0-cp39-cp39-win_arm64.whl", hash = "sha256:290f2cc809f9da7d6d622550bbf4c1e57518212da51b6a30fe8e0a270a5b78bd"}, 187 | {file = "pillow-11.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1187739620f2b365de756ce086fdb3604573337cc28a0d3ac4a01ab6b2d2a6d2"}, 188 | {file = "pillow-11.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbbcb7b57dc9c794843e3d1258c0fbf0f48656d46ffe9e09b63bbd6e8cd5d0a2"}, 189 | {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d203af30149ae339ad1b4f710d9844ed8796e97fda23ffbc4cc472968a47d0b"}, 190 | {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21a0d3b115009ebb8ac3d2ebec5c2982cc693da935f4ab7bb5c8ebe2f47d36f2"}, 191 | {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:73853108f56df97baf2bb8b522f3578221e56f646ba345a372c78326710d3830"}, 192 | {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e58876c91f97b0952eb766123bfef372792ab3f4e3e1f1a2267834c2ab131734"}, 193 | {file = "pillow-11.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:224aaa38177597bb179f3ec87eeefcce8e4f85e608025e9cfac60de237ba6316"}, 194 | {file = "pillow-11.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5bd2d3bdb846d757055910f0a59792d33b555800813c3b39ada1829c372ccb06"}, 195 | {file = "pillow-11.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:375b8dd15a1f5d2feafff536d47e22f69625c1aa92f12b339ec0b2ca40263273"}, 196 | {file = "pillow-11.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:daffdf51ee5db69a82dd127eabecce20729e21f7a3680cf7cbb23f0829189790"}, 197 | {file = "pillow-11.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7326a1787e3c7b0429659e0a944725e1b03eeaa10edd945a86dead1913383944"}, 198 | {file = "pillow-11.0.0.tar.gz", hash = "sha256:72bacbaf24ac003fea9bff9837d1eedb6088758d41e100c1552930151f677739"}, 199 | ] 200 | 201 | [package.extras] 202 | docs = ["furo", "olefile", "sphinx (>=8.1)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] 203 | fpx = ["olefile"] 204 | mic = ["olefile"] 205 | tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] 206 | typing = ["typing-extensions"] 207 | xmp = ["defusedxml"] 208 | 209 | [[package]] 210 | name = "pybind11" 211 | version = "2.13.6" 212 | description = "Seamless operability between C++11 and Python" 213 | optional = false 214 | python-versions = ">=3.7" 215 | files = [ 216 | {file = "pybind11-2.13.6-py3-none-any.whl", hash = "sha256:237c41e29157b962835d356b370ededd57594a26d5894a795960f0047cb5caf5"}, 217 | {file = "pybind11-2.13.6.tar.gz", hash = "sha256:ba6af10348c12b24e92fa086b39cfba0eff619b61ac77c406167d813b096d39a"}, 218 | ] 219 | 220 | [package.extras] 221 | global = ["pybind11-global (==2.13.6)"] 222 | 223 | [[package]] 224 | name = "skia-python" 225 | version = "132.0b11" 226 | description = "Skia python binding" 227 | optional = false 228 | python-versions = "*" 229 | files = [ 230 | {file = "skia_python-132.0b11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:586891745253b39d7fbb9a665748f5560acc1ac979a7b8feec81cb8e78d4920e"}, 231 | {file = "skia_python-132.0b11-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:44d2d523cdf613070320251ecc0575eeaa54378407ac77150038987368365828"}, 232 | {file = "skia_python-132.0b11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1e7aebf65108d37c56025faef72dd2c9a7c77826f85e3a3fbe11262815c83d6"}, 233 | {file = "skia_python-132.0b11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5a44ec0cb731d2e9484c9bc2d4bb13581b1ae215880a2bbb13a4d38fcf88a00"}, 234 | {file = "skia_python-132.0b11-cp310-cp310-win_amd64.whl", hash = "sha256:5c81a8cac6b9c713ed299b71fe61d973414aba63bdf7a613b8962a2f1da31bd9"}, 235 | {file = "skia_python-132.0b11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:95fc735b01609f1743e18449008f76fbdc33bf7dd7563aec7049b9f19e3a267c"}, 236 | {file = "skia_python-132.0b11-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:bd6d491e4baf7e157aeaf13f6963161a4a60c90835166c98a6bed667b0dbedfa"}, 237 | {file = "skia_python-132.0b11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f058a6ef14a1ae07b912ca6d9538a8122951d8faf5d918540b39ea618e39bc5"}, 238 | {file = "skia_python-132.0b11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1495d709570fb247f4ac7321697fb1d6293f42149eca61e461070d2056c85228"}, 239 | {file = "skia_python-132.0b11-cp311-cp311-win_amd64.whl", hash = "sha256:e12385570d8ca99d1a30e6d73125d8832ac11517551b7066adfb04b6a02ae8df"}, 240 | {file = "skia_python-132.0b11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e55f7ae17fa7082088cab26b09667496cedd0a882dbe87366d0192721edfd2eb"}, 241 | {file = "skia_python-132.0b11-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:3b6f54a0be6b8bb5520fa9de22fce035f8aff934ec4008ef5512a7cdc211f024"}, 242 | {file = "skia_python-132.0b11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc9800bc297baa3b877626f1effd4eefd3a3b4f67ac9c542d6370937533eb4a6"}, 243 | {file = "skia_python-132.0b11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db1b9088739577d41e40375beac56b9db852b1ea55c4fabef3d34ad385138c15"}, 244 | {file = "skia_python-132.0b11-cp312-cp312-win_amd64.whl", hash = "sha256:fe8faf84a4f07a07ef3599339b8acdffb0aa3baed473828177ea668e765bf5e0"}, 245 | {file = "skia_python-132.0b11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0039b43be09f5c8811e762068e0799f437ce154510f0040a1d2428df2a6808c2"}, 246 | {file = "skia_python-132.0b11-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:5dc3a89c28eae94792608768afb866526273ac78bd34af5d9c5defe096b13210"}, 247 | {file = "skia_python-132.0b11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:054acd950dd05878f2ffbce51db28befe083c2a1e04887dbd566d6580a81d01e"}, 248 | {file = "skia_python-132.0b11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96912300d26180af2573298acfca9e270278e162c0de8d0480c4b3b9a86d8f95"}, 249 | {file = "skia_python-132.0b11-cp313-cp313-win_amd64.whl", hash = "sha256:caaac5ba9f1c26f5dfbf50873c9191f75289dbcea45ba946a2f626de1cd3a14b"}, 250 | {file = "skia_python-132.0b11-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:84a754c52d05c230c7dfb13a0bef89a0a609403285001379b245cd13919913b6"}, 251 | {file = "skia_python-132.0b11-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:b818227fed51e859e5c736c81a4b70653a017cc65d75da1f80b547a897b1913f"}, 252 | {file = "skia_python-132.0b11-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e01f8582c2acc03056a750ad9aa26078013e1c9c221aad8eca2908410c5ed09d"}, 253 | {file = "skia_python-132.0b11-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58ffa1c859cb2e444a8899b4caf98c41d0b7ee20737435fee2411fb321bcc9a7"}, 254 | {file = "skia_python-132.0b11-cp38-cp38-win_amd64.whl", hash = "sha256:f26397d9329c55eeff22527a2740a6b5bf17eee61ec0f7a56755059643b817b5"}, 255 | {file = "skia_python-132.0b11-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b2f373f6c1baba9465e0a270d2f84646d31b040f03c987895b791756a7cae87a"}, 256 | {file = "skia_python-132.0b11-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:40d65b329369a7afdbc64eccf857a3ec9cb0b5e0dc1ff484c06b757522535ea6"}, 257 | {file = "skia_python-132.0b11-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1177d68e05a713ae44257c054c1a72d04f53b1833e264de7f053fe228914732"}, 258 | {file = "skia_python-132.0b11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffd829cf2b7b85772e8d0a3c65d52abcd31b20b163f02c7701a908a1ee7a4304"}, 259 | {file = "skia_python-132.0b11-cp39-cp39-win_amd64.whl", hash = "sha256:cc922461845e4dfb31304e8c19e8cdc6b7ba735be7c63c94310be77d23396ddd"}, 260 | ] 261 | 262 | [package.dependencies] 263 | numpy = "*" 264 | pybind11 = ">=2.6" 265 | 266 | [metadata] 267 | lock-version = "2.0" 268 | python-versions = "^3.9" 269 | content-hash = "ed3731229f96edb512b0cd668ef4f213829f2b505d5caf298d30c71309cdf5b7" 270 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pil-utils" 3 | version = "0.2.2" 4 | description = "A simple PIL wrapper and text-to-image tool" 5 | authors = ["meetwq "] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://github.com/MeetWq/pil-utils" 9 | repository = "https://github.com/MeetWq/pil-utils" 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.9" 13 | Pillow = ">=10.0.0,<12.0.0" 14 | numpy = ">=1.21.0" 15 | opencv-python-headless = "^4.0.0" 16 | bbcode = "^1.1.0" 17 | skia-python = ">=132.0b11" 18 | msvc-runtime = {version = "*", platform = "win32"} 19 | 20 | [tool.poetry.group.dev.dependencies] 21 | 22 | [tool.pyright] 23 | pythonVersion = "3.9" 24 | pythonPlatform = "All" 25 | typeCheckingMode = "basic" 26 | 27 | [tool.ruff] 28 | line-length = 88 29 | target-version = "py39" 30 | 31 | [tool.ruff.lint] 32 | select = ["E", "W", "F", "UP", "C", "T", "PYI", "PT", "Q"] 33 | ignore = ["E402", "C901", "UP037"] 34 | 35 | [build-system] 36 | requires = ["poetry-core>=1.0.0"] 37 | build-backend = "poetry.core.masonry.api" 38 | --------------------------------------------------------------------------------