This commit is contained in:
Joachim 2021-05-25 23:04:28 +02:00
parent d1737b44bd
commit fa7334826c
9 changed files with 195 additions and 61 deletions

View file

@ -37,6 +37,7 @@ class Book(BookData):
publishedDate: str = ""
cover: Document = None
preview_image: Document = None
type: str = "Book"

View file

@ -12,10 +12,8 @@ class Migration(migrations.Migration):
operations = [
migrations.AddField(
model_name="edition",
name="preview_image",
field=bookwyrm.models.fields.ImageField(
blank=True, null=True, upload_to="previews/"
),
model_name='book',
name='preview_image',
field=bookwyrm.models.fields.ImageField(blank=True, null=True, upload_to='cover_previews/'),
),
]

View file

@ -6,7 +6,7 @@ from django.dispatch import receiver
from model_utils.managers import InheritanceManager
from bookwyrm import activitypub
from bookwyrm.preview_images import generate_preview_image_task
from bookwyrm.preview_images import generate_preview_image_from_edition_task
from bookwyrm.settings import DOMAIN, DEFAULT_LANGUAGE
from bookwyrm.tasks import app
@ -85,6 +85,9 @@ class Book(BookDataModel):
cover = fields.ImageField(
upload_to="covers/", blank=True, null=True, alt_field="alt_text"
)
preview_image = fields.ImageField(
upload_to="cover_previews/", blank=True, null=True, alt_field="alt_text"
)
first_published_date = fields.DateTimeField(blank=True, null=True)
published_date = fields.DateTimeField(blank=True, null=True)
@ -207,9 +210,6 @@ class Edition(Book):
activitypub_field="work",
)
edition_rank = fields.IntegerField(default=0)
preview_image = fields.ImageField(
upload_to="previews/", blank=True, null=True, alt_field="alt_text"
)
activity_serializer = activitypub.Edition
name_field = "title"
@ -302,6 +302,7 @@ def isbn_13_to_10(isbn_13):
@receiver(models.signals.post_save, sender=Edition)
# pylint: disable=unused-argument
def preview_image(instance, *args, **kwargs):
generate_preview_image_task(instance, *args, **kwargs)
def preview_image(instance, **kwargs):
updated_fields = kwargs["update_fields"]
generate_preview_image_from_edition_task.delay(instance.id, updated_fields)

View file

@ -1,13 +1,17 @@
import colorsys
import math
import os
import textwrap
from colorthief import ColorThief
from io import BytesIO
from PIL import Image, ImageDraw, ImageFont, ImageOps
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
@ -17,54 +21,64 @@ import logging
IMG_WIDTH = settings.PREVIEW_IMG_WIDTH
IMG_HEIGHT = settings.PREVIEW_IMG_HEIGHT
BG_COLOR = (182, 186, 177)
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)
TEXT_COLOR = (16, 16, 16)
margin = math.ceil(IMG_HEIGHT / 10)
gutter = math.ceil(margin / 2)
cover_img_limits = math.ceil(IMG_HEIGHT * 0.8)
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_path = path.joinpath("static/fonts/public_sans")
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
def generate_texts_layer(edition, text_x):
try:
font_title = ImageFont.truetype("%s/PublicSans-Bold.ttf" % font_path, 48)
font_authors = ImageFont.truetype("%s/PublicSans-Regular.ttf" % font_path, 40)
font = ImageFont.truetype(font_path, size)
except OSError:
font_title = ImageFont.load_default()
font_authors = ImageFont.load_default()
font = ImageFont.load_default()
text_layer = Image.new("RGBA", (IMG_WIDTH, IMG_HEIGHT), color=TRANSPARENT_COLOR)
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
text_y = text_y + 6
# title
title = textwrap.fill(edition.title, width=28)
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 = ", ".join(a.name for a in edition.authors.all())
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
)
imageBox = text_layer.getbbox()
return text_layer.crop(imageBox)
text_layer_box = text_layer.getbbox()
return text_layer.crop(text_layer_box)
def generate_site_layer(text_x):
try:
font_instance = ImageFont.truetype("%s/PublicSans-Light.ttf" % font_path, 28)
except OSError:
font_instance = ImageFont.load_default()
def generate_instance_layer(content_width):
font_instance = get_font("light", size=28)
site = models.SiteSettings.objects.get()
@ -74,42 +88,157 @@ def generate_site_layer(text_x):
static_path = path.joinpath("static/images/logo-small.png")
logo_img = Image.open(static_path)
site_layer = Image.new("RGBA", (IMG_WIDTH - text_x - margin, 50), color=BG_COLOR)
instance_layer = Image.new("RGBA", (content_width, 62), color=TRANSPARENT_COLOR)
logo_img.thumbnail((50, 50), Image.ANTIALIAS)
site_layer.paste(logo_img, (0, 0))
instance_layer.paste(logo_img, (0, 0))
site_layer_draw = ImageDraw.Draw(site_layer)
site_layer_draw.text((60, 10), site.name, font=font_instance, fill=TEXT_COLOR)
instance_layer_draw = ImageDraw.Draw(instance_layer)
instance_layer_draw.text((60, 10), site.name, font=font_instance, fill=TEXT_COLOR)
return site_layer
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_preview_image(edition):
img = Image.new("RGBA", (IMG_WIDTH, IMG_HEIGHT), color=BG_COLOR)
def generate_rating_layer(rating, content_width):
font_icons = get_font("icomoon", size=60)
cover_img_layer = Image.open(edition.cover)
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)
text_x = margin + cover_img_layer.width + gutter
# 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
texts_layer = generate_texts_layer(edition, text_x)
text_y = IMG_HEIGHT - margin - texts_layer.height
# Background (using the color)
img = Image.new("RGBA", (IMG_WIDTH, IMG_HEIGHT), color=image_bg_color)
site_layer = generate_site_layer(text_x)
# Contents
content_x = margin + cover_img_layer.width + gutter
content_width = IMG_WIDTH - content_x - margin
# Composite all layers
img.paste(cover_img_layer, (margin, margin))
img.alpha_composite(texts_layer, (text_x, text_y))
img.alpha_composite(site_layer, (text_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")
edition.preview_image = InMemoryUploadedFile(
book.preview_image = InMemoryUploadedFile(
ContentFile(image_buffer.getvalue()),
"preview_image",
file_name,
@ -117,17 +246,17 @@ def generate_preview_image(edition):
image_buffer.tell(),
None,
)
book.save(update_fields=["preview_image"])
edition.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_task(instance, *args, **kwargs):
def generate_preview_image_from_edition_task(book_id, updated_fields=None):
"""generate preview_image after save"""
updated_fields = kwargs["update_fields"]
if not updated_fields or "preview_image" not in updated_fields:
logging.warn("image name to delete", instance.preview_image.name)
generate_preview_image(edition=instance)
generate_preview_image(book_id=book_id)

View file

@ -37,10 +37,14 @@ LOCALE_PATHS = [
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# preview image
# Preview image
# Specify RGB tuple or RGB hex strings, or 'use_dominant_color'
PREVIEW_BG_COLOR = 'use_dominant_color'
PREVIEW_IMG_WIDTH = 1200
PREVIEW_IMG_HEIGHT = 630
PREVIEW_TEXT_COLOR = '#363636'
PREVIEW_DEFAULT_COVER_COLOR = '#002549'
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,4 +1,5 @@
celery==4.4.2
colorthief==0.2.1
Django==3.2.0
django-model-utils==4.0.0
environs==7.2.0