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

Complexity

Conditions 11

Size

Total Lines 84
Code Lines 61

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 48
CRAP Score 11

Importance

Changes 0
Metric Value
cc 11
eloc 61
nop 8
dl 0
loc 84
rs 5.0563
c 0
b 0
f 0
ccs 48
cts 48
cp 1
crap 11

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 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
Unable to import 'PIL'
Loading history...
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