Skip to content

Module app.utils.images

View Source
from __future__ import annotations

import hashlib

import io

from pathlib import Path

from typing import TYPE_CHECKING, Iterator, List, Optional, Tuple

from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageOps

from sanic.log import logger

from .. import settings

from ..types import Dimensions, Offset, Point

from .text import encode

if TYPE_CHECKING:

    from ..models import Template

def preview(

    template: Template, lines: List[str], style: str = settings.DEFAULT_STYLE

) -> Tuple[bytes, str]:

    image = render_image(template, style, lines, settings.PREVIEW_SIZE, pad=False)

    stream = io.BytesIO()

    image.save(stream, format="JPEG", quality=50)

    return stream.getvalue(), "image/jpeg"

def save(

    template: Template,

    lines: List[str],

    ext: str = settings.DEFAULT_EXT,

    style: str = settings.DEFAULT_STYLE,

    size: Dimensions = (0, 0),

    *,

    directory: Path = settings.IMAGES_DIRECTORY,

) -> Path:

    size = fit_image(*size)

    slug = encode(lines)

    variant = str(style) + str(size)

    fingerprint = hashlib.sha1(variant.encode()).hexdigest()

    path = directory / template.key / f"{slug}.{fingerprint}.{ext}"

    if path.exists():

        logger.info(f"Found meme at {path}")

        if settings.DEPLOYED:

            return path

    else:

        logger.info(f"Saving meme to {path}")

        path.parent.mkdir(parents=True, exist_ok=True)

    image = render_image(template, style, lines, size)

    image.save(path, quality=95)

    return path

def load(path: Path) -> Image:

    image = Image.open(path).convert("RGB")

    image = ImageOps.exif_transpose(image)

    return image

def render_image(

    template: Template,

    style: str,

    lines: List[str],

    size: Dimensions,

    *,

    pad: Optional[bool] = None,

) -> Image:

    background = load(template.get_image(style))

    pad = all(size) if pad is None else pad

    image = resize_image(background, *size, pad)

    for (

        point,

        offset,

        text,

        max_text_size,

        text_fill,

        font_size,

        stroke_width,

        stroke_fill,

        angle,

    ) in get_image_elements(template, lines, image.size):

        box = Image.new("RGBA", max_text_size)

        draw = ImageDraw.Draw(box)

        if settings.DEBUG:

            xy = (0, 0, max_text_size[0] - 1, max_text_size[1] - 1)

            draw.rectangle(xy, outline="lime")

        if angle:

            font = ImageFont.truetype(str(settings.FONT_THIN), size=font_size)

        else:

            font = ImageFont.truetype(str(settings.FONT_THICK), size=font_size)

        draw.text(

            (-offset[0], -offset[1]),

            text,

            text_fill,

            font,

            spacing=-offset[1] / 2,

            align="center",

            stroke_width=stroke_width,

            stroke_fill=stroke_fill,

        )

        box = box.rotate(angle, resample=Image.BICUBIC, expand=True)

        image.paste(box, point, box)

    if pad:

        image = add_blurred_background(image, background, *size)

    return image

def resize_image(image: Image, width: int, height: int, pad: bool) -> Image:

    ratio = image.width / image.height

    default_width, default_height = settings.DEFAULT_SIZE

    if pad:

        if width < height * ratio:

            size = width, int(width / ratio)

        else:

            size = int(height * ratio), height

    elif width:

        size = width, int(width / ratio)

    elif height:

        size = int(height * ratio), height

    elif ratio < 1.0:

        size = default_width, int(default_height / ratio)

    else:

        size = int(default_width * ratio), default_height

    image = image.resize(size, Image.LANCZOS)

    return image

def fit_image(width: float, height: float) -> Tuple[int, int]:

    while width * height > settings.MAXIMUM_PIXELS:

        width *= 0.75

        height *= 0.75

    return int(width), int(height)

def add_blurred_background(

    foreground: Image, background: Image, width: int, height: int

) -> Image:

    base_width, base_height = foreground.size

    border_width = min(width, base_width + 2)

    border_height = min(height, base_height + 2)

    border_dimensions = border_width, border_height

    border = Image.new("RGB", border_dimensions)

    border.paste(

        foreground,

        ((border_width - base_width) // 2, (border_height - base_height) // 2),

    )

    padded = background.resize((width, height), Image.LANCZOS)

    darkened = padded.point(lambda p: p * 0.4)

    blurred = darkened.filter(ImageFilter.GaussianBlur(5))

    blurred_width, blurred_height = blurred.size

    offset = (

        (blurred_width - border_width) // 2,

        (blurred_height - border_height) // 2,

    )

    blurred.paste(border, offset)

    return blurred

def get_image_elements(

    template: Template, lines: List[str], image_size: Dimensions

) -> Iterator[Tuple[Point, Offset, str, Dimensions, str, int, int, str, float]]:

    for index, text in enumerate(template.text):

        point = text.get_anchor(image_size)

        max_text_size = text.get_size(image_size)

        max_font_size = int(image_size[1] / 9)

        try:

            line = lines[index]

        except IndexError:

            line = ""

        else:

            line = text.stylize(wrap(line, max_text_size, max_font_size))

        font = get_font(line, text.angle, max_text_size, max_font_size)

        offset = get_text_offset(line, font, max_text_size)

        stroke_fill = "black"

        if text.color == "black":

            stroke_width = 0

        else:

            stroke_width = get_stroke_width(font)

        yield point, offset, line, max_text_size, text.color, font.size, stroke_width, stroke_fill, text.angle

def wrap(line: str, max_text_size: Dimensions, max_font_size: int) -> str:

    lines = split(line)

    single = get_font(line, 0, max_text_size, max_font_size)

    double = get_font(lines, 0, max_text_size, max_font_size)

    if single.size >= double.size:

        return line

    if get_text_size(lines, double)[0] >= max_text_size[0] * 0.65:

        return lines

    return line

def split(line: str) -> str:

    midpoint = len(line) // 2 - 1

    for offset in range(0, len(line) // 4):

        for index in [midpoint - offset, midpoint + offset]:

            if line[index] == " ":

                return line[:index] + "\n" + line[index:]

    return line

def get_font(

    text: str, angle: float, max_text_size: Dimensions, max_font_size: int

) -> ImageFont:

    max_text_width = max_text_size[0] - max_text_size[0] / 35

    max_text_height = max_text_size[1] - max_text_size[1] / 10

    for size in range(max(7, max_font_size), 6, -1):

        if angle:

            font = ImageFont.truetype(str(settings.FONT_THIN), size=size)

        else:

            font = ImageFont.truetype(str(settings.FONT_THICK), size=size)

        text_width, text_height = get_text_size_minus_font_offset(text, font)

        if text_width <= max_text_width and text_height <= max_text_height:

            break

    return font

def get_text_size_minus_font_offset(text: str, font: ImageFont) -> Dimensions:

    text_width, text_height = get_text_size(text, font)

    offset = font.getoffset(text)

    return text_width - offset[0], text_height - offset[1]

def get_text_offset(text: str, font: ImageFont, max_text_size: Dimensions) -> Offset:

    text_size = get_text_size(text, font)

    stroke_width = get_stroke_width(font)

    x_offset, y_offset = font.getoffset(text)

    x_offset -= stroke_width

    y_offset -= stroke_width

    x_offset -= (max_text_size[0] - text_size[0]) / 2

    y_offset -= (max_text_size[1] - text_size[1] / (1.25 if "\n" in text else 1.5)) // 2

    return x_offset, y_offset

def get_text_size(text: str, font: ImageFont) -> Dimensions:

    image = Image.new("RGB", (100, 100))

    draw = ImageDraw.Draw(image)

    text_size = draw.textsize(text, font)

    stroke_width = get_stroke_width(font)

    return text_size[0] + stroke_width, text_size[1] + stroke_width

def get_stroke_width(font: ImageFont) -> int:

    return min(3, max(1, font.size // 12))

Variables

TYPE_CHECKING

Functions

add_blurred_background

def add_blurred_background(
    foreground: 'Image',
    background: 'Image',
    width: 'int',
    height: 'int'
) -> 'Image'
View Source
def add_blurred_background(

    foreground: Image, background: Image, width: int, height: int

) -> Image:

    base_width, base_height = foreground.size

    border_width = min(width, base_width + 2)

    border_height = min(height, base_height + 2)

    border_dimensions = border_width, border_height

    border = Image.new("RGB", border_dimensions)

    border.paste(

        foreground,

        ((border_width - base_width) // 2, (border_height - base_height) // 2),

    )

    padded = background.resize((width, height), Image.LANCZOS)

    darkened = padded.point(lambda p: p * 0.4)

    blurred = darkened.filter(ImageFilter.GaussianBlur(5))

    blurred_width, blurred_height = blurred.size

    offset = (

        (blurred_width - border_width) // 2,

        (blurred_height - border_height) // 2,

    )

    blurred.paste(border, offset)

    return blurred

fit_image

def fit_image(
    width: 'float',
    height: 'float'
) -> 'Tuple[int, int]'
View Source
def fit_image(width: float, height: float) -> Tuple[int, int]:

    while width * height > settings.MAXIMUM_PIXELS:

        width *= 0.75

        height *= 0.75

    return int(width), int(height)

get_font

def get_font(
    text: 'str',
    angle: 'float',
    max_text_size: 'Dimensions',
    max_font_size: 'int'
) -> 'ImageFont'
View Source
def get_font(

    text: str, angle: float, max_text_size: Dimensions, max_font_size: int

) -> ImageFont:

    max_text_width = max_text_size[0] - max_text_size[0] / 35

    max_text_height = max_text_size[1] - max_text_size[1] / 10

    for size in range(max(7, max_font_size), 6, -1):

        if angle:

            font = ImageFont.truetype(str(settings.FONT_THIN), size=size)

        else:

            font = ImageFont.truetype(str(settings.FONT_THICK), size=size)

        text_width, text_height = get_text_size_minus_font_offset(text, font)

        if text_width <= max_text_width and text_height <= max_text_height:

            break

    return font

get_image_elements

def get_image_elements(
    template: 'Template',
    lines: 'List[str]',
    image_size: 'Dimensions'
) -> 'Iterator[Tuple[Point, Offset, str, Dimensions, str, int, int, str, float]]'
View Source
def get_image_elements(

    template: Template, lines: List[str], image_size: Dimensions

) -> Iterator[Tuple[Point, Offset, str, Dimensions, str, int, int, str, float]]:

    for index, text in enumerate(template.text):

        point = text.get_anchor(image_size)

        max_text_size = text.get_size(image_size)

        max_font_size = int(image_size[1] / 9)

        try:

            line = lines[index]

        except IndexError:

            line = ""

        else:

            line = text.stylize(wrap(line, max_text_size, max_font_size))

        font = get_font(line, text.angle, max_text_size, max_font_size)

        offset = get_text_offset(line, font, max_text_size)

        stroke_fill = "black"

        if text.color == "black":

            stroke_width = 0

        else:

            stroke_width = get_stroke_width(font)

        yield point, offset, line, max_text_size, text.color, font.size, stroke_width, stroke_fill, text.angle

get_stroke_width

def get_stroke_width(
    font: 'ImageFont'
) -> 'int'
View Source
def get_stroke_width(font: ImageFont) -> int:

    return min(3, max(1, font.size // 12))

get_text_offset

def get_text_offset(
    text: 'str',
    font: 'ImageFont',
    max_text_size: 'Dimensions'
) -> 'Offset'
View Source
def get_text_offset(text: str, font: ImageFont, max_text_size: Dimensions) -> Offset:

    text_size = get_text_size(text, font)

    stroke_width = get_stroke_width(font)

    x_offset, y_offset = font.getoffset(text)

    x_offset -= stroke_width

    y_offset -= stroke_width

    x_offset -= (max_text_size[0] - text_size[0]) / 2

    y_offset -= (max_text_size[1] - text_size[1] / (1.25 if "\n" in text else 1.5)) // 2

    return x_offset, y_offset

get_text_size

def get_text_size(
    text: 'str',
    font: 'ImageFont'
) -> 'Dimensions'
View Source
def get_text_size(text: str, font: ImageFont) -> Dimensions:

    image = Image.new("RGB", (100, 100))

    draw = ImageDraw.Draw(image)

    text_size = draw.textsize(text, font)

    stroke_width = get_stroke_width(font)

    return text_size[0] + stroke_width, text_size[1] + stroke_width

get_text_size_minus_font_offset

def get_text_size_minus_font_offset(
    text: 'str',
    font: 'ImageFont'
) -> 'Dimensions'
View Source
def get_text_size_minus_font_offset(text: str, font: ImageFont) -> Dimensions:

    text_width, text_height = get_text_size(text, font)

    offset = font.getoffset(text)

    return text_width - offset[0], text_height - offset[1]

load

def load(
    path: 'Path'
) -> 'Image'
View Source
def load(path: Path) -> Image:

    image = Image.open(path).convert("RGB")

    image = ImageOps.exif_transpose(image)

    return image

preview

def preview(
    template: 'Template',
    lines: 'List[str]',
    style: 'str' = 'default'
) -> 'Tuple[bytes, str]'
View Source
def preview(

    template: Template, lines: List[str], style: str = settings.DEFAULT_STYLE

) -> Tuple[bytes, str]:

    image = render_image(template, style, lines, settings.PREVIEW_SIZE, pad=False)

    stream = io.BytesIO()

    image.save(stream, format="JPEG", quality=50)

    return stream.getvalue(), "image/jpeg"

render_image

def render_image(
    template: 'Template',
    style: 'str',
    lines: 'List[str]',
    size: 'Dimensions',
    *,
    pad: 'Optional[bool]' = None
) -> 'Image'
View Source
def render_image(

    template: Template,

    style: str,

    lines: List[str],

    size: Dimensions,

    *,

    pad: Optional[bool] = None,

) -> Image:

    background = load(template.get_image(style))

    pad = all(size) if pad is None else pad

    image = resize_image(background, *size, pad)

    for (

        point,

        offset,

        text,

        max_text_size,

        text_fill,

        font_size,

        stroke_width,

        stroke_fill,

        angle,

    ) in get_image_elements(template, lines, image.size):

        box = Image.new("RGBA", max_text_size)

        draw = ImageDraw.Draw(box)

        if settings.DEBUG:

            xy = (0, 0, max_text_size[0] - 1, max_text_size[1] - 1)

            draw.rectangle(xy, outline="lime")

        if angle:

            font = ImageFont.truetype(str(settings.FONT_THIN), size=font_size)

        else:

            font = ImageFont.truetype(str(settings.FONT_THICK), size=font_size)

        draw.text(

            (-offset[0], -offset[1]),

            text,

            text_fill,

            font,

            spacing=-offset[1] / 2,

            align="center",

            stroke_width=stroke_width,

            stroke_fill=stroke_fill,

        )

        box = box.rotate(angle, resample=Image.BICUBIC, expand=True)

        image.paste(box, point, box)

    if pad:

        image = add_blurred_background(image, background, *size)

    return image

resize_image

def resize_image(
    image: 'Image',
    width: 'int',
    height: 'int',
    pad: 'bool'
) -> 'Image'
View Source
def resize_image(image: Image, width: int, height: int, pad: bool) -> Image:

    ratio = image.width / image.height

    default_width, default_height = settings.DEFAULT_SIZE

    if pad:

        if width < height * ratio:

            size = width, int(width / ratio)

        else:

            size = int(height * ratio), height

    elif width:

        size = width, int(width / ratio)

    elif height:

        size = int(height * ratio), height

    elif ratio < 1.0:

        size = default_width, int(default_height / ratio)

    else:

        size = int(default_width * ratio), default_height

    image = image.resize(size, Image.LANCZOS)

    return image

save

def save(
    template: 'Template',
    lines: 'List[str]',
    ext: 'str' = 'png',
    style: 'str' = 'default',
    size: 'Dimensions' = (0, 0),
    *,
    directory: 'Path' = PosixPath('/home/kyle/repos/memegen/images')
) -> 'Path'
View Source
def save(

    template: Template,

    lines: List[str],

    ext: str = settings.DEFAULT_EXT,

    style: str = settings.DEFAULT_STYLE,

    size: Dimensions = (0, 0),

    *,

    directory: Path = settings.IMAGES_DIRECTORY,

) -> Path:

    size = fit_image(*size)

    slug = encode(lines)

    variant = str(style) + str(size)

    fingerprint = hashlib.sha1(variant.encode()).hexdigest()

    path = directory / template.key / f"{slug}.{fingerprint}.{ext}"

    if path.exists():

        logger.info(f"Found meme at {path}")

        if settings.DEPLOYED:

            return path

    else:

        logger.info(f"Saving meme to {path}")

        path.parent.mkdir(parents=True, exist_ok=True)

    image = render_image(template, style, lines, size)

    image.save(path, quality=95)

    return path

split

def split(
    line: 'str'
) -> 'str'
View Source
def split(line: str) -> str:

    midpoint = len(line) // 2 - 1

    for offset in range(0, len(line) // 4):

        for index in [midpoint - offset, midpoint + offset]:

            if line[index] == " ":

                return line[:index] + "\n" + line[index:]

    return line

wrap

def wrap(
    line: 'str',
    max_text_size: 'Dimensions',
    max_font_size: 'int'
) -> 'str'
View Source
def wrap(line: str, max_text_size: Dimensions, max_font_size: int) -> str:

    lines = split(line)

    single = get_font(line, 0, max_text_size, max_font_size)

    double = get_font(lines, 0, max_text_size, max_font_size)

    if single.size >= double.size:

        return line

    if get_text_size(lines, double)[0] >= max_text_size[0] * 0.65:

        return lines

    return line