ikechan8370 commited on
Commit
1f9a4c8
1 Parent(s): 94d5c15
Files changed (4) hide show
  1. Dockerfile +35 -1
  2. config.toml +27 -0
  3. dianzhongdian/__init__.py +65 -0
  4. utils.py +448 -0
Dockerfile CHANGED
@@ -1 +1,35 @@
1
- FROM geyinchi/meme-generator:latest
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM ubuntu:20.04
2
+
3
+ RUN apt update && apt install -y python3-pip fonts-noto-cjk fonts-noto-color-emoji git fontconfig
4
+
5
+ RUN useradd -m -u 1000 user
6
+ USER user
7
+ ENV HOME=/home/user \
8
+ PATH=/home/user/.local/bin:$PATH
9
+
10
+ WORKDIR $HOME/app
11
+
12
+ COPY --chown=user . $HOME/app
13
+
14
+ RUN git clone https://github.com/MeetWq/meme-generator.git && mkdir /usr/share/fonts/meme && mv meme-generator/resources/fonts/* /usr/share/fonts/meme
15
+
16
+ RUN fc-cache -fv
17
+
18
+
19
+ RUN pip install poetry
20
+
21
+ RUN git clone https://github.com/MeetWq/meme-generator-contrib && mkdir /meme-extend && mv meme-generator-contrib/memes/* /meme-extend
22
+
23
+ ADD config.toml /root/.config/meme_generator/config.toml
24
+
25
+ RUN cd meme-generator && poetry install && . .venv/bin/activate && python -m meme_generator.download && cd ..
26
+
27
+ RUN rm -rf meme-generator-contrib && rm -rf $HOME/meme-generator
28
+
29
+ ADD utils.py meme-generator/meme_generator
30
+
31
+ ADD dianzhongdian/__init__.py meme-generator/meme_generator/memes/dianzhongdian/
32
+ # 如果有自己扩展包
33
+ # ADD extends/ /meme-extend
34
+
35
+ CMD cd meme-generator && . .venv/bin/activate && python3 -m meme_generator.app
config.toml ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [meme]
2
+ load_builtin_memes = true # 是否加载内置表情包
3
+ meme_dirs = ["/meme-extend"] # 加载其他位置的表情包,填写文件夹路径
4
+ meme_disabled_list = [] # 禁用的表情包列表,填写表情的 `key`
5
+
6
+ [resource]
7
+ resource_urls = [
8
+ "https://raw.githubusercontent.com/MeetWq/meme-generator/",
9
+ "https://ghproxy.com/https://raw.githubusercontent.com/MeetWq/meme-generator/",
10
+ "https://fastly.jsdelivr.net/gh/MeetWq/meme-generator@",
11
+ "https://raw.fastgit.org/MeetWq/meme-generator/",
12
+ "https://raw.fgit.ml/MeetWq/meme-generator/",
13
+ "https://raw.gitmirror.com/MeetWq/meme-generator/",
14
+ "https://raw.kgithub.com/MeetWq/meme-generator/",
15
+ ]
16
+
17
+ [gif]
18
+ gif_max_size = 10.0 # 限制生成的 gif 文件大小,单位为 Mb
19
+ gif_max_frames = 100 # 限制生成的 gif 文件帧数
20
+
21
+ [translate]
22
+ baidu_trans_appid = "" # 百度翻译api相关,表情包 `dianzhongdian` 需要使用
23
+ baidu_trans_apikey = "" # 可在 百度翻译开放平台 (http://api.fanyi.baidu.com) 申请
24
+
25
+ [server]
26
+ host = "0.0.0.0" # web server 监听地址
27
+ port = 2233 # web server 端口
dianzhongdian/__init__.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+
3
+ from pil_utils import BuildImage
4
+
5
+ from meme_generator import add_meme
6
+ from meme_generator.exception import TextOverLength
7
+ from meme_generator.utils import run_sync, translate, translate_microsoft
8
+
9
+
10
+ @run_sync
11
+ def _dianzhongdian(img: BuildImage, text: str, trans: str):
12
+ img = img.convert("L").resize_width(500)
13
+ text_img1 = BuildImage.new("RGBA", (500, 60))
14
+ text_img2 = BuildImage.new("RGBA", (500, 35))
15
+
16
+ try:
17
+ text_img1.draw_text(
18
+ (20, 0, text_img1.width - 20, text_img1.height),
19
+ text,
20
+ max_fontsize=50,
21
+ min_fontsize=25,
22
+ fill="white",
23
+ )
24
+ except ValueError:
25
+ raise TextOverLength(text)
26
+
27
+ try:
28
+ text_img2.draw_text(
29
+ (20, 0, text_img2.width - 20, text_img2.height),
30
+ trans,
31
+ max_fontsize=25,
32
+ min_fontsize=10,
33
+ fill="white",
34
+ )
35
+ except ValueError:
36
+ raise TextOverLength(text)
37
+
38
+ frame = BuildImage.new("RGBA", (500, img.height + 100), "black")
39
+ frame.paste(img, alpha=True)
40
+ frame.paste(text_img1, (0, img.height), alpha=True)
41
+ frame.paste(text_img2, (0, img.height + 60), alpha=True)
42
+ return frame.save_jpg()
43
+
44
+
45
+ async def dianzhongdian(images: List[BuildImage], texts: List[str], args):
46
+ if len(texts) == 1:
47
+ text = texts[0]
48
+ trans = await translate_microsoft(text, lang_to="jp")
49
+ else:
50
+ text = texts[0]
51
+ trans = texts[1]
52
+
53
+ return await _dianzhongdian(images[0], text, trans)
54
+
55
+
56
+ add_meme(
57
+ "dianzhongdian",
58
+ dianzhongdian,
59
+ min_images=1,
60
+ max_images=1,
61
+ min_texts=1,
62
+ max_texts=2,
63
+ default_texts=["救命啊"],
64
+ keywords=["入典", "典中典", "黑白草图"],
65
+ )
utils.py ADDED
@@ -0,0 +1,448 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import hashlib
3
+ import inspect
4
+ import math
5
+ import random
6
+ import time
7
+ from dataclasses import dataclass
8
+ from enum import Enum
9
+ from functools import partial, wraps
10
+ from io import BytesIO
11
+ from typing import (
12
+ TYPE_CHECKING,
13
+ Any,
14
+ Callable,
15
+ Coroutine,
16
+ List,
17
+ Literal,
18
+ Optional,
19
+ Protocol,
20
+ Tuple,
21
+ TypeVar,
22
+ )
23
+
24
+ import httpx
25
+ from PIL.Image import Image as IMG
26
+ from pil_utils import BuildImage, Text2Image
27
+ from pil_utils.types import ColorType, FontStyle, FontWeight
28
+ from typing_extensions import ParamSpec
29
+
30
+ from .config import meme_config
31
+ from .exception import MemeGeneratorException
32
+
33
+ if TYPE_CHECKING:
34
+ from .meme import Meme
35
+
36
+ P = ParamSpec("P")
37
+ R = TypeVar("R")
38
+
39
+
40
+ def run_sync(call: Callable[P, R]) -> Callable[P, Coroutine[None, None, R]]:
41
+ """一个用于包装 sync function 为 async function 的装饰器
42
+ 参数:
43
+ call: 被装饰的同步函数
44
+ """
45
+
46
+ @wraps(call)
47
+ async def _wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
48
+ loop = asyncio.get_running_loop()
49
+ pfunc = partial(call, *args, **kwargs)
50
+ result = await loop.run_in_executor(None, pfunc)
51
+ return result
52
+
53
+ return _wrapper
54
+
55
+
56
+ def is_coroutine_callable(call: Callable[..., Any]) -> bool:
57
+ """检查 call 是否是一个 callable 协程函数"""
58
+ if inspect.isroutine(call):
59
+ return inspect.iscoroutinefunction(call)
60
+ if inspect.isclass(call):
61
+ return False
62
+ func_ = getattr(call, "__call__", None)
63
+ return inspect.iscoroutinefunction(func_)
64
+
65
+
66
+ def save_gif(frames: List[IMG], duration: float) -> BytesIO:
67
+ output = BytesIO()
68
+ frames[0].save(
69
+ output,
70
+ format="GIF",
71
+ save_all=True,
72
+ append_images=frames[1:],
73
+ duration=duration * 1000,
74
+ loop=0,
75
+ disposal=2,
76
+ optimize=False,
77
+ )
78
+
79
+ # 没有超出最大大小,直接返回
80
+ nbytes = output.getbuffer().nbytes
81
+ if nbytes <= meme_config.gif.gif_max_size * 10**6:
82
+ return output
83
+
84
+ # 超出最大大小,帧数超出最大帧数时,缩减帧数
85
+ n_frames = len(frames)
86
+ gif_max_frames = meme_config.gif.gif_max_frames
87
+ if n_frames > gif_max_frames:
88
+ index = range(n_frames)
89
+ ratio = n_frames / gif_max_frames
90
+ index = (int(i * ratio) for i in range(gif_max_frames))
91
+ new_duration = duration * ratio
92
+ new_frames = [frames[i] for i in index]
93
+ return save_gif(new_frames, new_duration)
94
+
95
+ # 超出最大大小,帧数没有超出最大帧数时,缩小尺寸
96
+ new_frames = [
97
+ frame.resize((int(frame.width * 0.9), int(frame.height * 0.9)))
98
+ for frame in frames
99
+ ]
100
+ return save_gif(new_frames, duration)
101
+
102
+
103
+ class Maker(Protocol):
104
+ def __call__(self, img: BuildImage) -> BuildImage:
105
+ ...
106
+
107
+
108
+ class GifMaker(Protocol):
109
+ def __call__(self, i: int) -> Maker:
110
+ ...
111
+
112
+
113
+ def get_avg_duration(image: IMG) -> float:
114
+ if not getattr(image, "is_animated", False):
115
+ return 0
116
+ total_duration = 0
117
+ for i in range(image.n_frames):
118
+ image.seek(i)
119
+ total_duration += image.info["duration"]
120
+ return total_duration / image.n_frames
121
+
122
+
123
+ def split_gif(image: IMG) -> List[IMG]:
124
+ frames: List[IMG] = []
125
+
126
+ update_mode = "full"
127
+ for i in range(image.n_frames):
128
+ image.seek(i)
129
+ if image.tile: # type: ignore
130
+ update_region = image.tile[0][1][2:] # type: ignore
131
+ if update_region != image.size:
132
+ update_mode = "partial"
133
+ break
134
+
135
+ last_frame: Optional[IMG] = None
136
+ for i in range(image.n_frames):
137
+ image.seek(i)
138
+ frame = image.copy()
139
+ if update_mode == "partial" and last_frame:
140
+ frame = last_frame.copy().paste(frame)
141
+ frames.append(frame)
142
+ image.seek(0)
143
+ if image.info.__contains__("transparency"):
144
+ frames[0].info["transparency"] = image.info["transparency"]
145
+ return frames
146
+
147
+
148
+ def make_jpg_or_gif(
149
+ img: BuildImage, func: Maker, keep_transparency: bool = False
150
+ ) -> BytesIO:
151
+ """
152
+ 制作静图或者动图
153
+ :params
154
+ * ``img``: 输入图片
155
+ * ``func``: 图片处理函数,输入img,返回处理后的图片
156
+ * ``keep_transparency``: 传入gif时,是否保留该gif的透明度
157
+ """
158
+ image = img.image
159
+ if not getattr(image, "is_animated", False):
160
+ return func(img).save_jpg()
161
+ else:
162
+ frames = split_gif(image)
163
+ duration = get_avg_duration(image) / 1000
164
+ frames = [func(BuildImage(frame)).image for frame in frames]
165
+ if keep_transparency:
166
+ image.seek(0)
167
+ if image.info.__contains__("transparency"):
168
+ frames[0].info["transparency"] = image.info["transparency"]
169
+ return save_gif(frames, duration)
170
+
171
+
172
+ def make_png_or_gif(
173
+ img: BuildImage, func: Maker, keep_transparency: bool = False
174
+ ) -> BytesIO:
175
+ """
176
+ 制作静图或者动图
177
+ :params
178
+ * ``img``: 输入图片
179
+ * ``func``: 图片处理函数,输入img,返回处理后的图片
180
+ * ``keep_transparency``: 传入gif时,是否保留该gif的透明度
181
+ """
182
+ image = img.image
183
+ if not getattr(image, "is_animated", False):
184
+ return func(img).save_png()
185
+ else:
186
+ frames = split_gif(image)
187
+ duration = get_avg_duration(image) / 1000
188
+ frames = [func(BuildImage(frame)).image for frame in frames]
189
+ if keep_transparency:
190
+ image.seek(0)
191
+ if image.info.__contains__("transparency"):
192
+ frames[0].info["transparency"] = image.info["transparency"]
193
+ return save_gif(frames, duration)
194
+
195
+
196
+ class FrameAlignPolicy(Enum):
197
+ """
198
+ 要叠加的gif长度大于基准gif时,是否延长基准gif长度以对齐两个gif
199
+ """
200
+
201
+ no_extend = 0
202
+ """不延长"""
203
+ extend_first = 1
204
+ """延长第一帧"""
205
+ extend_last = 2
206
+ """延长最后一帧"""
207
+ extend_loop = 3
208
+ """以循环方式延长"""
209
+
210
+
211
+ def make_gif_or_combined_gif(
212
+ img: BuildImage,
213
+ maker: GifMaker,
214
+ frame_num: int,
215
+ duration: float,
216
+ frame_align: FrameAlignPolicy = FrameAlignPolicy.no_extend,
217
+ input_based: bool = False,
218
+ keep_transparency: bool = False,
219
+ ) -> BytesIO:
220
+ """
221
+ 使用静图或动图制作gif
222
+ :params
223
+ * ``img``: 输入图片,如头像
224
+ * ``maker``: 图片处理函数生成,传入第几帧,返回对应的图片处理函数
225
+ * ``frame_num``: 目标gif的帧数
226
+ * ``duration``: 相邻帧之间的时间间隔,单位为秒
227
+ * ``frame_align``: 要叠加的gif长度大于基准gif时,gif长度对齐方式
228
+ * ``input_based``: 是否以输入gif为基准合成gif,默认为`False`,即以目标gif为基准
229
+ * ``keep_transparency``: 传入gif时,是否保留该gif的透明度
230
+ """
231
+ image = img.image
232
+ if not getattr(image, "is_animated", False):
233
+ return save_gif([maker(i)(img).image for i in range(frame_num)], duration)
234
+
235
+ frame_num_in = image.n_frames
236
+ duration_in = get_avg_duration(image) / 1000
237
+ total_duration_in = frame_num_in * duration_in
238
+ total_duration = frame_num * duration
239
+
240
+ if input_based:
241
+ frame_num_base = frame_num_in
242
+ frame_num_fit = frame_num
243
+ duration_base = duration_in
244
+ duration_fit = duration
245
+ total_duration_base = total_duration_in
246
+ total_duration_fit = total_duration
247
+ else:
248
+ frame_num_base = frame_num
249
+ frame_num_fit = frame_num_in
250
+ duration_base = duration
251
+ duration_fit = duration_in
252
+ total_duration_base = total_duration
253
+ total_duration_fit = total_duration_in
254
+
255
+ frame_idxs: List[int] = list(range(frame_num_base))
256
+ diff_duration = total_duration_fit - total_duration_base
257
+ diff_num = int(diff_duration / duration_base)
258
+
259
+ if diff_duration >= duration_base:
260
+ if frame_align == FrameAlignPolicy.extend_first:
261
+ frame_idxs = [0] * diff_num + frame_idxs
262
+
263
+ elif frame_align == FrameAlignPolicy.extend_last:
264
+ frame_idxs += [frame_num_base - 1] * diff_num
265
+
266
+ elif frame_align == FrameAlignPolicy.extend_loop:
267
+ frame_num_total = frame_num_base
268
+ # 重复基准gif,直到两个gif总时长之差在1个间隔以内,或总帧数超出最大帧数
269
+ while frame_num_total + frame_num_base <= meme_config.gif.gif_max_frames:
270
+ frame_num_total += frame_num_base
271
+ frame_idxs += list(range(frame_num_base))
272
+ multiple = round(frame_num_total * duration_base / total_duration_fit)
273
+ if (
274
+ math.fabs(
275
+ total_duration_fit * multiple - frame_num_total * duration_base
276
+ )
277
+ <= duration_base
278
+ ):
279
+ break
280
+
281
+ frames: List[IMG] = []
282
+ frame_idx_fit = 0
283
+ time_start = 0
284
+ for i, idx in enumerate(frame_idxs):
285
+ while frame_idx_fit < frame_num_fit:
286
+ if (
287
+ frame_idx_fit * duration_fit
288
+ <= i * duration_base - time_start
289
+ < (frame_idx_fit + 1) * duration_fit
290
+ ):
291
+ if input_based:
292
+ idx_in = idx
293
+ idx_maker = frame_idx_fit
294
+ else:
295
+ idx_in = frame_idx_fit
296
+ idx_maker = idx
297
+
298
+ func = maker(idx_maker)
299
+ image.seek(idx_in)
300
+ frames.append(func(BuildImage(image.copy())).image)
301
+ break
302
+ else:
303
+ frame_idx_fit += 1
304
+ if frame_idx_fit >= frame_num_fit:
305
+ frame_idx_fit = 0
306
+ time_start += total_duration_fit
307
+
308
+ if keep_transparency:
309
+ image.seek(0)
310
+ if image.info.__contains__("transparency"):
311
+ frames[0].info["transparency"] = image.info["transparency"]
312
+
313
+ return save_gif(frames, duration)
314
+
315
+
316
+ async def translate(text: str, lang_from: str = "auto", lang_to: str = "zh") -> str:
317
+ appid = meme_config.translate.baidu_trans_appid
318
+ apikey = meme_config.translate.baidu_trans_apikey
319
+ if not appid or not apikey:
320
+ raise MemeGeneratorException(
321
+ "The `baidu_trans_appid` or `baidu_trans_apikey` is not set."
322
+ "Please check your config file!"
323
+ )
324
+ salt = str(round(time.time() * 1000))
325
+ sign_raw = appid + text + salt + apikey
326
+ sign = hashlib.md5(sign_raw.encode("utf8")).hexdigest()
327
+ params = {
328
+ "q": text,
329
+ "from": lang_from,
330
+ "to": lang_to,
331
+ "appid": appid,
332
+ "salt": salt,
333
+ "sign": sign,
334
+ }
335
+ url = "https://fanyi-api.baidu.com/api/trans/vip/translate"
336
+ async with httpx.AsyncClient() as client:
337
+ resp = await client.get(url, params=params)
338
+ result = resp.json()
339
+ return result["trans_result"][0]["dst"]
340
+ async def translate_microsoft(text: str, lang_from: str = "zh-CN", lang_to: str = "ja") -> str:
341
+ if lang_to == 'jp':
342
+ lang_to = 'ja'
343
+ params = {
344
+ "text": text,
345
+ "from": lang_from,
346
+ "to": lang_to,
347
+ }
348
+ url = "https://api.pawan.krd/mtranslate"
349
+ async with httpx.AsyncClient() as client:
350
+ resp = await client.get(url, params=params)
351
+ result = resp.json()
352
+ return result["translated"]
353
+
354
+ def random_text() -> str:
355
+ return random.choice(["刘一", "陈二", "张三", "李四", "王五", "赵六", "孙七", "周八", "吴九", "郑十"])
356
+
357
+
358
+ def random_image() -> BytesIO:
359
+ text = random.choice(["😂", "😅", "🤗", "🤤", "🥵", "🥰", "😍", "😭", "😋", "😏"])
360
+ return (
361
+ BuildImage.new("RGBA", (500, 500), "white")
362
+ .draw_text((0, 0, 500, 500), text, max_fontsize=400)
363
+ .save_png()
364
+ )
365
+
366
+
367
+ @dataclass
368
+ class TextProperties:
369
+ fill: ColorType = "black"
370
+ style: FontStyle = "normal"
371
+ weight: FontWeight = "normal"
372
+ stroke_width: int = 0
373
+ stroke_fill: Optional[ColorType] = None
374
+
375
+
376
+ def default_template(meme: "Meme", number: int) -> str:
377
+ return f"{number}. {'/'.join(meme.keywords)}"
378
+
379
+
380
+ def render_meme_list(
381
+ meme_list: List[Tuple["Meme", TextProperties]],
382
+ *,
383
+ template: Callable[["Meme", int], str] = default_template,
384
+ order_direction: Literal["row", "column"] = "column",
385
+ columns: int = 4,
386
+ column_align: Literal["left", "center", "right"] = "left",
387
+ item_padding: Tuple[int, int] = (15, 6),
388
+ image_padding: Tuple[int, int] = (50, 50),
389
+ bg_color: ColorType = "white",
390
+ fontsize: int = 30,
391
+ fontname: str = "",
392
+ fallback_fonts: List[str] = [],
393
+ ) -> BytesIO:
394
+ item_images: List[Text2Image] = []
395
+ for i, (meme, properties) in enumerate(meme_list, start=1):
396
+ text = template(meme, i)
397
+ t2m = Text2Image.from_text(
398
+ text,
399
+ fontsize=fontsize,
400
+ style=properties.style,
401
+ weight=properties.weight,
402
+ fill=properties.fill,
403
+ stroke_width=properties.stroke_width,
404
+ stroke_fill=properties.stroke_fill,
405
+ fontname=fontname,
406
+ fallback_fonts=fallback_fonts,
407
+ )
408
+ item_images.append(t2m)
409
+ char_A = (
410
+ Text2Image.from_text(
411
+ "A", fontsize=fontsize, fontname=fontname, fallback_fonts=fallback_fonts
412
+ )
413
+ .lines[0]
414
+ .chars[0]
415
+ )
416
+ num_per_col = math.ceil(len(item_images) / columns)
417
+ column_images: List[BuildImage] = []
418
+ for col in range(columns):
419
+ if order_direction == "column":
420
+ images = item_images[col * num_per_col : (col + 1) * num_per_col]
421
+ else:
422
+ images = [
423
+ item_images[num * columns + col]
424
+ for num in range((len(item_images) - col - 1) // columns + 1)
425
+ ]
426
+ img_w = max((t2m.width for t2m in images)) + item_padding[0] * 2
427
+ img_h = (char_A.ascent + item_padding[1] * 2) * len(images) + char_A.descent
428
+ image = BuildImage.new("RGB", (img_w, img_h), bg_color)
429
+ y = item_padding[1]
430
+ for t2m in images:
431
+ if column_align == "left":
432
+ x = 0
433
+ elif column_align == "center":
434
+ x = (img_w - t2m.width - item_padding[0] * 2) // 2
435
+ else:
436
+ x = img_w - t2m.width - item_padding[0] * 2
437
+ t2m.draw_on_image(image.image, (x, y))
438
+ y += char_A.ascent + item_padding[1] * 2
439
+ column_images.append(image)
440
+
441
+ img_w = sum((img.width for img in column_images)) + image_padding[0] * 2
442
+ img_h = max((img.height for img in column_images)) + image_padding[1] * 2
443
+ image = BuildImage.new("RGB", (img_w, img_h), bg_color)
444
+ x, y = image_padding
445
+ for img in column_images:
446
+ image.paste(img, (x, y))
447
+ x += img.width
448
+ return image.save_jpg()