moviewyrm/bookwyrm/preview_images.py
Joel Bradshaw 9e6390662b Download fonts at app startup instead
We can't bake the font into the Docker image as such, because we mount
the volumes which blows away anything we have in the app tree
beforehand.

We could stash it somewhere in the image and then copy it from there on
app startup or something, but at that point we might as well just
download it as part of the app startup.
2022-01-25 00:53:01 -08:00

473 lines
15 KiB
Python

""" Generate social media preview images for twitter/mastodon/etc """
import math
import os
import textwrap
from io import BytesIO
from uuid import uuid4
import urllib
import logging
import colorsys
from colorthief import ColorThief
from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageColor
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import InMemoryUploadedFile
from django.core.files.storage import default_storage
from django.db.models import Avg
from bookwyrm import models, settings
from bookwyrm.tasks import app
logger = logging.getLogger(__name__)
IMG_WIDTH = settings.PREVIEW_IMG_WIDTH
IMG_HEIGHT = settings.PREVIEW_IMG_HEIGHT
BG_COLOR = settings.PREVIEW_BG_COLOR
TEXT_COLOR = settings.PREVIEW_TEXT_COLOR
DEFAULT_COVER_COLOR = settings.PREVIEW_DEFAULT_COVER_COLOR
DEFAULT_FONT = settings.PREVIEW_DEFAULT_FONT
TRANSPARENT_COLOR = (0, 0, 0, 0)
margin = math.floor(IMG_HEIGHT / 10)
gutter = math.floor(margin / 2)
inner_img_height = math.floor(IMG_HEIGHT * 0.8)
inner_img_width = math.floor(inner_img_height * 0.7)
def get_imagefont(name, size):
try:
config = settings.FONTS[name]
path = os.path.join(settings.FONT_DIR, config["directory"], config["filename"])
return ImageFont.truetype(path, size)
except KeyError:
logger.error("Font %s not found in config", name)
except OSError:
logger.error("Could not load font %s from file", name)
return ImageFont.load_default()
def get_font(weight, size=28):
font = get_imagefont(DEFAULT_FONT, size)
try:
if weight == "light":
font.set_variation_by_name("Light")
if weight == "bold":
font.set_variation_by_name("Bold")
if weight == "regular":
font.set_variation_by_name("Regular")
except AttributeError:
pass
return font
def get_wrapped_text(text, font, content_width):
"""text wrap length depends on the max width of the content"""
low = 0
high = len(text)
try:
# ideal length is determined via binary search
while low < high:
mid = math.floor(low + high)
wrapped_text = textwrap.fill(text, width=mid)
width = font.getsize_multiline(wrapped_text)[0]
if width < content_width:
low = mid
else:
high = mid - 1
except AttributeError:
wrapped_text = text
return wrapped_text
def generate_texts_layer(texts, content_width):
"""Adds text for images"""
font_text_zero = get_font("bold", size=20)
font_text_one = get_font("bold", size=48)
font_text_two = get_font("bold", size=40)
font_text_three = get_font("regular", size=40)
text_layer = Image.new("RGBA", (content_width, IMG_HEIGHT), color=TRANSPARENT_COLOR)
text_layer_draw = ImageDraw.Draw(text_layer)
text_y = 0
if "text_zero" in texts and texts["text_zero"]:
# Text one (Book title)
text_zero = get_wrapped_text(texts["text_zero"], font_text_zero, content_width)
text_layer_draw.multiline_text(
(0, text_y), text_zero, font=font_text_zero, fill=TEXT_COLOR
)
try:
text_y = text_y + font_text_zero.getsize_multiline(text_zero)[1] + 16
except (AttributeError, IndexError):
text_y = text_y + 26
if "text_one" in texts and texts["text_one"]:
# Text one (Book title)
text_one = get_wrapped_text(texts["text_one"], font_text_one, content_width)
text_layer_draw.multiline_text(
(0, text_y), text_one, font=font_text_one, fill=TEXT_COLOR
)
try:
text_y = text_y + font_text_one.getsize_multiline(text_one)[1] + 16
except (AttributeError, IndexError):
text_y = text_y + 26
if "text_two" in texts and texts["text_two"]:
# Text one (Book subtitle)
text_two = get_wrapped_text(texts["text_two"], font_text_two, content_width)
text_layer_draw.multiline_text(
(0, text_y), text_two, font=font_text_two, fill=TEXT_COLOR
)
try:
text_y = text_y + font_text_one.getsize_multiline(text_two)[1] + 16
except (AttributeError, IndexError):
text_y = text_y + 26
if "text_three" in texts and texts["text_three"]:
# Text three (Book authors)
text_three = get_wrapped_text(
texts["text_three"], font_text_three, content_width
)
text_layer_draw.multiline_text(
(0, text_y), text_three, font=font_text_three, fill=TEXT_COLOR
)
text_layer_box = text_layer.getbbox()
return text_layer.crop(text_layer_box)
def generate_instance_layer(content_width):
"""Places components for instance preview"""
font_instance = get_font("light", size=28)
site = models.SiteSettings.objects.get()
if site.logo_small:
logo_img = Image.open(site.logo_small)
else:
try:
static_path = os.path.join(settings.STATIC_ROOT, "images/logo-small.png")
logo_img = Image.open(static_path)
except FileNotFoundError:
logo_img = None
instance_layer = Image.new("RGBA", (content_width, 62), color=TRANSPARENT_COLOR)
instance_text_x = 0
if logo_img:
logo_img.thumbnail((50, 50), Image.ANTIALIAS)
instance_layer.paste(logo_img, (0, 0))
instance_text_x = instance_text_x + 60
instance_layer_draw = ImageDraw.Draw(instance_layer)
instance_layer_draw.text(
(instance_text_x, 10), site.name, font=font_instance, fill=TEXT_COLOR
)
line_width = 50 + 10 + font_instance.getsize(site.name)[0]
line_layer = Image.new(
"RGBA", (line_width, 2), color=(*(ImageColor.getrgb(TEXT_COLOR)), 50)
)
instance_layer.alpha_composite(line_layer, (0, 60))
return instance_layer
def generate_rating_layer(rating, content_width):
"""Places components for rating preview"""
try:
icon_star_full = Image.open(
os.path.join(settings.STATIC_ROOT, "images/icons/star-full.png")
)
icon_star_empty = Image.open(
os.path.join(settings.STATIC_ROOT, "images/icons/star-empty.png")
)
icon_star_half = Image.open(
os.path.join(settings.STATIC_ROOT, "images/icons/star-half.png")
)
except FileNotFoundError:
return None
icon_size = 64
icon_margin = 10
rating_layer_base = Image.new(
"RGBA", (content_width, icon_size), color=TRANSPARENT_COLOR
)
rating_layer_color = Image.new("RGBA", (content_width, icon_size), color=TEXT_COLOR)
rating_layer_mask = Image.new(
"RGBA", (content_width, icon_size), color=TRANSPARENT_COLOR
)
position_x = 0
for _ in range(math.floor(rating)):
rating_layer_mask.alpha_composite(icon_star_full, (position_x, 0))
position_x = position_x + icon_size + icon_margin
if math.floor(rating) != math.ceil(rating):
rating_layer_mask.alpha_composite(icon_star_half, (position_x, 0))
position_x = position_x + icon_size + icon_margin
for _ in range(5 - math.ceil(rating)):
rating_layer_mask.alpha_composite(icon_star_empty, (position_x, 0))
position_x = position_x + icon_size + icon_margin
rating_layer_mask = rating_layer_mask.getchannel("A")
rating_layer_mask = ImageOps.invert(rating_layer_mask)
rating_layer_composite = Image.composite(
rating_layer_base, rating_layer_color, rating_layer_mask
)
return rating_layer_composite
def generate_default_inner_img():
"""Adds cover image"""
font_cover = get_font("light", size=28)
default_cover = Image.new(
"RGB", (inner_img_width, inner_img_height), color=DEFAULT_COVER_COLOR
)
default_cover_draw = ImageDraw.Draw(default_cover)
text = "no image :("
text_dimensions = font_cover.getsize(text)
text_coords = (
math.floor((inner_img_width - text_dimensions[0]) / 2),
math.floor((inner_img_height - text_dimensions[1]) / 2),
)
default_cover_draw.text(text_coords, text, font=font_cover, fill="white")
return default_cover
# pylint: disable=too-many-locals
# pylint: disable=too-many-statements
def generate_preview_image(
texts=None, picture=None, rating=None, show_instance_layer=True
):
"""Puts everything together"""
texts = texts or {}
# Cover
try:
inner_img_layer = Image.open(picture)
inner_img_layer.thumbnail((inner_img_width, inner_img_height), Image.ANTIALIAS)
color_thief = ColorThief(picture)
dominant_color = color_thief.get_color(quality=1)
except: # pylint: disable=bare-except
inner_img_layer = generate_default_inner_img()
dominant_color = ImageColor.getrgb(DEFAULT_COVER_COLOR)
# Color
if BG_COLOR in ["use_dominant_color_light", "use_dominant_color_dark"]:
red, green, blue = dominant_color
image_bg_color = f"rgb({red}, {green}, {blue})"
# Adjust color
image_bg_color_rgb = [x / 255.0 for x in ImageColor.getrgb(image_bg_color)]
image_bg_color_hls = colorsys.rgb_to_hls(*image_bg_color_rgb)
if BG_COLOR == "use_dominant_color_light":
lightness = max(0.9, image_bg_color_hls[1])
else:
lightness = min(0.15, image_bg_color_hls[1])
image_bg_color_hls = (
image_bg_color_hls[0],
lightness,
image_bg_color_hls[2],
)
image_bg_color = tuple(
math.ceil(x * 255) for x in colorsys.hls_to_rgb(*image_bg_color_hls)
)
else:
image_bg_color = BG_COLOR
# Background (using the color)
img = Image.new("RGBA", (IMG_WIDTH, IMG_HEIGHT), color=image_bg_color)
# Contents
inner_img_x = margin + inner_img_width - inner_img_layer.width
inner_img_y = math.floor((IMG_HEIGHT - inner_img_layer.height) / 2)
content_x = margin + inner_img_width + gutter
content_width = IMG_WIDTH - content_x - margin
contents_layer = Image.new(
"RGBA", (content_width, IMG_HEIGHT), color=TRANSPARENT_COLOR
)
contents_composite_y = 0
if show_instance_layer:
instance_layer = generate_instance_layer(content_width)
contents_layer.alpha_composite(instance_layer, (0, contents_composite_y))
contents_composite_y = contents_composite_y + instance_layer.height + gutter
texts_layer = generate_texts_layer(texts, content_width)
contents_layer.alpha_composite(texts_layer, (0, contents_composite_y))
contents_composite_y = contents_composite_y + texts_layer.height + gutter
if rating:
# Add some more margin
contents_composite_y = contents_composite_y + gutter
rating_layer = generate_rating_layer(rating, content_width)
if rating_layer:
contents_layer.alpha_composite(rating_layer, (0, contents_composite_y))
contents_composite_y = contents_composite_y + rating_layer.height + gutter
contents_layer_box = contents_layer.getbbox()
contents_layer_height = contents_layer_box[3] - contents_layer_box[1]
contents_y = math.floor((IMG_HEIGHT - contents_layer_height) / 2)
if show_instance_layer:
# Remove Instance Layer from centering calculations
contents_y = contents_y - math.floor((instance_layer.height + gutter) / 2)
contents_y = max(contents_y, margin)
# Composite layers
img.paste(
inner_img_layer, (inner_img_x, inner_img_y), inner_img_layer.convert("RGBA")
)
img.alpha_composite(contents_layer, (content_x, contents_y))
return img.convert("RGB")
def save_and_cleanup(image, instance=None):
"""Save and close the file"""
if not isinstance(instance, (models.Book, models.User, models.SiteSettings)):
return False
image_buffer = BytesIO()
try:
try:
file_name = instance.preview_image.name
except ValueError:
file_name = None
if not file_name or file_name == "":
uuid = uuid4()
file_name = f"{instance.id}-{uuid}.jpg"
# Clean up old file before saving
if file_name and default_storage.exists(file_name):
default_storage.delete(file_name)
# Save
image.save(image_buffer, format="jpeg", quality=75)
instance.preview_image = InMemoryUploadedFile(
ContentFile(image_buffer.getvalue()),
"preview_image",
file_name,
"image/jpg",
image_buffer.tell(),
None,
)
save_without_broadcast = isinstance(instance, (models.Book, models.User))
if save_without_broadcast:
instance.save(broadcast=False, update_fields=["preview_image"])
else:
instance.save(update_fields=["preview_image"])
finally:
image_buffer.close()
return True
# pylint: disable=invalid-name
@app.task(queue="low_priority")
def generate_site_preview_image_task():
"""generate preview_image for the website"""
if not settings.ENABLE_PREVIEW_IMAGES:
return
site = models.SiteSettings.objects.get()
if site.logo:
logo = site.logo
else:
logo = os.path.join(settings.STATIC_ROOT, "images/logo.png")
texts = {
"text_zero": settings.DOMAIN,
"text_one": site.name,
"text_three": site.instance_tagline,
}
image = generate_preview_image(texts=texts, picture=logo, show_instance_layer=False)
save_and_cleanup(image, instance=site)
# pylint: disable=invalid-name
@app.task(queue="low_priority")
def generate_edition_preview_image_task(book_id):
"""generate preview_image for a book"""
if not settings.ENABLE_PREVIEW_IMAGES:
return
book = models.Book.objects.select_subclasses().get(id=book_id)
rating = models.Review.objects.filter(
privacy="public",
deleted=False,
book__in=[book_id],
).aggregate(Avg("rating"))["rating__avg"]
texts = {
"text_one": book.title,
"text_two": book.subtitle,
"text_three": book.author_text,
}
image = generate_preview_image(texts=texts, picture=book.cover, rating=rating)
save_and_cleanup(image, instance=book)
@app.task(queue="low_priority")
def generate_user_preview_image_task(user_id):
"""generate preview_image for a book"""
if not settings.ENABLE_PREVIEW_IMAGES:
return
user = models.User.objects.get(id=user_id)
texts = {
"text_one": user.display_name,
"text_three": f"@{user.localname}@{settings.DOMAIN}",
}
if user.avatar:
avatar = user.avatar
else:
avatar = os.path.join(settings.STATIC_ROOT, "images/default_avi.jpg")
image = generate_preview_image(texts=texts, picture=avatar)
save_and_cleanup(image, instance=user)