bookwyrm/bookwyrm/preview_images.py
2021-05-25 23:04:28 +02:00

263 lines
8.9 KiB
Python

import colorsys
import math
import os
import textwrap
from colorthief import ColorThief
from io import BytesIO
from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageColor
from pathlib import Path
from uuid import uuid4
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import InMemoryUploadedFile
from django.db.models import Avg
from bookwyrm import models, settings
from bookwyrm.tasks import app
# dev
import logging
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
TRANSPARENT_COLOR = (0, 0, 0, 0)
margin = math.floor(IMG_HEIGHT / 10)
gutter = math.floor(margin / 2)
cover_img_limits = math.floor(IMG_HEIGHT * 0.8)
path = Path(__file__).parent.absolute()
font_dir = path.joinpath("static/fonts/public_sans")
icon_font_dir = path.joinpath("static/css/fonts")
def get_font(font_name, size=28):
if font_name == "light":
font_path = "%s/PublicSans-Light.ttf" % font_dir
if font_name == "regular":
font_path = "%s/PublicSans-Regular.ttf" % font_dir
elif font_name == "bold":
font_path = "%s/PublicSans-Bold.ttf" % font_dir
elif font_name == "icomoon":
font_path = "%s/icomoon.ttf" % icon_font_dir
try:
font = ImageFont.truetype(font_path, size)
except OSError:
font = ImageFont.load_default()
return font
def generate_texts_layer(book, content_width):
font_title = get_font("bold", size=48)
font_authors = 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
# title
title = textwrap.fill(book.title, width=28)
text_layer_draw.multiline_text((0, text_y), title, font=font_title, fill=TEXT_COLOR)
text_y = text_y + font_title.getsize_multiline(title)[1] + 16
# subtitle
authors_text = book.author_text
authors = textwrap.fill(authors_text, width=36)
text_layer_draw.multiline_text(
(0, text_y), authors, font=font_authors, fill=TEXT_COLOR
)
text_layer_box = text_layer.getbbox()
return text_layer.crop(text_layer_box)
def generate_instance_layer(content_width):
font_instance = get_font("light", size=28)
site = models.SiteSettings.objects.get()
if site.logo_small:
logo_img = Image.open(site.logo_small)
else:
static_path = path.joinpath("static/images/logo-small.png")
logo_img = Image.open(static_path)
instance_layer = Image.new("RGBA", (content_width, 62), color=TRANSPARENT_COLOR)
logo_img.thumbnail((50, 50), Image.ANTIALIAS)
instance_layer.paste(logo_img, (0, 0))
instance_layer_draw = ImageDraw.Draw(instance_layer)
instance_layer_draw.text((60, 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):
font_icons = get_font("icomoon", size=60)
icon_star_full = Image.open(path.joinpath("static/images/icons/star-full.png"))
icon_star_empty = Image.open(path.joinpath("static/images/icons/star-empty.png"))
icon_star_half = Image.open(path.joinpath("static/images/icons/star-half.png"))
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 r 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 r 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_cover():
font_cover = get_font("light", size=28)
cover_width = math.floor(cover_img_limits * .7)
default_cover = Image.new("RGB", (cover_width, cover_img_limits), color=DEFAULT_COVER_COLOR)
default_cover_draw = ImageDraw.Draw(default_cover)
text = "no cover :("
text_dimensions = font_cover.getsize(text)
text_coords = (math.floor((cover_width - text_dimensions[0]) / 2),
math.floor((cover_img_limits - text_dimensions[1]) / 2))
default_cover_draw.text(text_coords, text, font=font_cover, fill='white')
return default_cover
def generate_preview_image(book_id, rating=None):
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"]
# Cover
try:
cover_img_layer = Image.open(book.cover)
cover_img_layer.thumbnail((cover_img_limits, cover_img_limits), Image.ANTIALIAS)
color_thief = ColorThief(book.cover)
dominant_color = color_thief.get_color(quality=1)
except:
cover_img_layer = generate_default_cover()
dominant_color = ImageColor.getrgb(DEFAULT_COVER_COLOR)
# Color
if BG_COLOR == 'use_dominant_color':
image_bg_color = "rgb(%s, %s, %s)" % dominant_color
# Lighten 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)
image_bg_color_hls = (image_bg_color_hls[0], 0.9, image_bg_color_hls[1])
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
content_x = margin + cover_img_layer.width + gutter
content_width = IMG_WIDTH - content_x - margin
instance_layer = generate_instance_layer(content_width)
texts_layer = generate_texts_layer(book, content_width)
contents_layer = Image.new("RGBA", (content_width, IMG_HEIGHT), color=TRANSPARENT_COLOR)
contents_composite_y = 0
contents_layer.alpha_composite(instance_layer, (0, contents_composite_y))
contents_composite_y = contents_composite_y + instance_layer.height + gutter
contents_layer.alpha_composite(texts_layer, (0, contents_composite_y))
contents_composite_y = contents_composite_y + texts_layer.height + 30
if rating:
# Add some more margin
contents_composite_y = contents_composite_y + 30
rating_layer = generate_rating_layer(rating, content_width)
contents_layer.alpha_composite(rating_layer, (0, contents_composite_y))
contents_composite_y = contents_composite_y + rating_layer.height + 30
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)
# Remove Instance Layer from centering calculations
contents_y = contents_y - math.floor((instance_layer.height + gutter) / 2)
if contents_y < margin:
contents_y = margin
cover_y = math.floor((IMG_HEIGHT - cover_img_layer.height) / 2)
# Composite layers
img.paste(cover_img_layer, (margin, cover_y))
img.alpha_composite(contents_layer, (content_x, contents_y))
file_name = "%s.png" % str(uuid4())
image_buffer = BytesIO()
try:
try:
old_path = book.preview_image.path
except ValueError:
old_path = ''
# Save
img.save(image_buffer, format="png")
book.preview_image = InMemoryUploadedFile(
ContentFile(image_buffer.getvalue()),
"preview_image",
file_name,
"image/png",
image_buffer.tell(),
None,
)
book.save(update_fields=["preview_image"])
# Clean up old file after saving
if os.path.exists(old_path):
os.remove(old_path)
finally:
image_buffer.close()
@app.task
def generate_preview_image_from_edition_task(book_id, updated_fields=None):
"""generate preview_image after save"""
if not updated_fields or "preview_image" not in updated_fields:
generate_preview_image(book_id=book_id)