1 | 1 | import os |
|
2 | 1 | import hashlib |
|
3 | 1 | from contextlib import suppress |
|
4 | from typing import Tuple |
||
5 | 1 | ||
6 | from PIL import Image as ImageFile, ImageFont, ImageDraw, ImageFilter |
||
0 ignored issues
–
show
introduced
by
![]() |
|||
7 | import log |
||
8 | 1 | ||
9 | |||
10 | MAXIMUM_PIXELS = 1920 * 1080 |
||
11 | 1 | ||
12 | |||
13 | class Image: |
||
14 | 1 | """JPEG generated by applying text to a template.""" |
|
15 | |||
16 | def __init__(self, template, text, root=None, |
||
17 | 1 | style=None, font=None, size=None, |
|
18 | 1 | watermark="", watermark_font=None): |
|
19 | 1 | self.root = root |
|
20 | 1 | self.template = template |
|
21 | 1 | self.style = style |
|
22 | 1 | self.text = text |
|
23 | 1 | self.font = font |
|
24 | 1 | self.width = size.get('width') if size else None |
|
25 | 1 | self.height = size.get('height') if size else None |
|
26 | self.watermark = watermark |
||
27 | 1 | self.watermark_font = watermark_font |
|
28 | |||
29 | 1 | @property |
|
30 | def path(self): |
||
31 | if not self.root: |
||
32 | 1 | return None |
|
33 | 1 | ||
34 | base = os.path.join(self.root, self.template.key, self.text.path) |
||
35 | custom = [self.style, self.font, self.watermark, |
||
36 | 1 | self.width, self.height] |
|
37 | 1 | ||
38 | 1 | if any(custom): |
|
39 | slug = self.hash(custom) |
||
40 | 1 | return "{}#{}.img".format(base, slug) |
|
41 | else: |
||
42 | 1 | return base + ".img" |
|
43 | |||
44 | 1 | @staticmethod |
|
45 | 1 | def hash(values): |
|
46 | 1 | sha = hashlib.md5() |
|
47 | 1 | for index, value in enumerate(values): |
|
48 | sha.update("{}:{}".format(index, value or "").encode('utf-8')) |
||
49 | 1 | return sha.hexdigest() |
|
50 | 1 | ||
51 | def save(self): |
||
52 | data = _generate( |
||
53 | top=self.text.top, bottom=self.text.bottom, |
||
54 | font_path=self.font.path, |
||
55 | background=self.template.get_path(self.style), |
||
56 | width=self.width, height=self.height, |
||
57 | watermark=self.watermark, |
||
58 | watermark_font_path=self.watermark_font.path, |
||
59 | 1 | ) |
|
60 | 1 | ||
61 | 1 | directory = os.path.dirname(self.path) |
|
62 | with suppress(FileExistsError): |
||
63 | 1 | os.makedirs(directory) |
|
64 | 1 | ||
65 | log.info("Saving image: %s", self.path) |
||
66 | 1 | path = data.save(self.path, format=data.format) |
|
67 | |||
68 | return path |
||
69 | 1 | ||
70 | |||
71 | def _generate(top, bottom, font_path, background, width, height, |
||
72 | 1 | watermark, watermark_font_path): |
|
73 | 1 | """Add text to an image and save it.""" |
|
74 | 1 | log.info("Loading background: %s", background) |
|
75 | 1 | background_image = ImageFile.open(background) |
|
76 | 1 | if background_image.mode not in ('RGB', 'RGBA'): |
|
77 | 1 | if background_image.format == 'JPEG': |
|
78 | background_image = background_image.convert('RGB') |
||
79 | 1 | background_image.format = 'JPEG' |
|
80 | 1 | else: |
|
81 | background_image = background_image.convert('RGBA') |
||
82 | background_image.format = 'PNG' |
||
83 | 1 | ||
84 | 1 | # Resize to a maximum height and width |
|
85 | 1 | ratio = background_image.size[0] / background_image.size[1] |
|
86 | 1 | pad_image = bool(width and height) |
|
87 | pad_watermark = True |
||
88 | 1 | if pad_image: |
|
89 | 1 | if width < height * ratio: |
|
90 | 1 | dimensions = width, int(width / ratio) |
|
91 | 1 | pad_watermark = False |
|
92 | 1 | else: |
|
93 | dimensions = int(height * ratio), height |
||
94 | 1 | elif width: |
|
95 | 1 | dimensions = width, int(width / ratio) |
|
96 | 1 | elif height: |
|
97 | dimensions = int(height * ratio), height |
||
98 | else: |
||
99 | 1 | dimensions = 600, int(600 / ratio) |
|
100 | dimensions = _fit_image(*dimensions) |
||
101 | 1 | image = background_image.resize(dimensions, ImageFile.LANCZOS) |
|
102 | 1 | image.format = 'PNG' |
|
103 | 1 | ||
104 | 1 | # Draw image |
|
105 | draw = ImageDraw.Draw(image) |
||
106 | |||
107 | max_font_size = int(image.size[1] / 9) |
||
108 | 1 | min_font_size_single_line = int(image.size[1] / 12) |
|
109 | max_text_len = image.size[0] - 20 |
||
110 | top_font_size, top = _optimize_font_size( |
||
111 | font_path, top, max_font_size, |
||
112 | min_font_size_single_line, max_text_len, |
||
113 | 1 | ) |
|
114 | 1 | bottom_font_size, bottom = _optimize_font_size( |
|
115 | font_path, bottom, max_font_size, |
||
116 | 1 | min_font_size_single_line, max_text_len, |
|
117 | 1 | ) |
|
118 | |||
119 | top_font = ImageFont.truetype(font_path, top_font_size) |
||
120 | 1 | bottom_font = ImageFont.truetype(font_path, bottom_font_size) |
|
121 | 1 | ||
122 | 1 | top_text_size = draw.multiline_textsize(top, top_font) |
|
123 | bottom_text_size = draw.multiline_textsize(bottom, bottom_font) |
||
124 | |||
125 | 1 | # Find top centered position for top text |
|
126 | 1 | top_text_pos_x = (image.size[0] / 2) - (top_text_size[0] / 2) |
|
127 | 1 | top_text_pos_y = 0 |
|
128 | 1 | top_text_pos = (top_text_pos_x, top_text_pos_y) |
|
129 | 1 | ||
130 | # Find bottom centered position for bottom text |
||
131 | 1 | bottom_text_size_x = (image.size[0] / 2) - (bottom_text_size[0] / 2) |
|
132 | bottom_text_size_y = image.size[1] - bottom_text_size[1] * (7 / 6) |
||
133 | 1 | if watermark and pad_watermark: |
|
134 | bottom_text_size_y = bottom_text_size_y - 5 |
||
135 | bottom_text_pos = (bottom_text_size_x, bottom_text_size_y) |
||
136 | |||
137 | 1 | outline = int(round(max(1, min(dimensions) / 300))) |
|
138 | 1 | t_outline = min(outline, top_font_size // 20) |
|
139 | b_outline = min(outline, bottom_font_size // 20) |
||
140 | _draw_outlined_text(image, top_text_pos, top, top_font, t_outline) |
||
141 | 1 | _draw_outlined_text(image, bottom_text_pos, bottom, bottom_font, b_outline) |
|
142 | 1 | ||
143 | 1 | # Pad image if a specific dimension is requested |
|
144 | 1 | if pad_image: |
|
145 | image = _add_blurred_background(image, background_image, width, height) |
||
146 | |||
147 | 1 | # Add watermark |
|
148 | if watermark: |
||
149 | watermark_font = ImageFont.truetype(watermark_font_path, 11) |
||
150 | 1 | watermark_pos = (3, image.size[1] - 15) |
|
151 | _draw_outlined_text(image, watermark_pos, watermark, watermark_font, |
||
152 | alpha=True) |
||
153 | |||
154 | return image |
||
155 | 1 | ||
156 | 1 | ||
157 | def _optimize_font_size(font, text, max_font_size, min_font_size, |
||
158 | max_text_len): |
||
159 | 1 | """Calculate the optimal font size to fit text in a given size.""" |
|
160 | 1 | ||
161 | # Check size when using smallest single line font size |
||
162 | 1 | fontobj = ImageFont.truetype(font, min_font_size) |
|
163 | 1 | text_size = fontobj.getsize(text) |
|
164 | 1 | ||
165 | 1 | # Calculate font size for text, split if necessary |
|
166 | if text_size[0] > max_text_len: |
||
167 | phrases = _split(text) |
||
168 | else: |
||
169 | 1 | phrases = (text,) |
|
170 | font_size = max_font_size |
||
171 | 1 | for phrase in phrases: |
|
172 | font_size = min(_maximize_font_size(font, phrase, max_text_len), |
||
173 | font_size) |
||
174 | 1 | ||
175 | # Rebuild text with new lines |
||
176 | text = '\n'.join(phrases) |
||
177 | |||
178 | 1 | return font_size, text |
|
179 | 1 | ||
180 | 1 | ||
181 | 1 | def _draw_outlined_text(image, text_pos, text, font, width=1, alpha=False): |
|
182 | 1 | """Draw white text with black outline on an image.""" |
|
183 | if alpha: |
||
184 | black_alpha = 170 |
||
185 | white_alpha = 128 |
||
186 | 1 | else: |
|
187 | black_alpha = 255 |
||
188 | white_alpha = 240 |
||
189 | |||
190 | 1 | overlay = ImageFile.new('RGBA', image.size) |
|
191 | draw = ImageDraw.ImageDraw(overlay, 'RGBA') |
||
192 | 1 | ||
193 | # Draw black text outlines |
||
194 | 1 | for x in range(-width, 1 + width): |
|
195 | 1 | for y in range(-width, 1 + width): |
|
196 | 1 | pos = (text_pos[0] + x, text_pos[1] + y) |
|
197 | 1 | draw.multiline_text(pos, text, (0, 0, 0, black_alpha), |
|
198 | 1 | font=font, align='center') |
|
199 | |||
200 | # Draw inner white text |
||
201 | 1 | draw.multiline_text(text_pos, text, (255, 255, 255, white_alpha), |
|
202 | 1 | font=font, align='center') |
|
203 | |||
204 | 1 | image.paste(overlay, None, overlay) |
|
205 | |||
206 | 1 | ||
207 | 1 | def _add_blurred_background(foreground, background, width, height): |
|
208 | """Add a blurred background to match the requested dimensions.""" |
||
209 | 1 | base_width, base_height = foreground.size |
|
210 | 1 | ||
211 | border_width = min(width, base_width + 2) |
||
212 | 1 | border_height = min(height, base_height + 2) |
|
213 | border_dimensions = border_width, border_height |
||
214 | 1 | border = ImageFile.new('RGB', border_dimensions) |
|
215 | border.paste(foreground, ((border_width - base_width) // 2, |
||
216 | (border_height - base_height) // 2)) |
||
217 | 1 | ||
218 | padded_dimensions = _fit_image(width, height) |
||
219 | 1 | padded = background.resize(padded_dimensions, ImageFile.LANCZOS) |
|
220 | |||
221 | 1 | darkened = padded.point(lambda p: p * 0.4) |
|
222 | 1 | ||
223 | 1 | blurred = darkened.filter(ImageFilter.GaussianBlur(5)) |
|
224 | 1 | blurred.format = 'PNG' |
|
225 | 1 | ||
226 | 1 | blurred_width, blurred_height = blurred.size |
|
227 | offset = ((blurred_width - border_width) // 2, |
||
228 | 1 | (blurred_height - border_height) // 2) |
|
229 | blurred.paste(border, offset) |
||
230 | |||
231 | 1 | return blurred |
|
232 | |||
233 | |||
234 | def _maximize_font_size(font, text, max_size): |
||
235 | """Find the biggest font size that will fit.""" |
||
236 | font_size = max_size |
||
237 | |||
238 | fontobj = ImageFont.truetype(font, font_size) |
||
239 | text_size = fontobj.getsize(text) |
||
240 | while text_size[0] > max_size and font_size > 1: |
||
241 | font_size = font_size - 1 |
||
242 | fontobj = ImageFont.truetype(font, font_size) |
||
243 | text_size = fontobj.getsize(text) |
||
244 | 1 | ||
245 | return font_size |
||
246 | 1 | ||
247 | 1 | ||
248 | 1 | def _fit_image(width: int, height: int) -> Tuple[int, int]: |
|
249 | 1 | while width * height > MAXIMUM_PIXELS: |
|
250 | 1 | width *= 0.75 |
|
251 | 1 | height *= 0.75 |
|
252 | 1 | return int(width), int(height) |
|
253 | |||
254 | 1 | ||
255 | def _split(text): |
||
256 | """Split a line of text into two similarly sized pieces. |
||
257 | |||
258 | >>> _split("Hello, world!") |
||
259 | ('Hello,', 'world!') |
||
260 | |||
261 | >>> _split("This is a phrase that can be split.") |
||
262 | ('This is a phrase', 'that can be split.') |
||
263 | |||
264 | >>> _split("This_is_a_phrase_that_can_not_be_split.") |
||
265 | ('This_is_a_phrase_that_can_not_be_split.',) |
||
266 | |||
267 | """ |
||
268 | result = (text,) |
||
269 | |||
270 | if len(text) >= 3 and ' ' in text[1:-1]: # can split this string |
||
271 | space_indices = [i for i in range(len(text)) if text[i] == ' '] |
||
272 | space_proximities = [abs(i - len(text) // 2) for i in space_indices] |
||
273 | for i, j in zip(space_proximities, space_indices): |
||
274 | if i == min(space_proximities): |
||
275 | result = (text[:j], text[j + 1:]) |
||
276 | break |
||
277 | |||
278 | return result |
||
279 |