play-daemon-threaded/pipeline/handlers/thumbnail.py
Erik Thuning b81bd64ffd Made it possible to have more than one handler of the same type
by making the handler's queue an init parameter.
2023-11-23 15:41:42 +01:00

192 lines
6.7 KiB
Python

import re
import textwrap
from os import path, rename
from shutil import copy2
from PIL import Image, ImageDraw, ImageFont
from .handler import Handler
from .metadata import MetadataHandler
from ..exceptions import ValidationException
@Handler.register
class ThumbnailHandler(Handler):
"""
This class handles presentation thumbnails.
If the 'thumb' field is present in the jobspec, does one of the following:
- Saves the uploaded thumb to the package
- Generates a thumb on falsy 'thumb' value
In order to accept a job as valid, the fields 'pkg_id' and 'thumb'
must exist.
If 'thumb' has a truthy value, 'upload_dir' must also exist and 'thumb'
must point to a valid file under 'upload_dir'
"""
@classmethod
def wants(cls, jobspec, existing_package):
if 'thumb' in jobspec:
return cls._validate(jobspec, existing_package)
return False
@classmethod
def _validate(cls, jobspec, existing_package):
super()._validate(jobspec, existing_package)
if jobspec['thumb']:
if 'upload_dir' not in jobspec:
raise ValidationException("upload_dir missing")
if not path.isfile(path.join(jobspec['upload_dir'],
jobspec['thumb'])):
raise ValidationException("invalid thumb value")
return True
@classmethod
def apply_after(cls, handlerclass):
if issubclass(handlerclass, MetadataHandler):
return True
return False
def _handle(self, jobspec, existing_package, tempdir):
if jobspec['thumb']:
def apply_func(package):
filename = path.basename(jobspec['thumb'])
copy2(path.join(jobspec['upload_dir'],
jobspec['thumb']),
path.join(package.basedir, filename))
package['thumb'] = filename
else:
def apply_func(package):
title = package['title']['sv']
description = package['description']
courses = ', '.join(sorted(
[course['designation'] for course in package['courses']]))
presenters = ', '.join(sorted(
[self.ldap.get_name(uid)
for uid in package['presenters']]))
extras = []
if courses:
extras.append(courses)
if presenters:
extras.append(presenters)
extra = ': '.join(extras)
thumb = make_thumb(tempdir,
self.config['baseimage'],
self.config['textcolor'],
title,
description,
extra)
rename(path.join(tempdir, thumb),
path.join(package.basedir, thumb))
package['thumb'] = thumb
return apply_func
def make_thumb(tempdir, baseimg, textcolor,
title, description, extra):
"""
Generates a thumbnail image.
"""
thumbname = 'thumb.png'
font = 'verdana.ttf'
titlesize_max = 150
descrsize_max = 80
extrasize_max = 60
titlebox = (1/9, 0.3,
8/9, 0.5)
descrbox = (1/9, 0.55,
8/9, 0.8)
with Image.open(baseimg) as im:
draw = ImageDraw.Draw(im)
# Size and draw title
s_titlebox = scale_box(titlebox, im.size)
(t1x, t1y, t2x, t2y) = s_titlebox
title_size = max_size(draw, s_titlebox, fix_outliers(title),
font, titlesize_max)
draw.text((t1x, t2y), title, fill=textcolor, anchor='ls',
font=ImageFont.truetype(font, title_size))
# Size and draw description
s_descrbox = scale_box(descrbox, im.size)
(d1x, d1y, d2x, d2y) = s_descrbox
descr = textwrap.fill(description)
descr_size = max_size(draw, s_descrbox, descr, font, descrsize_max)
f = ImageFont.truetype(font, descr_size)
draw.multiline_text((d1x, d1y), descr, fill=textcolor, font=f)
# Size and draw extra
if extra:
# Get effective size of the description text
(b1x, b1y, b2x, b2y) = draw.textbbox((d1x, d1y),
descr,
font=f)
# Create a preliminary bounding box for extra
# Same width as description box, immediately underneath
# extending to bottom of image
extrabox = (d1x, b2y,
d2x, im.height)
# Make sure the text fits in the box
extra_size = max_size(draw, extrabox, extra,
font, extrasize_max)
# Offset the extra box by one line height
extrabox = (extrabox[0], extrabox[1] + extra_size,
extrabox[2], extrabox[3])
(p1x, p1y, p2x, p2y) = extrabox
f = ImageFont.truetype(font, extra_size)
# Draw it
draw.text((p1x, p1y), extra, fill=textcolor,
font=f, anchor='lt')
im.save(path.join(tempdir, thumbname))
return thumbname
def scale_box(inbox, imgsize):
"""
Scales the relative measurements in inbox to imgsize
"""
(i1x, i1y, i2x, i2y) = inbox
(w, h) = imgsize
return (i1x*w, i1y*h,
i2x*w, i2y*h)
def max_size(draw, inbox, text, font, size):
"""
Returns the maximum font size for text that will fit in inbox.
Starts at size and shrinks the text in 5pt increments until it fits.
"""
anchor='lt'
if '\n' in text:
anchor=None
f = ImageFont.truetype(font, size)
(i1x, i1y, i2x, i2y) = inbox
(b1x, b1y, b2x, b2y) = draw.textbbox((i1x, i1y), text,
font=f, anchor=anchor)
if (b1x < i1x or b1y < i1y or
b2x > i2x or b2y > i2y):
return max_size(draw, inbox, text, font, size-5)
return size
def fix_outliers(text):
"""
Replaces characters that extend up/down past the top line and/or the
baseline, in order to get consistent bounding box information for any
string. Replacements attempt to stick close to the look of the
initial string.
For details on the reference lines, see:
https://pillow.readthedocs.io/en/stable/handbook/text-anchors.html
"""
modifiers = [('[ÖQ]', 'O'),
('[ÅÄ]', 'A'),
('y', 'v'),
('[qpg]', 'o'),
('j', 'i')]
for (pat, rep) in modifiers:
text = re.sub(pat, rep, text)
return text