From d1737b44bdad3ad435721c6363dca18ac3e4f94b Mon Sep 17 00:00:00 2001 From: Joachim Date: Tue, 25 May 2021 00:41:53 +0200 Subject: [PATCH 01/55] First functioning commit TODO - [ ] Delay task (Celery?) - [ ] Store the image in a subfolder unique to the edition, to make cleaning up the image easy - [ ] Clean up the image before replacing it - [ ] Ensure that the image will be cleaned when the edition is deleted ?? - [ ] Use instance custom colors? - [ ] Use book cover color base? --- .gitignore | 3 + .../migrations/0076_book_preview_image.py | 21 +++ bookwyrm/models/book.py | 12 ++ bookwyrm/preview_images.py | 133 ++++++++++++++++++ bookwyrm/settings.py | 5 + bookwyrm/static/fonts/public_sans/OFL.txt | 93 ++++++++++++ .../fonts/public_sans/PublicSans-Bold.ttf | Bin 0 -> 56580 bytes .../fonts/public_sans/PublicSans-Light.ttf | Bin 0 -> 56452 bytes .../fonts/public_sans/PublicSans-Regular.ttf | Bin 0 -> 56424 bytes celerywyrm/celery.py | 1 + 10 files changed, 268 insertions(+) create mode 100644 bookwyrm/migrations/0076_book_preview_image.py create mode 100644 bookwyrm/preview_images.py create mode 100644 bookwyrm/static/fonts/public_sans/OFL.txt create mode 100644 bookwyrm/static/fonts/public_sans/PublicSans-Bold.ttf create mode 100644 bookwyrm/static/fonts/public_sans/PublicSans-Light.ttf create mode 100644 bookwyrm/static/fonts/public_sans/PublicSans-Regular.ttf 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 0000000000000000000000000000000000000000..3eb5ac24e2b78297531f6ac6c31255a306af980d GIT binary patch literal 56580 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�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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..13fd7edbceff6e379e28d7ca78b17822b1753d0c GIT binary patch literal 56452 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..25c1646a40bfcbf8d0ebc8f8a20406fec29da05e GIT binary patch literal 56424 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 literal 0 HcmV?d00001 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") From fa7334826c2df4dec35419ee9c596898f3b40998 Mon Sep 17 00:00:00 2001 From: Joachim Date: Tue, 25 May 2021 23:04:28 +0200 Subject: [PATCH 02/55] Update --- bookwyrm/activitypub/book.py | 1 + .../migrations/0076_book_preview_image.py | 8 +- bookwyrm/models/book.py | 15 +- bookwyrm/preview_images.py | 225 ++++++++++++++---- bookwyrm/settings.py | 6 +- bookwyrm/static/images/icons/star-empty.png | Bin 0 -> 1200 bytes bookwyrm/static/images/icons/star-full.png | Bin 0 -> 923 bytes bookwyrm/static/images/icons/star-half.png | Bin 0 -> 1153 bytes requirements.txt | 1 + 9 files changed, 195 insertions(+), 61 deletions(-) create mode 100755 bookwyrm/static/images/icons/star-empty.png create mode 100755 bookwyrm/static/images/icons/star-full.png create mode 100755 bookwyrm/static/images/icons/star-half.png diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py index 1599b408a..ccb4c0ea3 100644 --- a/bookwyrm/activitypub/book.py +++ b/bookwyrm/activitypub/book.py @@ -37,6 +37,7 @@ class Book(BookData): publishedDate: str = "" cover: Document = None + preview_image: Document = None type: str = "Book" diff --git a/bookwyrm/migrations/0076_book_preview_image.py b/bookwyrm/migrations/0076_book_preview_image.py index 070be663f..c068e2e27 100644 --- a/bookwyrm/migrations/0076_book_preview_image.py +++ b/bookwyrm/migrations/0076_book_preview_image.py @@ -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/'), ), ] diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index 72f0547bf..af3005606 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -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) diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py index b659f678b..d0da30839 100644 --- a/bookwyrm/preview_images.py +++ b/bookwyrm/preview_images.py @@ -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) - cover_img_layer.thumbnail((cover_img_limits, cover_img_limits), Image.ANTIALIAS) + 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")) - text_x = margin + cover_img_layer.width + gutter + icon_size = 64 + icon_margin = 10 - texts_layer = generate_texts_layer(edition, text_x) - text_y = IMG_HEIGHT - margin - texts_layer.height + 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) - site_layer = generate_site_layer(text_x) + position_x = 0 - # 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)) + 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") - 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) diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index cee07e913..cef11630e 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -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/ diff --git a/bookwyrm/static/images/icons/star-empty.png b/bookwyrm/static/images/icons/star-empty.png new file mode 100755 index 0000000000000000000000000000000000000000..896417ef69cb053e349720a11b12d7900154011b GIT binary patch literal 1200 zcmV;h1W)^kP)P5Yc6Q#r zdy|~+{O8_1k^g+0ESCRzSpWB`eXIuXG6=4}r*l{d0PTMd65ke)C04K!09t<$0K5{B zC04KsfNDRE0XL@Gzlf{?@JvKr8-Y(nT8ipOtpfU08-i~|1-26m~I6#0i?9AnEf>|iXXU{@m!=#et-ecPhL1%01)P85Dp;`wj^XuIkno~iUqZbS2#cc4v>BZV3+0q zzgcl%247$Uo zW@4A-bq=ou=>0(WWi~`CV51{O>dg{ft!Xm*cw@Z)h{Ruvq%=*$h!F!Hr^$)Oy_TS7 zH`NOu<&#|+DNsvUTvy=j1z-;^X*c!FuMrl&oS1vuu6vH#4twhbfT?q)sN-`;MWcjg z-d)%11z?~2jLZNE)ny6fK~%yH_NNpa84`M7imPc9*vP5sNyi;T&5txGcU1Z ztGEFYPpx#gGeHl4+Gt!OxP@E-QM-20rFsBR0>_$6X#pSK(s*fufS}gEBI04|53X~) zZSDn7s>pbz$0oO20+-P^fd9uPZNXsj%=aODv0DY|1(1ux2vH+A*VtF%5sJUpfB;@k z208%t0!ZazPGM+{a)V8$1h?1MfJm+7>O#E$Qhj+&aQXwTZ~2!PTnxzk*vE9abzD`b z7l4hzsZFWi2=DFJTR^z_5HA3m0W1JALXY5fJ_TW4r;V?3WdaWXBD?;(o82f;8dI;S z=1M6r^*w7df9}nZ=CcS7fK&z;Ex3h|1JT?QcmTvj2!2PPsy>JJsD0xevJS4MXA;dN zu?K*@{|AUsXMe4nuu643+hlFdqU!Bei$v^Nic-c4Sq`~Lx3cQx;1#*kzH O0000gjN7J_HSike*j=E2yHC@JOS`-LS=Xq zv;Z*Xed7@x1pm=wPR z;0O?A#g_osGlWI)r2zH_p-6lUfIUGd7GDcs4-mNM;UuSc$*e4gxme<4=}u=erEh7H zQ+3ir0YKfoL6F2NLtyCiULeTgy#Oo{el;EnJX%u~_CtHsAz=65Z1GED0QYK=UT&)w zKo9!}uJZ(50K=nhtpdSy#Lf$V<&b-dE!*c*{v;c`0M>Z&BN;yEKU#sL;4d8iFJpUQaVI=^p|1D{JQ$&_{!AbyF{aFI=LPVB$ z!72dR`eh8b7ViHdvI@Wx5qYT;J{6I-(GXSvVC~;f%6=CSVnOIw0pO8{yxGt)+zVC! zs98Vr2={_$0DIQY5JJ5m8bHnZzeVIqu_QvhAQHfq^{D}JJrM2%kpQ;VKKUQ3FY$sX z0DEg68Oo|K)C-~j>_vWLpdwu21rY#Rkw43@#0w$->{%ao&oV6Wf};VntX~4)@8Mo> zG=P@%HGpt0I1)gQ^)-NSFE|3g?Ud7LZFR?2>w&ZkTV4QAs>Uxf+eI&ctmM6v!uU7J z_9(37ugOr01?6+3On!jDR@z4|oIL<2b6rGG4y&N7SIIr2)nur7L9gL69H0OPNIw#= zOYdOvyGltWN9m}sWdxSc!^uLF9&-kV$$Nv%idzHNI)=E+w==#4{p z`ISo&Z}q}C+jl4@XN|vCUD-k4FTA!Fz@98MI5~#t=g1@vQsBI_D-2zNN7DlF5AgfJ6;N%=hhT#JG*3xHYq7Jw)SGXS`h z?*R}2VHAK{`Cb4=Ll^-tq`U>dkr2E9hLyJhI0}LXz*gl-(?gNd-7>j{AHbFf*k z$WS|3!lyNp+n4fMCa>v9B>o%-fJTNUryO@IL2onw>E~zcBUp@3^SGh&1eVEbhHh|aos9IEfZMZaW0no!Q^&2B6sL}=@z!XS;ttiXVfym{k+H5^MYAva7G3wWwC(J zp6AX4BOXwz%B%*^_Y>iH@9Rc!gSYV2Di3!i7y&RN18f0nftZ#FMgUL(=hNP!mfr(G zt$|0BL)RYwbDcK#0+`VQ(7O{y7_9%&r8N*-9;M%<3$^&;>Iu96YLU3t3s4+o^wD^f z;{SU<@D81UT$#WNpq7hU0Jap@&-H*{$47s`ewqSkOO6ZIy#Op3fX`9fZ~2uNmKc!v z>HSPcoZx=b@B-+`09kRA_x9@+5U!rU3!s$&$jc*&Yb$0D`cQRwpDPo10Pxv4|GJwN zlqij>_m;ILcTI;Sc>mVP5pyQ+0H|evqZQYnFhUK4ITLsQltl>Zj=E+_8OD+!p-0!G0-X-+TO=j6V07ixUr=I@{g Date: Tue, 25 May 2021 23:05:38 +0200 Subject: [PATCH 03/55] Thank you Black --- .../migrations/0076_book_preview_image.py | 8 ++- bookwyrm/preview_images.py | 59 ++++++++++++------- bookwyrm/settings.py | 6 +- 3 files changed, 46 insertions(+), 27 deletions(-) diff --git a/bookwyrm/migrations/0076_book_preview_image.py b/bookwyrm/migrations/0076_book_preview_image.py index c068e2e27..bc756f898 100644 --- a/bookwyrm/migrations/0076_book_preview_image.py +++ b/bookwyrm/migrations/0076_book_preview_image.py @@ -12,8 +12,10 @@ class Migration(migrations.Migration): operations = [ migrations.AddField( - model_name='book', - name='preview_image', - field=bookwyrm.models.fields.ImageField(blank=True, null=True, upload_to='cover_previews/'), + model_name="book", + name="preview_image", + field=bookwyrm.models.fields.ImageField( + blank=True, null=True, upload_to="cover_previews/" + ), ), ] diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py index d0da30839..960762701 100644 --- a/bookwyrm/preview_images.py +++ b/bookwyrm/preview_images.py @@ -33,6 +33,7 @@ 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 @@ -99,7 +100,9 @@ def generate_instance_layer(content_width): line_width = 50 + 10 + font_instance.getsize(site.name)[0] - line_layer = Image.new("RGBA", (line_width, 2), color=(*(ImageColor.getrgb(TEXT_COLOR)), 50)) + 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 @@ -115,9 +118,13 @@ def generate_rating_layer(rating, content_width): icon_size = 64 icon_margin = 10 - rating_layer_base = Image.new("RGBA", (content_width, icon_size), color=TRANSPARENT_COLOR) + 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) + rating_layer_mask = Image.new( + "RGBA", (content_width, icon_size), color=TRANSPARENT_COLOR + ) position_x = 0 @@ -136,7 +143,9 @@ def generate_rating_layer(rating, content_width): 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) + rating_layer_composite = Image.composite( + rating_layer_base, rating_layer_color, rating_layer_mask + ) return rating_layer_composite @@ -144,15 +153,19 @@ def generate_rating_layer(rating, content_width): 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) + cover_width = math.floor(cover_img_limits * 0.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') + 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 @@ -168,22 +181,24 @@ def generate_preview_image(book_id, rating=None): # 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) + 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) + cover_img_layer = generate_default_cover() + dominant_color = ImageColor.getrgb(DEFAULT_COVER_COLOR) # Color - if BG_COLOR == 'use_dominant_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_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)]) + 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 @@ -197,7 +212,9 @@ def generate_preview_image(book_id, rating=None): 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_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 @@ -217,7 +234,7 @@ def generate_preview_image(book_id, rating=None): 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 @@ -234,7 +251,7 @@ def generate_preview_image(book_id, rating=None): try: old_path = book.preview_image.path except ValueError: - old_path = '' + old_path = "" # Save img.save(image_buffer, format="png") diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index cef11630e..db15be468 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -40,11 +40,11 @@ DEFAULT_AUTO_FIELD = "django.db.models.AutoField" # Preview image # Specify RGB tuple or RGB hex strings, or 'use_dominant_color' -PREVIEW_BG_COLOR = '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' +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/ From e305c5d73d42aea0ed51d2053d3766d697fe26c0 Mon Sep 17 00:00:00 2001 From: Joachim Date: Tue, 25 May 2021 23:12:54 +0200 Subject: [PATCH 04/55] Fix color --- bookwyrm/preview_images.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py index 960762701..6372aa540 100644 --- a/bookwyrm/preview_images.py +++ b/bookwyrm/preview_images.py @@ -195,7 +195,7 @@ def generate_preview_image(book_id, rating=None): # 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_hls = (image_bg_color_hls[0], 0.9, image_bg_color_hls[2]) image_bg_color = tuple( [math.ceil(x * 255) for x in colorsys.hls_to_rgb(*image_bg_color_hls)] ) From 5b03934ec3e243043906dfbd8b1e5b24acf5bebb Mon Sep 17 00:00:00 2001 From: Joachim Date: Tue, 25 May 2021 23:16:33 +0200 Subject: [PATCH 05/55] Update preview_images.py --- bookwyrm/preview_images.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py index 6372aa540..31b2dc279 100644 --- a/bookwyrm/preview_images.py +++ b/bookwyrm/preview_images.py @@ -195,7 +195,11 @@ def generate_preview_image(book_id, rating=None): # 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[2]) + image_bg_color_hls = ( + image_bg_color_hls[0], + max(0.9, image_bg_color_hls[1]), + image_bg_color_hls[2], + ) image_bg_color = tuple( [math.ceil(x * 255) for x in colorsys.hls_to_rgb(*image_bg_color_hls)] ) From 8c25272462ccaa440d2e99e13bd22e844ce73082 Mon Sep 17 00:00:00 2001 From: Joachim Date: Wed, 26 May 2021 09:09:13 +0200 Subject: [PATCH 06/55] Fix last night's bugs --- bookwyrm/activitypub/book.py | 1 - bookwyrm/migrations/0076_book_preview_image.py | 4 +--- bookwyrm/models/book.py | 10 ++++++---- bookwyrm/preview_images.py | 7 +++---- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py index ccb4c0ea3..1599b408a 100644 --- a/bookwyrm/activitypub/book.py +++ b/bookwyrm/activitypub/book.py @@ -37,7 +37,6 @@ class Book(BookData): publishedDate: str = "" cover: Document = None - preview_image: Document = None type: str = "Book" diff --git a/bookwyrm/migrations/0076_book_preview_image.py b/bookwyrm/migrations/0076_book_preview_image.py index bc756f898..01ff6576c 100644 --- a/bookwyrm/migrations/0076_book_preview_image.py +++ b/bookwyrm/migrations/0076_book_preview_image.py @@ -14,8 +14,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name="book", name="preview_image", - field=bookwyrm.models.fields.ImageField( - blank=True, null=True, upload_to="cover_previews/" - ), + field=models.ImageField(blank=True, null=True, upload_to="cover_previews/"), ), ] diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index af3005606..5298af929 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -85,8 +85,8 @@ 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" + preview_image = models.ImageField( + upload_to="cover_previews/", blank=True, null=True ) first_published_date = fields.DateTimeField(blank=True, null=True) published_date = fields.DateTimeField(blank=True, null=True) @@ -302,7 +302,9 @@ def isbn_13_to_10(isbn_13): @receiver(models.signals.post_save, sender=Edition) -def preview_image(instance, **kwargs): +# pylint: disable=unused-argument +def preview_image(instance, *args, **kwargs): updated_fields = kwargs["update_fields"] - generate_preview_image_from_edition_task.delay(instance.id, updated_fields) + if not updated_fields or "preview_image" not in updated_fields: + generate_preview_image_from_edition_task.delay(instance.id) diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py index 31b2dc279..dd4a62141 100644 --- a/bookwyrm/preview_images.py +++ b/bookwyrm/preview_images.py @@ -277,7 +277,6 @@ def generate_preview_image(book_id, rating=None): @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) +def generate_preview_image_from_edition_task(book_id): + """generate preview_image""" + generate_preview_image(book_id=book_id) From a83aa47c9aded79242bc9fa606faf16c0d3a5410 Mon Sep 17 00:00:00 2001 From: Joachim Date: Wed, 26 May 2021 09:10:05 +0200 Subject: [PATCH 07/55] Generate on new rating --- bookwyrm/models/status.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index bd21ec563..987261e46 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -5,11 +5,13 @@ import re from django.apps import apps from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.dispatch import receiver from django.template.loader import get_template from django.utils import timezone from model_utils.managers import InheritanceManager from bookwyrm import activitypub +from bookwyrm.preview_images import generate_preview_image_from_edition_task from .activitypub_mixin import ActivitypubMixin, ActivityMixin from .activitypub_mixin import OrderedCollectionPageMixin from .base_model import BookWyrmModel @@ -398,3 +400,11 @@ class Boost(ActivityMixin, Status): # This constraint can't work as it would cross tables. # class Meta: # unique_together = ('user', 'boosted_status') + + +@receiver(models.signals.post_save) +# pylint: disable=unused-argument +def preview_image(instance, sender, *args, **kwargs): + if sender in (Review, ReviewRating): + edition = instance.book + generate_preview_image_from_edition_task.delay(edition.id) From fd82567cbf28dadf3e784d92de0d3a48c103ba90 Mon Sep 17 00:00:00 2001 From: Joachim Date: Wed, 26 May 2021 09:16:15 +0200 Subject: [PATCH 08/55] Update 0076_book_preview_image.py --- bookwyrm/migrations/0076_book_preview_image.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bookwyrm/migrations/0076_book_preview_image.py b/bookwyrm/migrations/0076_book_preview_image.py index 01ff6576c..5db550507 100644 --- a/bookwyrm/migrations/0076_book_preview_image.py +++ b/bookwyrm/migrations/0076_book_preview_image.py @@ -1,7 +1,6 @@ # Generated by Django 3.2 on 2021-05-24 18:03 -import bookwyrm.models.fields -from django.db import migrations +from django.db import migrations, models class Migration(migrations.Migration): From 101ca0ff81ee4410e7fd56fb509197d75db162fa Mon Sep 17 00:00:00 2001 From: Joachim Date: Wed, 26 May 2021 09:44:32 +0200 Subject: [PATCH 09/55] Refactor some --- bookwyrm/models/book.py | 4 +- bookwyrm/models/status.py | 4 +- bookwyrm/preview_images.py | 99 +++++++++++++++++++++++--------------- 3 files changed, 65 insertions(+), 42 deletions(-) diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index 5298af929..01dfbba72 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -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_from_edition_task +from bookwyrm.preview_images import generate_edition_preview_image_task from bookwyrm.settings import DOMAIN, DEFAULT_LANGUAGE from bookwyrm.tasks import app @@ -307,4 +307,4 @@ def preview_image(instance, *args, **kwargs): updated_fields = kwargs["update_fields"] if not updated_fields or "preview_image" not in updated_fields: - generate_preview_image_from_edition_task.delay(instance.id) + generate_edition_preview_image_task.delay(instance.id) diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index 987261e46..c55cd8d69 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -11,7 +11,7 @@ from django.utils import timezone from model_utils.managers import InheritanceManager from bookwyrm import activitypub -from bookwyrm.preview_images import generate_preview_image_from_edition_task +from bookwyrm.preview_images import generate_edition_preview_image_task from .activitypub_mixin import ActivitypubMixin, ActivityMixin from .activitypub_mixin import OrderedCollectionPageMixin from .base_model import BookWyrmModel @@ -407,4 +407,4 @@ class Boost(ActivityMixin, Status): def preview_image(instance, sender, *args, **kwargs): if sender in (Review, ReviewRating): edition = instance.book - generate_preview_image_from_edition_task.delay(edition.id) + generate_edition_preview_image_task.delay(edition.id) diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py index dd4a62141..82e8dc798 100644 --- a/bookwyrm/preview_images.py +++ b/bookwyrm/preview_images.py @@ -31,7 +31,6 @@ 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): @@ -41,8 +40,6 @@ def get_font(font_name, size=28): 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) @@ -52,27 +49,44 @@ def get_font(font_name, size=28): return font -def generate_texts_layer(book, content_width): - font_title = get_font("bold", size=48) - font_authors = get_font("regular", size=40) +def generate_texts_layer(texts, content_width): + 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 - # title - title = textwrap.fill(book.title, width=28) - text_layer_draw.multiline_text((0, text_y), title, font=font_title, fill=TEXT_COLOR) + if 'text_zero' in texts: + # Text one (Book title) + text_zero = textwrap.fill(texts['text_zero'], width=72) + text_layer_draw.multiline_text((0, text_y), text_zero, font=font_text_zero, fill=TEXT_COLOR) - text_y = text_y + font_title.getsize_multiline(title)[1] + 16 + text_y = text_y + font_text_zero.getsize_multiline(text_zero)[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 - ) + if 'text_one' in texts: + # Text one (Book title) + text_one = textwrap.fill(texts['text_one'], width=28) + text_layer_draw.multiline_text((0, text_y), text_one, font=font_text_one, fill=TEXT_COLOR) + + text_y = text_y + font_text_one.getsize_multiline(text_one)[1] + 16 + + if 'text_two' in texts: + # Text one (Book subtitle) + text_two = textwrap.fill(texts['text_two'], width=36) + text_layer_draw.multiline_text((0, text_y), text_two, font=font_text_two, fill=TEXT_COLOR) + + text_y = text_y + font_text_one.getsize_multiline(text_two)[1] + 16 + + if 'text_three' in texts: + # Text three (Book authors) + text_three = textwrap.fill(texts['text_three'], width=36) + 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) @@ -109,8 +123,6 @@ def generate_instance_layer(content_width): 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")) @@ -159,7 +171,7 @@ def generate_default_cover(): ) default_cover_draw = ImageDraw.Draw(default_cover) - text = "no cover :(" + text = "no image :(" text_dimensions = font_cover.getsize(text) text_coords = ( math.floor((cover_width - text_dimensions[0]) / 2), @@ -170,20 +182,12 @@ def generate_default_cover(): 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"] - +def generate_preview_image(book, texts={}, picture=None, rating=None): # Cover try: - cover_img_layer = Image.open(book.cover) + cover_img_layer = Image.open(picture) cover_img_layer.thumbnail((cover_img_limits, cover_img_limits), Image.ANTIALIAS) - color_thief = ColorThief(book.cover) + color_thief = ColorThief(picture) dominant_color = color_thief.get_color(quality=1) except: cover_img_layer = generate_default_cover() @@ -214,7 +218,7 @@ def generate_preview_image(book_id, rating=None): content_width = IMG_WIDTH - content_x - margin instance_layer = generate_instance_layer(content_width) - texts_layer = generate_texts_layer(book, content_width) + texts_layer = generate_texts_layer(texts, content_width) contents_layer = Image.new( "RGBA", (content_width, IMG_HEIGHT), color=TRANSPARENT_COLOR @@ -248,8 +252,33 @@ def generate_preview_image(book_id, rating=None): img.paste(cover_img_layer, (margin, cover_y)) img.alpha_composite(contents_layer, (content_x, contents_y)) - file_name = "%s.png" % str(uuid4()) + return img + +@app.task +def generate_edition_preview_image_task(book_id): + """generate preview_image""" + 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_zero': "ADDED A REVIEW", + 'text_one': book.title, + 'text_two': book.subtitle, + 'text_three': book.author_text + } + + img = generate_preview_image(book=book, + texts=texts, + picture=book.cover, + rating=rating) + + file_name = "%s.png" % str(uuid4()) image_buffer = BytesIO() try: try: @@ -274,9 +303,3 @@ def generate_preview_image(book_id, rating=None): os.remove(old_path) finally: image_buffer.close() - - -@app.task -def generate_preview_image_from_edition_task(book_id): - """generate preview_image""" - generate_preview_image(book_id=book_id) From 34caa36ab70baa51bdeea11cc396289be0e0eebd Mon Sep 17 00:00:00 2001 From: Joachim Date: Wed, 26 May 2021 10:19:39 +0200 Subject: [PATCH 10/55] Add site preview task --- bookwyrm/models/book.py | 2 +- bookwyrm/models/site.py | 13 +++++++ bookwyrm/preview_images.py | 80 +++++++++++++++++++++++++++++++------- 3 files changed, 79 insertions(+), 16 deletions(-) diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index 01dfbba72..aa9a56017 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -86,7 +86,7 @@ class Book(BookDataModel): upload_to="covers/", blank=True, null=True, alt_field="alt_text" ) preview_image = models.ImageField( - upload_to="cover_previews/", blank=True, null=True + upload_to="previews/covers/", blank=True, null=True ) first_published_date = fields.DateTimeField(blank=True, null=True) published_date = fields.DateTimeField(blank=True, null=True) diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py index 2c5a21642..dc226bce4 100644 --- a/bookwyrm/models/site.py +++ b/bookwyrm/models/site.py @@ -4,9 +4,12 @@ import datetime from Crypto import Random from django.db import models, IntegrityError +from django.dispatch import receiver from django.utils import timezone +from bookwyrm.preview_images import generate_site_preview_image_task from bookwyrm.settings import DOMAIN +from bookwyrm.tasks import app from .base_model import BookWyrmModel from .user import User @@ -35,6 +38,7 @@ class SiteSettings(models.Model): logo = models.ImageField(upload_to="logos/", null=True, blank=True) logo_small = models.ImageField(upload_to="logos/", null=True, blank=True) favicon = models.ImageField(upload_to="logos/", null=True, blank=True) + preview_image = models.ImageField(upload_to="previews/logos/", null=True, blank=True) # footer support_link = models.CharField(max_length=255, null=True, blank=True) @@ -119,3 +123,12 @@ class PasswordReset(models.Model): def link(self): """formats the invite link""" return "https://{}/password-reset/{}".format(DOMAIN, self.code) + + +@receiver(models.signals.post_save, sender=SiteSettings) +# pylint: disable=unused-argument +def preview_image(instance, *args, **kwargs): + updated_fields = kwargs["update_fields"] + + if not updated_fields or "preview_image" not in updated_fields: + generate_site_preview_image_task.delay() diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py index 82e8dc798..949ffac12 100644 --- a/bookwyrm/preview_images.py +++ b/bookwyrm/preview_images.py @@ -14,6 +14,7 @@ from django.core.files.uploadedfile import InMemoryUploadedFile from django.db.models import Avg from bookwyrm import models, settings +from bookwyrm.settings import DOMAIN from bookwyrm.tasks import app # dev @@ -182,7 +183,7 @@ def generate_default_cover(): return default_cover -def generate_preview_image(book, texts={}, picture=None, rating=None): +def generate_preview_image(texts={}, picture=None, rating=None, show_instance_layer=True): # Cover try: cover_img_layer = Image.open(picture) @@ -217,31 +218,35 @@ def generate_preview_image(book, texts={}, picture=None, rating=None): 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(texts, 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 + + 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 + 30 + contents_composite_y = contents_composite_y + texts_layer.height + gutter if rating: # Add some more margin - contents_composite_y = contents_composite_y + 30 + contents_composite_y = contents_composite_y + gutter 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_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) - # Remove Instance Layer from centering calculations - contents_y = contents_y - math.floor((instance_layer.height + gutter) / 2) + + if show_instance_layer: + # Remove Instance Layer from centering calculations + contents_y = contents_y - math.floor((instance_layer.height + gutter) / 2) if contents_y < margin: contents_y = margin @@ -255,9 +260,56 @@ def generate_preview_image(book, texts={}, picture=None, rating=None): return img +@app.task +def generate_site_preview_image_task(): + """generate preview_image for the website""" + site = models.SiteSettings.objects.get() + + if site.logo: + logo = site.logo + else: + logo = path.joinpath("static/images/logo-small.png") + + texts = { + 'text_zero': DOMAIN, + 'text_one': site.name, + 'text_three': site.instance_tagline, + } + + img = generate_preview_image(texts=texts, + picture=logo, + show_instance_layer=False) + + file_name = "%s.png" % str(uuid4()) + image_buffer = BytesIO() + try: + try: + old_path = site.preview_image.path + except ValueError: + old_path = "" + + # Save + img.save(image_buffer, format="png") + site.preview_image = InMemoryUploadedFile( + ContentFile(image_buffer.getvalue()), + "preview_image", + file_name, + "image/png", + image_buffer.tell(), + None, + ) + site.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_edition_preview_image_task(book_id): - """generate preview_image""" + """generate preview_image for a book""" book = models.Book.objects.select_subclasses().get(id=book_id) rating = models.Review.objects.filter( @@ -267,14 +319,12 @@ def generate_edition_preview_image_task(book_id): ).aggregate(Avg("rating"))["rating__avg"] texts = { - 'text_zero': "ADDED A REVIEW", 'text_one': book.title, 'text_two': book.subtitle, 'text_three': book.author_text } - img = generate_preview_image(book=book, - texts=texts, + img = generate_preview_image(texts=texts, picture=book.cover, rating=rating) From bf503d370ce05dca92ffe711c5349aab1e3bfce8 Mon Sep 17 00:00:00 2001 From: Joachim Date: Wed, 26 May 2021 12:54:57 +0200 Subject: [PATCH 11/55] Add user preview task --- bookwyrm/models/user.py | 14 ++++ bookwyrm/preview_images.py | 135 +++++++++++++++++++------------------ 2 files changed, 83 insertions(+), 66 deletions(-) diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index d9f3eba99..2c4851520 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -6,6 +6,7 @@ from django.apps import apps from django.contrib.auth.models import AbstractUser, Group from django.contrib.postgres.fields import CICharField from django.core.validators import MinValueValidator +from django.dispatch import receiver from django.db import models from django.utils import timezone import pytz @@ -14,6 +15,7 @@ from bookwyrm import activitypub from bookwyrm.connectors import get_data, ConnectorException from bookwyrm.models.shelf import Shelf from bookwyrm.models.status import Status, Review +from bookwyrm.preview_images import generate_user_preview_image_task from bookwyrm.settings import DOMAIN from bookwyrm.signatures import create_key_pair from bookwyrm.tasks import app @@ -70,6 +72,9 @@ class User(OrderedCollectionPageMixin, AbstractUser): activitypub_field="icon", alt_field="alt_text", ) + preview_image = models.ImageField( + upload_to="previews/avatars/", blank=True, null=True + ) followers = fields.ManyToManyField( "self", link_only=True, @@ -443,3 +448,12 @@ def get_remote_reviews(outbox): if not activity["type"] == "Review": continue activitypub.Review(**activity).to_model() + + +@receiver(models.signals.post_save, sender=User) +# pylint: disable=unused-argument +def preview_image(instance, *args, **kwargs): + updated_fields = kwargs["update_fields"] + + if not updated_fields or "preview_image" not in updated_fields: + generate_user_preview_image_task.delay(instance.id) diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py index 949ffac12..304250a21 100644 --- a/bookwyrm/preview_images.py +++ b/bookwyrm/preview_images.py @@ -29,7 +29,7 @@ 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) +inner_img_limits = math.floor(IMG_HEIGHT * 0.8) path = Path(__file__).parent.absolute() font_dir = path.joinpath("static/fonts/public_sans") @@ -163,12 +163,12 @@ def generate_rating_layer(rating, content_width): return rating_layer_composite -def generate_default_cover(): +def generate_default_inner_img(): font_cover = get_font("light", size=28) - cover_width = math.floor(cover_img_limits * 0.7) + cover_width = math.floor(inner_img_limits * 0.7) default_cover = Image.new( - "RGB", (cover_width, cover_img_limits), color=DEFAULT_COVER_COLOR + "RGB", (cover_width, inner_img_limits), color=DEFAULT_COVER_COLOR ) default_cover_draw = ImageDraw.Draw(default_cover) @@ -176,7 +176,7 @@ def generate_default_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), + math.floor((inner_img_limits - text_dimensions[1]) / 2), ) default_cover_draw.text(text_coords, text, font=font_cover, fill="white") @@ -186,14 +186,15 @@ def generate_default_cover(): def generate_preview_image(texts={}, picture=None, rating=None, show_instance_layer=True): # Cover try: - cover_img_layer = Image.open(picture) - cover_img_layer.thumbnail((cover_img_limits, cover_img_limits), Image.ANTIALIAS) + inner_img_layer = Image.open(picture) + inner_img_layer.thumbnail((inner_img_limits, inner_img_limits), Image.ANTIALIAS) color_thief = ColorThief(picture) dominant_color = color_thief.get_color(quality=1) except: - cover_img_layer = generate_default_cover() + inner_img_layer = generate_default_inner_img() dominant_color = ImageColor.getrgb(DEFAULT_COVER_COLOR) + # Color if BG_COLOR == "use_dominant_color": image_bg_color = "rgb(%s, %s, %s)" % dominant_color @@ -215,7 +216,7 @@ def generate_preview_image(texts={}, picture=None, rating=None, show_instance_la img = Image.new("RGBA", (IMG_WIDTH, IMG_HEIGHT), color=image_bg_color) # Contents - content_x = margin + cover_img_layer.width + gutter + content_x = margin + inner_img_layer.width + gutter content_width = IMG_WIDTH - content_x - margin contents_layer = Image.new( @@ -251,15 +252,45 @@ def generate_preview_image(texts={}, picture=None, rating=None, show_instance_la if contents_y < margin: contents_y = margin - cover_y = math.floor((IMG_HEIGHT - cover_img_layer.height) / 2) + cover_y = math.floor((IMG_HEIGHT - inner_img_layer.height) / 2) # Composite layers - img.paste(cover_img_layer, (margin, cover_y)) + img.paste(inner_img_layer, (margin, cover_y), inner_img_layer.convert('RGBA')) img.alpha_composite(contents_layer, (content_x, contents_y)) return img +def save_and_cleanup(image, instance=None): + if instance: + file_name = "%s.png" % str(uuid4()) + image_buffer = BytesIO() + + try: + try: + old_path = instance.preview_image.path + except ValueError: + old_path = "" + + # Save + image.save(image_buffer, format="png") + instance.preview_image = InMemoryUploadedFile( + ContentFile(image_buffer.getvalue()), + "preview_image", + file_name, + "image/png", + image_buffer.tell(), + None, + ) + instance.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_site_preview_image_task(): """generate preview_image for the website""" @@ -268,7 +299,7 @@ def generate_site_preview_image_task(): if site.logo: logo = site.logo else: - logo = path.joinpath("static/images/logo-small.png") + logo = path.joinpath("static/images/logo.png") texts = { 'text_zero': DOMAIN, @@ -276,35 +307,11 @@ def generate_site_preview_image_task(): 'text_three': site.instance_tagline, } - img = generate_preview_image(texts=texts, - picture=logo, - show_instance_layer=False) + image = generate_preview_image(texts=texts, + picture=logo, + show_instance_layer=False) - file_name = "%s.png" % str(uuid4()) - image_buffer = BytesIO() - try: - try: - old_path = site.preview_image.path - except ValueError: - old_path = "" - - # Save - img.save(image_buffer, format="png") - site.preview_image = InMemoryUploadedFile( - ContentFile(image_buffer.getvalue()), - "preview_image", - file_name, - "image/png", - image_buffer.tell(), - None, - ) - site.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() + save_and_cleanup(image, instance=site) @app.task @@ -324,32 +331,28 @@ def generate_edition_preview_image_task(book_id): 'text_three': book.author_text } - img = generate_preview_image(texts=texts, - picture=book.cover, - rating=rating) + image = generate_preview_image(texts=texts, + picture=book.cover, + rating=rating) - file_name = "%s.png" % str(uuid4()) - image_buffer = BytesIO() - try: - try: - old_path = book.preview_image.path - except ValueError: - old_path = "" + save_and_cleanup(image, instance=book) - # 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"]) +@app.task +def generate_user_preview_image_task(user_id): + """generate preview_image for a book""" + user = models.User.objects.get(id=user_id) - # Clean up old file after saving - if os.path.exists(old_path): - os.remove(old_path) - finally: - image_buffer.close() + texts = { + 'text_one': user.display_name, + 'text_three': "@{}@{}".format(user.localname, DOMAIN) + } + + if user.avatar: + avatar = user.avatar + else: + avatar = path.joinpath("static/images/default_avi.jpg") + + image = generate_preview_image(texts=texts, + picture=avatar) + + save_and_cleanup(image, instance=user) From b47edc5f0d15945044346791982cdd9fb4803845 Mon Sep 17 00:00:00 2001 From: Joachim Date: Wed, 26 May 2021 13:07:33 +0200 Subject: [PATCH 12/55] Add dark mode --- bookwyrm/preview_images.py | 13 ++++++++++--- bookwyrm/settings.py | 7 ++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py index 304250a21..9539edbaf 100644 --- a/bookwyrm/preview_images.py +++ b/bookwyrm/preview_images.py @@ -196,14 +196,21 @@ def generate_preview_image(texts={}, picture=None, rating=None, show_instance_la # Color - if BG_COLOR == "use_dominant_color": + if BG_COLOR in ["use_dominant_color_light", "use_dominant_color_dark"]: image_bg_color = "rgb(%s, %s, %s)" % dominant_color - # Lighten color + + # 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], - max(0.9, image_bg_color_hls[1]), + lightness, image_bg_color_hls[2], ) image_bg_color = tuple( diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index db15be468..ce5c4547d 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -39,11 +39,12 @@ DEFAULT_AUTO_FIELD = "django.db.models.AutoField" # Preview image -# Specify RGB tuple or RGB hex strings, or 'use_dominant_color' -PREVIEW_BG_COLOR = "use_dominant_color" +# Specify RGB tuple or RGB hex strings, +# or "use_dominant_color_light" / "use_dominant_color_dark" +PREVIEW_BG_COLOR = "use_dominant_color_dark" PREVIEW_IMG_WIDTH = 1200 PREVIEW_IMG_HEIGHT = 630 -PREVIEW_TEXT_COLOR = "#363636" +PREVIEW_TEXT_COLOR = "#FFF" PREVIEW_DEFAULT_COVER_COLOR = "#002549" # Quick-start development settings - unsuitable for production From 65de40a95a8aa64a9bdeb563c25f8e88a1a9fb7a Mon Sep 17 00:00:00 2001 From: Joachim Date: Wed, 26 May 2021 13:52:10 +0200 Subject: [PATCH 13/55] Add `generate_preview_images` command --- .../commands/generate_preview_images.py | 48 +++++++++++++++++++ bookwyrm/preview_images.py | 1 + bookwyrm/settings.py | 4 +- bw-dev | 5 +- 4 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 bookwyrm/management/commands/generate_preview_images.py diff --git a/bookwyrm/management/commands/generate_preview_images.py b/bookwyrm/management/commands/generate_preview_images.py new file mode 100644 index 000000000..13eaf3a68 --- /dev/null +++ b/bookwyrm/management/commands/generate_preview_images.py @@ -0,0 +1,48 @@ +""" Generate preview images """ +import sys + +from django.core.management.base import BaseCommand + +from bookwyrm import activitystreams, models, settings, preview_images + + +def generate_preview_images(): + """generate preview images""" + print(" | Hello! I will be generating preview images for your instance.") + print("šŸ§‘ā€šŸŽØ āŽØ This might take quite long if your instance has a lot of books and users.") + print(" | āœ§ Thank you for your patience āœ§") + + # Site + sys.stdout.write(" ā†’ Site preview image: ") + preview_images.generate_site_preview_image_task() + sys.stdout.write(" OK šŸ–¼\n") + + + # Users + users = models.User.objects.filter( + local=True, + is_active=True, + ) + sys.stdout.write(" ā†’ User preview images ({}): ".format(len(users))) + for user in users: + preview_images.generate_user_preview_image_task(user.id) + sys.stdout.write(".") + sys.stdout.write(" OK šŸ–¼\n") + + # Books + books = models.Book.objects.select_subclasses().filter() + sys.stdout.write(" ā†’ Book preview images ({}): ".format(len(books))) + for book in books: + preview_images.generate_edition_preview_image_task(book.id) + sys.stdout.write(".") + sys.stdout.write(" OK šŸ–¼\n") + + print("šŸ§‘ā€šŸŽØ āŽØ Iā€™m all done! āœ§ Enjoy āœ§") + + +class Command(BaseCommand): + help = "Generate preview images" + # pylint: disable=no-self-use,unused-argument + def handle(self, *args, **options): + """run feed builder""" + generate_preview_images() diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py index 9539edbaf..b0f9792d7 100644 --- a/bookwyrm/preview_images.py +++ b/bookwyrm/preview_images.py @@ -344,6 +344,7 @@ def generate_edition_preview_image_task(book_id): save_and_cleanup(image, instance=book) + @app.task def generate_user_preview_image_task(user_id): """generate preview_image for a book""" diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index ce5c4547d..b3ba312f9 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -41,10 +41,10 @@ DEFAULT_AUTO_FIELD = "django.db.models.AutoField" # Specify RGB tuple or RGB hex strings, # or "use_dominant_color_light" / "use_dominant_color_dark" -PREVIEW_BG_COLOR = "use_dominant_color_dark" +PREVIEW_BG_COLOR = "use_dominant_color_light" PREVIEW_IMG_WIDTH = 1200 PREVIEW_IMG_HEIGHT = 630 -PREVIEW_TEXT_COLOR = "#FFF" +PREVIEW_TEXT_COLOR = "#363636" # Change to "#FFF" if you use "use_dominant_color_dark" PREVIEW_DEFAULT_COVER_COLOR = "#002549" # Quick-start development settings - unsuitable for production diff --git a/bw-dev b/bw-dev index c2b63bc17..95681b115 100755 --- a/bw-dev +++ b/bw-dev @@ -107,7 +107,10 @@ case "$CMD" in populate_streams) runweb python manage.py populate_streams ;; + generate_preview_images) + runweb python manage.py generate_preview_images + ;; *) - echo "Unrecognised command. Try: build, clean, up, initdb, resetdb, makemigrations, migrate, bash, shell, dbshell, restart_celery, test, pytest, test_report, black, populate_feeds" + echo "Unrecognised command. Try: build, clean, up, initdb, resetdb, makemigrations, migrate, bash, shell, dbshell, restart_celery, test, pytest, test_report, black, populate_feeds, generate_preview_images" ;; esac From e5e549d125660544fae55c81d11e9e4dd6459edc Mon Sep 17 00:00:00 2001 From: Joachim Date: Wed, 26 May 2021 14:44:15 +0200 Subject: [PATCH 14/55] Add opengraph image depending on context --- bookwyrm/templates/book/book.html | 7 ++++++- bookwyrm/templates/layout.html | 6 ++++-- bookwyrm/templates/user/layout.html | 10 ++++++---- bookwyrm/templatetags/layout.py | 7 +++++++ 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index af2230200..09d5634bf 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -1,8 +1,13 @@ {% extends 'layout.html' %} -{% load i18n %}{% load bookwyrm_tags %}{% load humanize %}{% load utilities %} +{% load i18n %}{% load bookwyrm_tags %}{% load humanize %}{% load utilities %}{% load layout %} {% block title %}{{ book|book_title }}{% endblock %} +{% block opengraph_images %} + + +{% endblock %} + {% block content %} {% with user_authenticated=request.user.is_authenticated can_edit_book=perms.bookwyrm.edit_book %}
diff --git a/bookwyrm/templates/layout.html b/bookwyrm/templates/layout.html index d899d62cb..bd3dbf7c1 100644 --- a/bookwyrm/templates/layout.html +++ b/bookwyrm/templates/layout.html @@ -16,8 +16,10 @@ - - + {% block opengraph_images %} + + + {% endblock %} diff --git a/bookwyrm/templates/user/layout.html b/bookwyrm/templates/user/layout.html index 0830a4068..c1503ec6d 100644 --- a/bookwyrm/templates/user/layout.html +++ b/bookwyrm/templates/user/layout.html @@ -1,11 +1,13 @@ {% extends 'layout.html' %} -{% load i18n %} -{% load humanize %} -{% load utilities %} -{% load markdown %} +{% load i18n %}{% load humanize %}{% load utilities %}{% load markdown %}{% load layout %} {% block title %}{{ user.display_name }}{% endblock %} +{% block opengraph_images %} + + +{% endblock %} + {% block content %}
{% block header %} diff --git a/bookwyrm/templatetags/layout.py b/bookwyrm/templatetags/layout.py index e0f1d8ba6..f518808c1 100644 --- a/bookwyrm/templatetags/layout.py +++ b/bookwyrm/templatetags/layout.py @@ -1,6 +1,7 @@ """ template filters used for creating the layout""" from django import template, utils +from bookwyrm.settings import DOMAIN register = template.Library() @@ -10,3 +11,9 @@ def get_lang(): """get current language, strip to the first two letters""" language = utils.translation.get_language() return language[0 : language.find("-")] + + +@register.simple_tag(takes_context=False) +def get_path(): + """get protocol and host""" + return "https://%s" % DOMAIN From eb56cced8d19319d622b866f36307cb583fda8ab Mon Sep 17 00:00:00 2001 From: Joachim Date: Wed, 26 May 2021 14:46:34 +0200 Subject: [PATCH 15/55] Lint --- .../commands/generate_preview_images.py | 5 +- bookwyrm/models/site.py | 4 +- bookwyrm/preview_images.py | 62 ++++++++++--------- bookwyrm/settings.py | 2 +- 4 files changed, 39 insertions(+), 34 deletions(-) diff --git a/bookwyrm/management/commands/generate_preview_images.py b/bookwyrm/management/commands/generate_preview_images.py index 13eaf3a68..754f6e9a9 100644 --- a/bookwyrm/management/commands/generate_preview_images.py +++ b/bookwyrm/management/commands/generate_preview_images.py @@ -9,7 +9,9 @@ from bookwyrm import activitystreams, models, settings, preview_images def generate_preview_images(): """generate preview images""" print(" | Hello! I will be generating preview images for your instance.") - print("šŸ§‘ā€šŸŽØ āŽØ This might take quite long if your instance has a lot of books and users.") + print( + "šŸ§‘ā€šŸŽØ āŽØ This might take quite long if your instance has a lot of books and users." + ) print(" | āœ§ Thank you for your patience āœ§") # Site @@ -17,7 +19,6 @@ def generate_preview_images(): preview_images.generate_site_preview_image_task() sys.stdout.write(" OK šŸ–¼\n") - # Users users = models.User.objects.filter( local=True, diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py index dc226bce4..d0076dc63 100644 --- a/bookwyrm/models/site.py +++ b/bookwyrm/models/site.py @@ -38,7 +38,9 @@ class SiteSettings(models.Model): logo = models.ImageField(upload_to="logos/", null=True, blank=True) logo_small = models.ImageField(upload_to="logos/", null=True, blank=True) favicon = models.ImageField(upload_to="logos/", null=True, blank=True) - preview_image = models.ImageField(upload_to="previews/logos/", null=True, blank=True) + preview_image = models.ImageField( + upload_to="previews/logos/", null=True, blank=True + ) # footer support_link = models.CharField(max_length=255, null=True, blank=True) diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py index b0f9792d7..8b6f90324 100644 --- a/bookwyrm/preview_images.py +++ b/bookwyrm/preview_images.py @@ -61,30 +61,36 @@ def generate_texts_layer(texts, content_width): text_y = 0 - if 'text_zero' in texts: + if "text_zero" in texts: # Text one (Book title) - text_zero = textwrap.fill(texts['text_zero'], width=72) - text_layer_draw.multiline_text((0, text_y), text_zero, font=font_text_zero, fill=TEXT_COLOR) + text_zero = textwrap.fill(texts["text_zero"], width=72) + text_layer_draw.multiline_text( + (0, text_y), text_zero, font=font_text_zero, fill=TEXT_COLOR + ) text_y = text_y + font_text_zero.getsize_multiline(text_zero)[1] + 16 - if 'text_one' in texts: + if "text_one" in texts: # Text one (Book title) - text_one = textwrap.fill(texts['text_one'], width=28) - text_layer_draw.multiline_text((0, text_y), text_one, font=font_text_one, fill=TEXT_COLOR) + text_one = textwrap.fill(texts["text_one"], width=28) + text_layer_draw.multiline_text( + (0, text_y), text_one, font=font_text_one, fill=TEXT_COLOR + ) text_y = text_y + font_text_one.getsize_multiline(text_one)[1] + 16 - if 'text_two' in texts: + if "text_two" in texts: # Text one (Book subtitle) - text_two = textwrap.fill(texts['text_two'], width=36) - text_layer_draw.multiline_text((0, text_y), text_two, font=font_text_two, fill=TEXT_COLOR) + text_two = textwrap.fill(texts["text_two"], width=36) + text_layer_draw.multiline_text( + (0, text_y), text_two, font=font_text_two, fill=TEXT_COLOR + ) text_y = text_y + font_text_one.getsize_multiline(text_two)[1] + 16 - if 'text_three' in texts: + if "text_three" in texts: # Text three (Book authors) - text_three = textwrap.fill(texts['text_three'], width=36) + text_three = textwrap.fill(texts["text_three"], width=36) text_layer_draw.multiline_text( (0, text_y), text_three, font=font_text_three, fill=TEXT_COLOR ) @@ -183,7 +189,9 @@ def generate_default_inner_img(): return default_cover -def generate_preview_image(texts={}, picture=None, rating=None, show_instance_layer=True): +def generate_preview_image( + texts={}, picture=None, rating=None, show_instance_layer=True +): # Cover try: inner_img_layer = Image.open(picture) @@ -194,7 +202,6 @@ def generate_preview_image(texts={}, picture=None, rating=None, show_instance_la 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"]: image_bg_color = "rgb(%s, %s, %s)" % dominant_color @@ -262,7 +269,7 @@ def generate_preview_image(texts={}, picture=None, rating=None, show_instance_la cover_y = math.floor((IMG_HEIGHT - inner_img_layer.height) / 2) # Composite layers - img.paste(inner_img_layer, (margin, cover_y), inner_img_layer.convert('RGBA')) + img.paste(inner_img_layer, (margin, cover_y), inner_img_layer.convert("RGBA")) img.alpha_composite(contents_layer, (content_x, contents_y)) return img @@ -309,14 +316,12 @@ def generate_site_preview_image_task(): logo = path.joinpath("static/images/logo.png") texts = { - 'text_zero': DOMAIN, - 'text_one': site.name, - 'text_three': site.instance_tagline, + "text_zero": DOMAIN, + "text_one": site.name, + "text_three": site.instance_tagline, } - image = generate_preview_image(texts=texts, - picture=logo, - show_instance_layer=False) + image = generate_preview_image(texts=texts, picture=logo, show_instance_layer=False) save_and_cleanup(image, instance=site) @@ -333,14 +338,12 @@ def generate_edition_preview_image_task(book_id): ).aggregate(Avg("rating"))["rating__avg"] texts = { - 'text_one': book.title, - 'text_two': book.subtitle, - 'text_three': book.author_text + "text_one": book.title, + "text_two": book.subtitle, + "text_three": book.author_text, } - image = generate_preview_image(texts=texts, - picture=book.cover, - rating=rating) + image = generate_preview_image(texts=texts, picture=book.cover, rating=rating) save_and_cleanup(image, instance=book) @@ -351,8 +354,8 @@ def generate_user_preview_image_task(user_id): user = models.User.objects.get(id=user_id) texts = { - 'text_one': user.display_name, - 'text_three': "@{}@{}".format(user.localname, DOMAIN) + "text_one": user.display_name, + "text_three": "@{}@{}".format(user.localname, DOMAIN), } if user.avatar: @@ -360,7 +363,6 @@ def generate_user_preview_image_task(user_id): else: avatar = path.joinpath("static/images/default_avi.jpg") - image = generate_preview_image(texts=texts, - picture=avatar) + image = generate_preview_image(texts=texts, picture=avatar) save_and_cleanup(image, instance=user) diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index b3ba312f9..86ad5c035 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -44,7 +44,7 @@ DEFAULT_AUTO_FIELD = "django.db.models.AutoField" PREVIEW_BG_COLOR = "use_dominant_color_light" PREVIEW_IMG_WIDTH = 1200 PREVIEW_IMG_HEIGHT = 630 -PREVIEW_TEXT_COLOR = "#363636" # Change to "#FFF" if you use "use_dominant_color_dark" +PREVIEW_TEXT_COLOR = "#363636" # Change to "#FFF" if you use "use_dominant_color_dark" PREVIEW_DEFAULT_COVER_COLOR = "#002549" # Quick-start development settings - unsuitable for production From 4db8aa85f06859d6070a69865a978073e03b0500 Mon Sep 17 00:00:00 2001 From: Joachim Date: Wed, 26 May 2021 14:55:55 +0200 Subject: [PATCH 16/55] Fix migration --- .../migrations/0076_book_preview_image.py | 18 ------------ bookwyrm/migrations/0076_preview_images.py | 28 +++++++++++++++++++ 2 files changed, 28 insertions(+), 18 deletions(-) delete mode 100644 bookwyrm/migrations/0076_book_preview_image.py create mode 100644 bookwyrm/migrations/0076_preview_images.py diff --git a/bookwyrm/migrations/0076_book_preview_image.py b/bookwyrm/migrations/0076_book_preview_image.py deleted file mode 100644 index 5db550507..000000000 --- a/bookwyrm/migrations/0076_book_preview_image.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2 on 2021-05-24 18:03 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("bookwyrm", "0075_announcement"), - ] - - operations = [ - migrations.AddField( - model_name="book", - name="preview_image", - field=models.ImageField(blank=True, null=True, upload_to="cover_previews/"), - ), - ] diff --git a/bookwyrm/migrations/0076_preview_images.py b/bookwyrm/migrations/0076_preview_images.py new file mode 100644 index 000000000..c2f251df0 --- /dev/null +++ b/bookwyrm/migrations/0076_preview_images.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2 on 2021-05-26 12:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0075_announcement'), + ] + + operations = [ + migrations.AddField( + model_name='book', + name='preview_image', + field=models.ImageField(blank=True, null=True, upload_to='previews/covers/'), + ), + migrations.AddField( + model_name='sitesettings', + name='preview_image', + field=models.ImageField(blank=True, null=True, upload_to='previews/logos/'), + ), + migrations.AddField( + model_name='user', + name='preview_image', + field=models.ImageField(blank=True, null=True, upload_to='previews/avatars/'), + ), + ] From d4fc1b0fdfec0b6b0d21e255bf402a28bf1b7e53 Mon Sep 17 00:00:00 2001 From: Joachim Date: Wed, 26 May 2021 15:17:28 +0200 Subject: [PATCH 17/55] Fix line endings --- bookwyrm/static/fonts/public_sans/OFL.txt | 186 +++++++++++----------- 1 file changed, 93 insertions(+), 93 deletions(-) diff --git a/bookwyrm/static/fonts/public_sans/OFL.txt b/bookwyrm/static/fonts/public_sans/OFL.txt index ac793eaaa..0916c2309 100644 --- a/bookwyrm/static/fonts/public_sans/OFL.txt +++ b/bookwyrm/static/fonts/public_sans/OFL.txt @@ -1,93 +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. +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. From 5943e6d79acd955ce78f8a7cb3e755dfafd664a7 Mon Sep 17 00:00:00 2001 From: Joachim Date: Wed, 26 May 2021 15:18:05 +0200 Subject: [PATCH 18/55] Black --- bookwyrm/migrations/0076_preview_images.py | 24 +++++++++++++--------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/bookwyrm/migrations/0076_preview_images.py b/bookwyrm/migrations/0076_preview_images.py index c2f251df0..8812e9b22 100644 --- a/bookwyrm/migrations/0076_preview_images.py +++ b/bookwyrm/migrations/0076_preview_images.py @@ -6,23 +6,27 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('bookwyrm', '0075_announcement'), + ("bookwyrm", "0075_announcement"), ] operations = [ migrations.AddField( - model_name='book', - name='preview_image', - field=models.ImageField(blank=True, null=True, upload_to='previews/covers/'), + model_name="book", + name="preview_image", + field=models.ImageField( + blank=True, null=True, upload_to="previews/covers/" + ), ), migrations.AddField( - model_name='sitesettings', - name='preview_image', - field=models.ImageField(blank=True, null=True, upload_to='previews/logos/'), + model_name="sitesettings", + name="preview_image", + field=models.ImageField(blank=True, null=True, upload_to="previews/logos/"), ), migrations.AddField( - model_name='user', - name='preview_image', - field=models.ImageField(blank=True, null=True, upload_to='previews/avatars/'), + model_name="user", + name="preview_image", + field=models.ImageField( + blank=True, null=True, upload_to="previews/avatars/" + ), ), ] From 22c13f639c90d6e0eab9f8292f6f46970b60514b Mon Sep 17 00:00:00 2001 From: Joachim Date: Wed, 26 May 2021 15:18:35 +0200 Subject: [PATCH 19/55] Update layout.html --- bookwyrm/templates/user/layout.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bookwyrm/templates/user/layout.html b/bookwyrm/templates/user/layout.html index c1503ec6d..5f09b8c27 100644 --- a/bookwyrm/templates/user/layout.html +++ b/bookwyrm/templates/user/layout.html @@ -1,5 +1,9 @@ {% extends 'layout.html' %} -{% load i18n %}{% load humanize %}{% load utilities %}{% load markdown %}{% load layout %} +{% load i18n %} +{% load humanize %} +{% load utilities %} +{% load markdown %} +{% load layout %} {% block title %}{{ user.display_name }}{% endblock %} From a8ae3c995015b8150607f1081c03c73dec832747 Mon Sep 17 00:00:00 2001 From: Joachim Date: Wed, 26 May 2021 15:37:09 +0200 Subject: [PATCH 20/55] Modify inner image position --- bookwyrm/preview_images.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py index 8b6f90324..13b241b1d 100644 --- a/bookwyrm/preview_images.py +++ b/bookwyrm/preview_images.py @@ -17,9 +17,6 @@ from bookwyrm import models, settings from bookwyrm.settings import DOMAIN 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 @@ -29,7 +26,8 @@ TRANSPARENT_COLOR = (0, 0, 0, 0) margin = math.floor(IMG_HEIGHT / 10) gutter = math.floor(margin / 2) -inner_img_limits = math.floor(IMG_HEIGHT * 0.8) +inner_img_height = math.floor(IMG_HEIGHT * 0.8) +inner_img_width = math.floor(inner_img_height * 0.7) path = Path(__file__).parent.absolute() font_dir = path.joinpath("static/fonts/public_sans") @@ -172,17 +170,16 @@ def generate_rating_layer(rating, content_width): def generate_default_inner_img(): font_cover = get_font("light", size=28) - cover_width = math.floor(inner_img_limits * 0.7) default_cover = Image.new( - "RGB", (cover_width, inner_img_limits), color=DEFAULT_COVER_COLOR + "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((cover_width - text_dimensions[0]) / 2), - math.floor((inner_img_limits - text_dimensions[1]) / 2), + 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") @@ -195,7 +192,7 @@ def generate_preview_image( # Cover try: inner_img_layer = Image.open(picture) - inner_img_layer.thumbnail((inner_img_limits, inner_img_limits), Image.ANTIALIAS) + 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: @@ -230,7 +227,9 @@ def generate_preview_image( img = Image.new("RGBA", (IMG_WIDTH, IMG_HEIGHT), color=image_bg_color) # Contents - content_x = margin + inner_img_layer.width + gutter + 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( @@ -266,10 +265,8 @@ def generate_preview_image( if contents_y < margin: contents_y = margin - cover_y = math.floor((IMG_HEIGHT - inner_img_layer.height) / 2) - # Composite layers - img.paste(inner_img_layer, (margin, cover_y), inner_img_layer.convert("RGBA")) + 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 From f7b117e4fb981b081847ffe1eaa346564351af0f Mon Sep 17 00:00:00 2001 From: Joachim Date: Wed, 26 May 2021 15:46:40 +0200 Subject: [PATCH 21/55] Update preview_images.py --- bookwyrm/preview_images.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py index 13b241b1d..875755547 100644 --- a/bookwyrm/preview_images.py +++ b/bookwyrm/preview_images.py @@ -266,7 +266,9 @@ def generate_preview_image( contents_y = margin # Composite layers - img.paste(inner_img_layer, (inner_img_x, inner_img_y), inner_img_layer.convert("RGBA")) + 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 From 3ea935e7ce6473c1ff9713b86939cf1aa61c4341 Mon Sep 17 00:00:00 2001 From: Joachim Date: Wed, 26 May 2021 15:49:08 +0200 Subject: [PATCH 22/55] Update OFL.txt --- bookwyrm/static/fonts/public_sans/OFL.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bookwyrm/static/fonts/public_sans/OFL.txt b/bookwyrm/static/fonts/public_sans/OFL.txt index 0916c2309..ba4ea0b96 100644 --- a/bookwyrm/static/fonts/public_sans/OFL.txt +++ b/bookwyrm/static/fonts/public_sans/OFL.txt @@ -1,4 +1,5 @@ -Copyright (c) 2015, Pablo Impallari, Rodrigo Fuenzalida (Modified by Dan O. Williams and USWDS) (https://github.com/uswds/public-sans) +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: From 7ea31530269721d7ab2856b3ec5d1186836795da Mon Sep 17 00:00:00 2001 From: Joachim Date: Wed, 26 May 2021 16:57:28 +0200 Subject: [PATCH 23/55] Fix site_path tag --- bookwyrm/context_processors.py | 2 ++ bookwyrm/templates/book/book.html | 4 ++-- bookwyrm/templates/layout.html | 4 ++-- bookwyrm/templates/user/layout.html | 4 ++-- bookwyrm/templatetags/layout.py | 8 -------- 5 files changed, 8 insertions(+), 14 deletions(-) diff --git a/bookwyrm/context_processors.py b/bookwyrm/context_processors.py index b77c62b02..29775a0b5 100644 --- a/bookwyrm/context_processors.py +++ b/bookwyrm/context_processors.py @@ -1,5 +1,6 @@ """ customize the info available in context for rendering templates """ from bookwyrm import models +from bookwyrm.settings import DOMAIN def site_settings(request): # pylint: disable=unused-argument @@ -7,4 +8,5 @@ def site_settings(request): # pylint: disable=unused-argument return { "site": models.SiteSettings.objects.get(), "active_announcements": models.Announcement.active_announcements(), + "site_path": "https://%s" % DOMAIN, } diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index 09d5634bf..02409b389 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -4,8 +4,8 @@ {% block title %}{{ book|book_title }}{% endblock %} {% block opengraph_images %} - - + + {% endblock %} {% block content %} diff --git a/bookwyrm/templates/layout.html b/bookwyrm/templates/layout.html index bd3dbf7c1..47bd434e2 100644 --- a/bookwyrm/templates/layout.html +++ b/bookwyrm/templates/layout.html @@ -17,8 +17,8 @@ {% block opengraph_images %} - - + + {% endblock %} diff --git a/bookwyrm/templates/user/layout.html b/bookwyrm/templates/user/layout.html index 5f09b8c27..45b37f763 100644 --- a/bookwyrm/templates/user/layout.html +++ b/bookwyrm/templates/user/layout.html @@ -8,8 +8,8 @@ {% block title %}{{ user.display_name }}{% endblock %} {% block opengraph_images %} - - + + {% endblock %} {% block content %} diff --git a/bookwyrm/templatetags/layout.py b/bookwyrm/templatetags/layout.py index f518808c1..f42f3bda1 100644 --- a/bookwyrm/templatetags/layout.py +++ b/bookwyrm/templatetags/layout.py @@ -1,8 +1,6 @@ """ template filters used for creating the layout""" from django import template, utils -from bookwyrm.settings import DOMAIN - register = template.Library() @@ -11,9 +9,3 @@ def get_lang(): """get current language, strip to the first two letters""" language = utils.translation.get_language() return language[0 : language.find("-")] - - -@register.simple_tag(takes_context=False) -def get_path(): - """get protocol and host""" - return "https://%s" % DOMAIN From e362c8249563c2c2c371b1136481c6b4c8b1a970 Mon Sep 17 00:00:00 2001 From: Joachim Date: Wed, 26 May 2021 17:54:59 +0200 Subject: [PATCH 24/55] Expose static & media paths --- bookwyrm/context_processors.py | 8 ++++++-- bookwyrm/settings.py | 4 ++++ bookwyrm/templates/book/book.html | 4 ++-- bookwyrm/templates/layout.html | 8 ++++---- bookwyrm/templates/user/layout.html | 4 ++-- 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/bookwyrm/context_processors.py b/bookwyrm/context_processors.py index 29775a0b5..dcdf615d5 100644 --- a/bookwyrm/context_processors.py +++ b/bookwyrm/context_processors.py @@ -1,6 +1,6 @@ """ customize the info available in context for rendering templates """ from bookwyrm import models -from bookwyrm.settings import DOMAIN +from bookwyrm.settings import SITE_PATH, STATIC_URL, STATIC_PATH, MEDIA_URL, MEDIA_PATH def site_settings(request): # pylint: disable=unused-argument @@ -8,5 +8,9 @@ def site_settings(request): # pylint: disable=unused-argument return { "site": models.SiteSettings.objects.get(), "active_announcements": models.Announcement.active_announcements(), - "site_path": "https://%s" % DOMAIN, + "site_path": SITE_PATH, + "static_url": STATIC_URL, + "media_url": MEDIA_URL, + "static_path": STATIC_PATH, + "media_path": MEDIA_PATH, } diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 86ad5c035..47553c65f 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -182,10 +182,14 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.2/howto/static-files/ +SITE_PATH = "https://%s" % DOMAIN + PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) STATIC_URL = "/static/" +STATIC_PATH = "%s/%s" % (SITE_PATH, env("STATIC_ROOT", "static")) STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static")) MEDIA_URL = "/images/" +MEDIA_PATH = "%s/%s" % (SITE_PATH, env("MEDIA_ROOT", "images")) MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images")) USER_AGENT = "%s (BookWyrm/%s; +https://%s/)" % ( diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index 02409b389..7b47e32b6 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -4,8 +4,8 @@ {% block title %}{{ book|book_title }}{% endblock %} {% block opengraph_images %} - - + + {% endblock %} {% block content %} diff --git a/bookwyrm/templates/layout.html b/bookwyrm/templates/layout.html index 47bd434e2..625a224e6 100644 --- a/bookwyrm/templates/layout.html +++ b/bookwyrm/templates/layout.html @@ -8,7 +8,7 @@ - + @@ -17,8 +17,8 @@ {% block opengraph_images %} - - + + {% endblock %} @@ -27,7 +27,7 @@