2017-10-15 16:35:53 +02:00

245 lines
7.0 KiB
Python

"""
XKCD Excuse Generator API created using Hug Framework
"""
from binascii import hexlify, unhexlify, Error as BinAsciiError
from io import BytesIO
import os
from typing import Union
from falcon import HTTP_400, HTTP_404
import hug
from PIL import Image, ImageDraw, ImageFont
from slugify import slugify
# since base excuse image is fixed, this values are also constant
IMAGE_WIDTH = 413
# Y text coordinates
WHO_TEXT_Y = 12
LEGIT_TEXT_Y = 38
WHY_TEXT_Y = 85
WHAT_TEXT_Y = 220
dir_path = os.path.dirname(os.path.realpath(__file__))
@hug.get(
versions=1
# examples='who=programmer&why=my%20code%20is%20compiling&what=compiling' TODO
)
def excuse(request, response, who: hug.types.text='', why: hug.types.text='', what: hug.types.text='') -> dict:
"""
API view that returns JSON with url to rendered image or errors if there
were any.
GET https://function.xkcd-excuse.com/v1/excuse?who=one&why=two&what=three
>>
{
"errors": [
{
"code": 1001,
"text": "first text two long" // možda onda imati validaciju pokraj inputa
}
],
"data": {
"who": "one",
"why": "two",
"what": "three",
"image_url": "function.xkcd-excuse.com/media/<hash>-<hash>-<hash>.png"
}
}
:param request: request object
:param who: who's excuse
:param why: what is the excuse
:param what: what are they saying
:returns: data dict with url to image or with errors
"""
who, why, what = _sanitize_input(who), _sanitize_input(why), _sanitize_input(what)
data = get_excuse_image(who, why, what)
if isinstance(data, Image.Image):
who_hex, why_hex, what_hex = _encode_hex(who, why, what)
deploy_stage = os.environ.get('DEPLOY_STAGE')
image_url = '{scheme}://{domain}/{deploy_stage}media/{who}-{why}-{what}.png'.format(
scheme=request.scheme,
domain=request.netloc,
deploy_stage='{}/'.format(deploy_stage) if deploy_stage else '',
who=who_hex,
why=why_hex,
what=what_hex
)
return {
'data': {
'image_url': image_url,
}
}
else:
response.status = HTTP_400
return {
'errors': data
}
@hug.local()
@hug.get(
'/media/{who_hex}-{why_hex}-{what_hex}.png',
output=hug.output_format.png_image
# examples='/' TODO
)
def img(who_hex: hug.types.text, why_hex: hug.types.text, what_hex: hug.types.text):
"""
Media image view that displays image directly from app.
:param who_hex: hex representation of user's text
:param why_hex: hex representation of user's text
:param what_hex: hex representation of user's text
:returns: hug response
"""
try:
who, why, what = _decode_hex(who_hex, why_hex, what_hex)
except (BinAsciiError, UnicodeDecodeError):
raise hug.HTTPError(HTTP_404, 'message', 'invalid image path')
image = get_excuse_image(who, why, what)
if isinstance(image, Image.Image):
return image
else:
raise hug.HTTPError(HTTP_404, 'message', 'invalid image path')
def get_excuse_image(who: str, why: str, what: str) -> Union[Image.Image, list]:
"""
Load excuse template and write on it.
If there are errors (some text too long), return list of errors.
:param who: who's excuse
:param why: what is the excuse
:param what: what are they saying
:returns: pillow Image object with excuse written on it
"""
errors = []
who = 'The #1 {} excuse'.format(who).upper()
legit = 'for legitimately slacking off:'.upper()
why = '"{}."'.format(why)
what = '{}!'.format(what)
who_font = _get_text_font(24)
legit_font = _get_text_font(24)
why_font = _get_text_font(22)
what_font = _get_text_font(20)
errors = _check_user_input_size(errors, IMAGE_WIDTH, who, who_font, 1001)
errors = _check_user_input_size(errors, IMAGE_WIDTH, why, why_font, 1002)
errors = _check_user_input_size(errors, 100, what, what_font, 1003)
if errors:
return errors
# in the beginning this is an image without an excuse
image = Image.open(os.path.join(dir_path, 'blank_excuse.png'), 'r')\
.convert('RGBA')
draw = ImageDraw.Draw(image, 'RGBA')
draw.text((_get_text_x_position(IMAGE_WIDTH, who, who_font), WHO_TEXT_Y),
who, fill=(0, 0, 0, 200), font=who_font)
draw.text((_get_text_x_position(IMAGE_WIDTH, legit, legit_font), LEGIT_TEXT_Y),
legit, fill=(0, 0, 0, 200), font=legit_font)
draw.text((_get_text_x_position(IMAGE_WIDTH, why, why_font), WHY_TEXT_Y),
why, fill=(0, 0, 0, 200), font=why_font)
draw.text((_get_text_x_position(IMAGE_WIDTH, what, what_font, 25), WHAT_TEXT_Y),
what, fill=(0, 0, 0, 200), font=what_font)
buffer = BytesIO()
image.save(buffer, format="png")
return image
def _get_text_font(size: int) -> ImageFont:
"""
Loads font and sets font size for text on image
:param size: font size
:returns: ImageFont object with desired font size set
"""
return ImageFont.truetype('xkcd-script.ttf', size)
def _check_user_input_size(errors: list, max_width: float, text: str,
text_font: ImageFont, error_code: int) -> list:
"""
Checks if user input size can actually fit in image.
If not, add an error to existing list of errors.
:param errors: list of errors
:param max_width: max size of text
:param text: user's input
:param error_code: internal error code
:returns: list of errors
"""
if text_font.getsize(text)[0] > max_width:
errors.append({
'code': error_code,
'message': 'Text too long.'
})
return errors
def _get_text_x_position(image_width: int, text: str, text_font: ImageFont, offset: int=None) -> float:
"""
Calculate starting X coordinate for given text and text size.
:param text: user's text
:param text_font:
:param offset: how much to move from center of the image to the right
:returns: text's X coordinate
"""
offset = 0 if offset is None else offset
return image_width - (image_width / 2 + text_font.getsize(text)[0] / 2) - offset
def _sanitize_input(input: str) -> str:
"""
Sanitizing input so that it can be hexlifyied.
Removes extra spacing, slugifies all non-ascii chars, makes everything
uppercase.
:param input: dirty user input from get param
:returns: cleaned user input
"""
return slugify(input.strip(' .'), separator=' ').upper()
def _decode_hex(*texts) -> list:
"""
Transforms all attrs to regular (human-readable) strings.
:param texts: list of strings to be decoded
:returns: list of hex encoded strings
"""
return [unhexlify(text).decode() for text in texts]
def _encode_hex(*texts) -> list:
"""
Transforms all attrs to hex encoded strings.
:param texts: list of string to be encoded
:returns: list of hex values
"""
return [hexlify(bytes(text, 'utf-8')).decode() for text in texts]