bookwyrm/bookwyrm/preview_images.py

519 lines
16 KiB
Python
Raw Normal View History

2021-06-18 22:08:36 +00:00
""" Generate social media preview images for twitter/mastodon/etc """
import math
2021-05-25 21:04:28 +00:00
import os
import textwrap
2021-06-18 22:08:36 +00:00
from io import BytesIO
from uuid import uuid4
import logging
2021-06-18 22:08:36 +00:00
import colorsys
2021-05-25 21:04:28 +00:00
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
2021-11-20 15:59:05 +00:00
from django.core.files.storage import default_storage
2021-05-25 21:04:28 +00:00
from django.db.models import Avg
from bookwyrm import models, settings
from bookwyrm.tasks import app, LOW
logger = logging.getLogger(__name__)
IMG_WIDTH = settings.PREVIEW_IMG_WIDTH
IMG_HEIGHT = settings.PREVIEW_IMG_HEIGHT
2021-05-25 21:04:28 +00:00
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)
2021-05-25 21:04:28 +00:00
margin = math.floor(IMG_HEIGHT / 10)
gutter = math.floor(margin / 2)
2021-05-26 13:37:09 +00:00
inner_img_height = math.floor(IMG_HEIGHT * 0.8)
inner_img_width = math.floor(inner_img_height * 0.7)
2021-05-25 21:04:28 +00:00
2021-05-25 21:05:38 +00:00
def get_imagefont(name, size):
2022-01-25 09:04:46 +00:00
"""Loads an ImageFont based on config"""
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):
2022-01-25 09:04:46 +00:00
"""Gets a custom font with the given weight and size"""
font = get_imagefont(DEFAULT_FONT, size)
2021-05-25 21:04:28 +00:00
2022-01-20 21:19:49 +00:00
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
2021-05-25 21:04:28 +00:00
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)
2022-12-17 19:27:39 +00:00
draw = ImageDraw.Draw(Image.new("RGB", (100, 100)))
try:
# ideal length is determined via binary search
while low < high:
mid = math.floor(low + high)
wrapped_text = textwrap.fill(text, width=mid)
left, top, right, bottom = draw.multiline_textbbox(
(0, 0), wrapped_text, font=font
)
width = right - left
height = bottom - top
if width < content_width:
low = mid
else:
high = mid - 1
except AttributeError:
wrapped_text = text
height = 26
return wrapped_text, height
2021-05-26 07:44:32 +00:00
def generate_texts_layer(texts, content_width):
2021-06-18 22:08:36 +00:00
"""Adds text for images"""
2021-05-26 07:44:32 +00:00
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)
2021-05-25 21:04:28 +00:00
text_layer = Image.new("RGBA", (content_width, IMG_HEIGHT), color=TRANSPARENT_COLOR)
text_layer_draw = ImageDraw.Draw(text_layer)
text_y = 0
2021-05-28 19:24:45 +00:00
if "text_zero" in texts and texts["text_zero"]:
2022-12-17 18:32:10 +00:00
# Text zero (Site preview domain name)
text_zero, text_height = get_wrapped_text(
texts["text_zero"], font_text_zero, content_width
)
2021-05-26 12:46:34 +00:00
text_layer_draw.multiline_text(
(0, text_y), text_zero, font=font_text_zero, fill=TEXT_COLOR
)
2021-05-28 20:00:26 +00:00
try:
text_y = text_y + text_height + 16
2021-06-18 22:08:36 +00:00
except (AttributeError, IndexError):
2021-05-28 20:00:26 +00:00
text_y = text_y + 26
2021-05-28 19:24:45 +00:00
if "text_one" in texts and texts["text_one"]:
2022-12-17 18:32:10 +00:00
# Text one (Book/Site title, User display name)
text_one, text_height = get_wrapped_text(
texts["text_one"], font_text_one, content_width
)
2021-05-26 12:46:34 +00:00
text_layer_draw.multiline_text(
(0, text_y), text_one, font=font_text_one, fill=TEXT_COLOR
)
2021-05-26 07:44:32 +00:00
2021-05-28 20:00:26 +00:00
try:
text_y = text_y + text_height + 16
2021-06-18 22:08:36 +00:00
except (AttributeError, IndexError):
2021-05-28 20:00:26 +00:00
text_y = text_y + 26
2021-05-26 07:44:32 +00:00
2021-05-28 19:24:45 +00:00
if "text_two" in texts and texts["text_two"]:
2022-12-17 18:32:10 +00:00
# Text two (Book subtitle)
text_two, text_height = get_wrapped_text(
texts["text_two"], font_text_two, content_width
)
2021-05-26 12:46:34 +00:00
text_layer_draw.multiline_text(
(0, text_y), text_two, font=font_text_two, fill=TEXT_COLOR
)
2021-05-26 07:44:32 +00:00
2021-05-28 20:00:26 +00:00
try:
text_y = text_y + text_height + 16
2021-06-18 22:08:36 +00:00
except (AttributeError, IndexError):
2021-05-28 20:00:26 +00:00
text_y = text_y + 26
2021-05-26 07:44:32 +00:00
2021-05-28 19:24:45 +00:00
if "text_three" in texts and texts["text_three"]:
2022-12-17 18:32:10 +00:00
# Text three (Book authors, Site tagline, User address)
2022-12-17 19:27:39 +00:00
text_three, _ = get_wrapped_text(
texts["text_three"], font_text_three, content_width
)
2021-05-26 07:44:32 +00:00
text_layer_draw.multiline_text(
(0, text_y), text_three, font=font_text_three, fill=TEXT_COLOR
)
2021-05-25 21:04:28 +00:00
text_layer_box = text_layer.getbbox()
return text_layer.crop(text_layer_box)
2021-05-25 21:04:28 +00:00
def generate_instance_layer(content_width):
2021-06-18 22:08:36 +00:00
"""Places components for instance preview"""
2021-05-25 21:04:28 +00:00
font_instance = get_font("light", size=28)
site = models.SiteSettings.objects.get()
if site.logo_small:
logo_img = Image.open(site.logo_small)
else:
2021-05-28 19:40:20 +00:00
try:
static_path = os.path.join(settings.STATIC_ROOT, "images/logo-small.png")
logo_img = Image.open(static_path)
except FileNotFoundError:
logo_img = None
2021-05-25 21:04:28 +00:00
instance_layer = Image.new("RGBA", (content_width, 62), color=TRANSPARENT_COLOR)
2021-05-28 19:40:20 +00:00
instance_text_x = 0
2021-05-28 19:40:20 +00:00
if logo_img:
2022-12-17 17:44:17 +00:00
logo_img.thumbnail((50, 50), Image.Resampling.LANCZOS)
2021-05-28 19:40:20 +00:00
instance_layer.paste(logo_img, (0, 0))
instance_text_x = instance_text_x + 60
2021-05-25 21:04:28 +00:00
instance_layer_draw = ImageDraw.Draw(instance_layer)
2021-05-28 19:40:20 +00:00
instance_layer_draw.text(
(instance_text_x, 10), site.name, font=font_instance, fill=TEXT_COLOR
)
2021-05-25 21:04:28 +00:00
line_width = 50 + 10 + round(font_instance.getlength(site.name))
2021-05-25 21:04:28 +00:00
2021-05-25 21:05:38 +00:00
line_layer = Image.new(
"RGBA", (line_width, 2), color=(*(ImageColor.getrgb(TEXT_COLOR)), 50)
)
2021-05-25 21:04:28 +00:00
instance_layer.alpha_composite(line_layer, (0, 60))
return instance_layer
def generate_rating_layer(rating, content_width):
2021-06-18 22:08:36 +00:00
"""Places components for rating preview"""
2021-06-18 22:58:21 +00:00
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
2021-05-25 21:04:28 +00:00
2021-06-18 22:08:36 +00:00
icon_size = 64
icon_margin = 10
2021-05-25 21:04:28 +00:00
2021-06-18 22:08:36 +00:00
rating_layer_base = Image.new(
"RGBA", (content_width, icon_size), color=TRANSPARENT_COLOR
)
2021-06-18 22:24:10 +00:00
rating_layer_color = Image.new("RGBA", (content_width, icon_size), color=TEXT_COLOR)
2021-06-18 22:08:36 +00:00
rating_layer_mask = Image.new(
"RGBA", (content_width, icon_size), color=TRANSPARENT_COLOR
)
2021-05-25 21:04:28 +00:00
2021-06-18 22:08:36 +00:00
position_x = 0
2021-05-25 21:04:28 +00:00
2021-06-18 22:08:36 +00:00
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
2021-06-18 22:08:36 +00:00
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
2021-06-18 22:08:36 +00:00
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
2021-06-18 22:08:36 +00:00
rating_layer_mask = rating_layer_mask.getchannel("A")
rating_layer_mask = ImageOps.invert(rating_layer_mask)
2021-06-18 22:08:36 +00:00
rating_layer_composite = Image.composite(
rating_layer_base, rating_layer_color, rating_layer_mask
)
2021-06-18 22:08:36 +00:00
return rating_layer_composite
2021-05-26 10:54:57 +00:00
def generate_default_inner_img():
2021-06-18 22:08:36 +00:00
"""Adds cover image"""
2021-05-25 21:04:28 +00:00
font_cover = get_font("light", size=28)
2021-05-25 21:05:38 +00:00
default_cover = Image.new(
2021-05-26 13:37:09 +00:00
"RGB", (inner_img_width, inner_img_height), color=DEFAULT_COVER_COLOR
2021-05-25 21:05:38 +00:00
)
2021-05-25 21:04:28 +00:00
default_cover_draw = ImageDraw.Draw(default_cover)
2021-05-26 07:44:32 +00:00
text = "no image :("
text_left, text_top, text_right, text_bottom = font_cover.getbbox(text)
text_width, text_height = text_right - text_left, text_bottom - text_top
2021-05-25 21:05:38 +00:00
text_coords = (
math.floor((inner_img_width - text_width) / 2),
math.floor((inner_img_height - text_height) / 2),
2021-05-25 21:05:38 +00:00
)
default_cover_draw.text(text_coords, text, font=font_cover, fill="white")
2021-05-25 21:04:28 +00:00
return default_cover
2021-06-18 22:08:36 +00:00
# pylint: disable=too-many-locals
2021-09-20 23:44:59 +00:00
# pylint: disable=too-many-statements
2021-05-26 12:46:34 +00:00
def generate_preview_image(
2021-06-18 22:08:36 +00:00
texts=None, picture=None, rating=None, show_instance_layer=True
2021-05-26 12:46:34 +00:00
):
2021-06-18 22:08:36 +00:00
"""Puts everything together"""
texts = texts or {}
2021-05-25 21:04:28 +00:00
# Cover
try:
2021-05-26 10:54:57 +00:00
inner_img_layer = Image.open(picture)
2022-12-17 18:52:58 +00:00
inner_img_layer.thumbnail(
(inner_img_width, inner_img_height), Image.Resampling.LANCZOS
)
2021-05-26 07:44:32 +00:00
color_thief = ColorThief(picture)
2021-05-25 21:05:38 +00:00
dominant_color = color_thief.get_color(quality=1)
2021-06-18 22:08:36 +00:00
except: # pylint: disable=bare-except
2021-05-26 10:54:57 +00:00
inner_img_layer = generate_default_inner_img()
2021-05-25 21:05:38 +00:00
dominant_color = ImageColor.getrgb(DEFAULT_COVER_COLOR)
2021-05-25 21:04:28 +00:00
# Color
2021-05-26 11:07:33 +00:00
if BG_COLOR in ["use_dominant_color_light", "use_dominant_color_dark"]:
2021-09-20 23:44:59 +00:00
red, green, blue = dominant_color
image_bg_color = f"rgb({red}, {green}, {blue})"
2021-05-26 11:07:33 +00:00
# Adjust color
2021-05-25 21:05:38 +00:00
image_bg_color_rgb = [x / 255.0 for x in ImageColor.getrgb(image_bg_color)]
2021-05-25 21:04:28 +00:00
image_bg_color_hls = colorsys.rgb_to_hls(*image_bg_color_rgb)
2021-05-26 11:07:33 +00:00
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])
2021-05-25 21:16:33 +00:00
image_bg_color_hls = (
image_bg_color_hls[0],
2021-05-26 11:07:33 +00:00
lightness,
2021-05-25 21:16:33 +00:00
image_bg_color_hls[2],
)
2021-05-25 21:05:38 +00:00
image_bg_color = tuple(
2021-06-18 22:08:36 +00:00
math.ceil(x * 255) for x in colorsys.hls_to_rgb(*image_bg_color_hls)
2021-05-25 21:05:38 +00:00
)
2021-05-25 21:04:28 +00:00
else:
image_bg_color = BG_COLOR
# Background (using the color)
img = Image.new("RGBA", (IMG_WIDTH, IMG_HEIGHT), color=image_bg_color)
# Contents
2021-05-26 13:37:09 +00:00
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
2021-05-25 21:04:28 +00:00
content_width = IMG_WIDTH - content_x - margin
2021-05-25 21:05:38 +00:00
contents_layer = Image.new(
"RGBA", (content_width, IMG_HEIGHT), color=TRANSPARENT_COLOR
)
2021-05-25 21:04:28 +00:00
contents_composite_y = 0
2021-05-26 08:19:39 +00:00
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)
2021-05-25 21:04:28 +00:00
contents_layer.alpha_composite(texts_layer, (0, contents_composite_y))
2021-05-26 08:19:39 +00:00
contents_composite_y = contents_composite_y + texts_layer.height + gutter
2021-05-25 21:04:28 +00:00
if rating:
# Add some more margin
2021-05-26 08:19:39 +00:00
contents_composite_y = contents_composite_y + gutter
2021-05-25 21:04:28 +00:00
rating_layer = generate_rating_layer(rating, content_width)
2021-05-28 20:10:57 +00:00
if rating_layer:
contents_layer.alpha_composite(rating_layer, (0, contents_composite_y))
contents_composite_y = contents_composite_y + rating_layer.height + gutter
2021-05-25 21:04:28 +00:00
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)
2021-05-26 08:19:39 +00:00
if show_instance_layer:
# Remove Instance Layer from centering calculations
contents_y = contents_y - math.floor((instance_layer.height + gutter) / 2)
2021-05-25 21:05:38 +00:00
2021-06-18 22:08:36 +00:00
contents_y = max(contents_y, margin)
2021-05-25 21:04:28 +00:00
# Composite layers
2021-05-26 13:46:40 +00:00
img.paste(
inner_img_layer, (inner_img_x, inner_img_y), inner_img_layer.convert("RGBA")
)
2021-05-25 21:04:28 +00:00
img.alpha_composite(contents_layer, (content_x, contents_y))
2021-05-28 21:42:04 +00:00
return img.convert("RGB")
2021-05-26 07:44:32 +00:00
2021-05-26 10:54:57 +00:00
def save_and_cleanup(image, instance=None):
2021-06-18 22:08:36 +00:00
"""Save and close the file"""
if not isinstance(instance, (models.Book, models.User, models.SiteSettings)):
return False
image_buffer = BytesIO()
2021-05-26 10:54:57 +00:00
2021-06-18 22:08:36 +00:00
try:
2021-05-26 10:54:57 +00:00
try:
2021-11-20 15:23:50 +00:00
file_name = instance.preview_image.name
2021-06-18 22:08:36 +00:00
except ValueError:
2021-11-20 16:10:29 +00:00
file_name = None
2021-11-20 16:15:58 +00:00
if not file_name or file_name == "":
2021-11-20 15:26:02 +00:00
uuid = uuid4()
2021-11-20 15:23:50 +00:00
file_name = f"{instance.id}-{uuid}.jpg"
2021-06-18 22:08:36 +00:00
2021-11-20 15:59:05 +00:00
# Clean up old file before saving
if file_name and default_storage.exists(file_name):
default_storage.delete(file_name)
2021-06-18 22:08:36 +00:00
# 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"])
2021-06-18 22:08:36 +00:00
else:
instance.save(update_fields=["preview_image"])
2021-06-18 22:08:36 +00:00
finally:
image_buffer.close()
return True
2021-05-26 10:54:57 +00:00
2021-06-18 22:08:36 +00:00
# pylint: disable=invalid-name
@app.task(queue=LOW)
2021-05-26 08:19:39 +00:00
def generate_site_preview_image_task():
"""generate preview_image for the website"""
2021-06-18 22:08:36 +00:00
if not settings.ENABLE_PREVIEW_IMAGES:
return
2021-05-26 08:19:39 +00:00
2021-06-18 22:08:36 +00:00
site = models.SiteSettings.objects.get()
2021-05-26 08:19:39 +00:00
2021-06-18 22:08:36 +00:00
if site.logo:
logo = site.logo
else:
logo = os.path.join(settings.STATIC_ROOT, "images/logo.png")
2021-05-26 08:19:39 +00:00
2021-06-18 22:08:36 +00:00
texts = {
"text_zero": settings.DOMAIN,
"text_one": site.name,
"text_three": site.instance_tagline,
}
2021-06-18 22:24:10 +00:00
image = generate_preview_image(texts=texts, picture=logo, show_instance_layer=False)
2021-05-26 08:19:39 +00:00
2021-06-18 22:08:36 +00:00
save_and_cleanup(image, instance=site)
2021-05-26 08:19:39 +00:00
2021-06-18 22:08:36 +00:00
# pylint: disable=invalid-name
@app.task(queue=LOW)
2021-05-26 07:44:32 +00:00
def generate_edition_preview_image_task(book_id):
2021-05-26 08:19:39 +00:00
"""generate preview_image for a book"""
2021-06-18 22:08:36 +00:00
if not settings.ENABLE_PREVIEW_IMAGES:
return
book = models.Book.objects.select_subclasses().get(id=book_id)
2021-05-26 07:44:32 +00:00
2021-06-18 22:08:36 +00:00
rating = models.Review.objects.filter(
privacy="public",
deleted=False,
book__in=[book_id],
).aggregate(Avg("rating"))["rating__avg"]
2021-05-26 07:44:32 +00:00
2021-06-18 22:08:36 +00:00
texts = {
"text_one": book.title,
"text_two": book.subtitle,
"text_three": book.author_text,
}
2021-05-26 07:44:32 +00:00
2021-06-18 22:08:36 +00:00
image = generate_preview_image(texts=texts, picture=book.cover, rating=rating)
2021-05-26 07:44:32 +00:00
2021-06-18 22:08:36 +00:00
save_and_cleanup(image, instance=book)
2021-05-26 10:54:57 +00:00
2021-05-26 11:52:10 +00:00
@app.task(queue=LOW)
2021-05-26 10:54:57 +00:00
def generate_user_preview_image_task(user_id):
"""generate preview_image for a user"""
2021-06-18 22:08:36 +00:00
if not settings.ENABLE_PREVIEW_IMAGES:
return
2021-05-26 10:54:57 +00:00
2021-06-18 22:08:36 +00:00
user = models.User.objects.get(id=user_id)
2021-05-26 10:54:57 +00:00
if not user.local:
return
2021-06-18 22:08:36 +00:00
texts = {
"text_one": user.display_name,
"text_three": f"@{user.localname}@{settings.DOMAIN}",
2021-06-18 22:08:36 +00:00
}
if user.avatar:
avatar = user.avatar
else:
avatar = os.path.join(settings.STATIC_ROOT, "images/default_avi.jpg")
2021-05-26 10:54:57 +00:00
2021-06-18 22:08:36 +00:00
image = generate_preview_image(texts=texts, picture=avatar)
2021-06-18 22:08:36 +00:00
save_and_cleanup(image, instance=user)
@app.task(queue=LOW)
def remove_user_preview_image_task(user_id):
"""remove preview_image for a user"""
if not settings.ENABLE_PREVIEW_IMAGES:
return
user = models.User.objects.get(id=user_id)
try:
file_name = user.preview_image.name
except ValueError:
file_name = None
# Delete image in model
user.preview_image.delete(save=False)
user.save(broadcast=False, update_fields=["preview_image"])
# Delete image file
if file_name and default_storage.exists(file_name):
default_storage.delete(file_name)