diff --git a/.gitignore b/.gitignore index cf88e9878..624ce100c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ #nginx nginx/default.conf + +#macOS +**/.DS_Store diff --git a/bookwyrm/migrations/0076_book_preview_image.py b/bookwyrm/migrations/0076_book_preview_image.py new file mode 100644 index 000000000..070be663f --- /dev/null +++ b/bookwyrm/migrations/0076_book_preview_image.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2 on 2021-05-24 18:03 + +import bookwyrm.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0075_announcement"), + ] + + operations = [ + migrations.AddField( + model_name="edition", + name="preview_image", + field=bookwyrm.models.fields.ImageField( + blank=True, null=True, upload_to="previews/" + ), + ), + ] diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index 869ff04d2..72f0547bf 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -2,10 +2,13 @@ import re from django.db import models +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.settings import DOMAIN, DEFAULT_LANGUAGE +from bookwyrm.tasks import app from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin from .base_model import BookWyrmModel @@ -204,6 +207,9 @@ 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" @@ -293,3 +299,9 @@ def isbn_13_to_10(isbn_13): if checkdigit == 10: checkdigit = "X" return converted + str(checkdigit) + + +@receiver(models.signals.post_save, sender=Edition) +# pylint: disable=unused-argument +def preview_image(instance, *args, **kwargs): + generate_preview_image_task(instance, *args, **kwargs) diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py new file mode 100644 index 000000000..b659f678b --- /dev/null +++ b/bookwyrm/preview_images.py @@ -0,0 +1,133 @@ +import math +import textwrap + +from io import BytesIO +from PIL import Image, ImageDraw, ImageFont, ImageOps +from pathlib import Path +from uuid import uuid4 + +from django.core.files.base import ContentFile +from django.core.files.uploadedfile import InMemoryUploadedFile + +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 = (182, 186, 177) +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) +path = Path(__file__).parent.absolute() +font_path = path.joinpath("static/fonts/public_sans") + + +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) + except OSError: + font_title = ImageFont.load_default() + font_authors = ImageFont.load_default() + + text_layer = Image.new("RGBA", (IMG_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) + 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 = 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) + + +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() + + 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) + + site_layer = Image.new("RGBA", (IMG_WIDTH - text_x - margin, 50), color=BG_COLOR) + + logo_img.thumbnail((50, 50), Image.ANTIALIAS) + + site_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) + + return site_layer + + +def generate_preview_image(edition): + img = Image.new("RGBA", (IMG_WIDTH, IMG_HEIGHT), color=BG_COLOR) + + cover_img_layer = Image.open(edition.cover) + cover_img_layer.thumbnail((cover_img_limits, cover_img_limits), Image.ANTIALIAS) + + text_x = margin + cover_img_layer.width + gutter + + texts_layer = generate_texts_layer(edition, text_x) + text_y = IMG_HEIGHT - margin - texts_layer.height + + site_layer = generate_site_layer(text_x) + + # 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)) + + file_name = "%s.png" % str(uuid4()) + + image_buffer = BytesIO() + try: + img.save(image_buffer, format="png") + edition.preview_image = InMemoryUploadedFile( + ContentFile(image_buffer.getvalue()), + "preview_image", + file_name, + "image/png", + image_buffer.tell(), + None, + ) + + edition.save(update_fields=["preview_image"]) + finally: + image_buffer.close() + + +@app.task +def generate_preview_image_task(instance, *args, **kwargs): + """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) diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index d694e33fd..cee07e913 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -37,6 +37,11 @@ LOCALE_PATHS = [ DEFAULT_AUTO_FIELD = "django.db.models.AutoField" +# preview image + +PREVIEW_IMG_WIDTH = 1200 +PREVIEW_IMG_HEIGHT = 630 + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ diff --git a/bookwyrm/static/fonts/public_sans/OFL.txt b/bookwyrm/static/fonts/public_sans/OFL.txt new file mode 100644 index 000000000..ac793eaaa --- /dev/null +++ b/bookwyrm/static/fonts/public_sans/OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2015, Pablo Impallari, Rodrigo Fuenzalida (Modified by Dan O. Williams and USWDS) (https://github.com/uswds/public-sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/bookwyrm/static/fonts/public_sans/PublicSans-Bold.ttf b/bookwyrm/static/fonts/public_sans/PublicSans-Bold.ttf new file mode 100644 index 000000000..3eb5ac24e Binary files /dev/null and b/bookwyrm/static/fonts/public_sans/PublicSans-Bold.ttf differ diff --git a/bookwyrm/static/fonts/public_sans/PublicSans-Light.ttf b/bookwyrm/static/fonts/public_sans/PublicSans-Light.ttf new file mode 100644 index 000000000..13fd7edbc Binary files /dev/null and b/bookwyrm/static/fonts/public_sans/PublicSans-Light.ttf differ diff --git a/bookwyrm/static/fonts/public_sans/PublicSans-Regular.ttf b/bookwyrm/static/fonts/public_sans/PublicSans-Regular.ttf new file mode 100644 index 000000000..25c1646a4 Binary files /dev/null and b/bookwyrm/static/fonts/public_sans/PublicSans-Regular.ttf differ diff --git a/celerywyrm/celery.py b/celerywyrm/celery.py index 4af8e281d..1dad55888 100644 --- a/celerywyrm/celery.py +++ b/celerywyrm/celery.py @@ -24,5 +24,6 @@ app.autodiscover_tasks(["bookwyrm"], related_name="broadcast") app.autodiscover_tasks(["bookwyrm"], related_name="connectors.abstract_connector") app.autodiscover_tasks(["bookwyrm"], related_name="emailing") app.autodiscover_tasks(["bookwyrm"], related_name="goodreads_import") +app.autodiscover_tasks(["bookwyrm"], related_name="preview_images") app.autodiscover_tasks(["bookwyrm"], related_name="models.user") app.autodiscover_tasks(["bookwyrm"], related_name="views.inbox")