memegen.domain.image._generate()   C
last analyzed

Complexity

Conditions 11

Size

Total Lines 84
Code Lines 61

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 11
eloc 61
nop 8
dl 0
loc 84
rs 5.0563
c 0
b 0
f 0

How to fix   Long Method    Complexity    Many Parameters   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

Complex classes like memegen.domain.image._generate() often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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