Skip to content

Instantly share code, notes, and snippets.

@xjcl
Last active August 29, 2015 14:05
Show Gist options
  • Save xjcl/1cf1601e2bd5d421af8b to your computer and use it in GitHub Desktop.
Save xjcl/1cf1601e2bd5d421af8b to your computer and use it in GitHub Desktop.
Takes a short text quote and turns it into an inspirational image (text2quote). This gist includes a reddit bot (quotebot). License is GPL v3. Example: http://i.imgur.com/wEg3JsK.png
# WHAT?
# this is a modified version of tst's generate bot for
# text2quote (i.e. text on a fancy background) generation.
# it is stripped in that the imgur/reddit references are
# removed and it is called in the same way as my own text2quote
# module (so it is interchangable).
# UNTESTED AND HACKY, USE AT YOUR OWN RISK.
import os
import textwrap
import time
from random import choice, randint
from PIL import Image, ImageFont, ImageDraw
def get_random_file_from_directory(directory):
""" Returns the path of a random file from a directory """
# workaround for the interactive shell
files = os.listdir(directory)
files = [filename for filename in files
if filename != ".DS_Store"
and filename != "thumbs.db"] # XXX better way?
return os.path.join(directory, choice(files))
def get_font(path, size):
""" Returns a font for PIL """
return ImageFont.truetype(path, size=size)
def get_font_path():
""" Returns a random font path """
return get_random_file_from_directory("./fonts")
def get_background():
""" Returns a random background resized """
path = get_random_file_from_directory("./backgrounds")
image = Image.open(path)
return resize_background(image)
def scale(variable, scalar):
""" Takes a variable and an scalar and multiples them
rounds them and returns an integer """
return int(round(variable * scalar))
def scale_direction(variable, scalar):
""" Takes a tuple of (x, y) variable and returns the scaled version
of both """
return tuple([scale(direction, scalar) for direction in variable])
def resize_background(image, max_size=1024):
""" Takes a background and resizes it to max_size
returns a new image """
oldx, _ = image.size
scalar = round(max_size / float(oldx), 2)
x, y = scale_direction(image.size, scalar)
return image.resize((x, y))
def generate_text_location(size, draw, text, font):
""" Takes a size tuple (x, y) and
returns a random placement for the beginning
of a text as a tuple """
scalar = randint(1, 4) / 9.0
x, y = scale_direction(size, scalar)
return (x, y)
def get_letter_pixel_ratio(font):
""" Returns the letter pixel ratio for a font """
example_text = "Ab c .-,: 039"
textwidth, _ = font.getsize(example_text)
letter_pixel_ratio = textwidth / float(len(example_text))
return letter_pixel_ratio
def calculate_text_wrap(backgroundsize, textcoords, font):
letter_pixel_ratio = get_letter_pixel_ratio(font)
backgroundwidth, _ = backgroundsize
x, y = textcoords
text_wrap = int(round(((backgroundwidth * (1 - (randint(1, 3) / 6.0)) - x)
/ letter_pixel_ratio)))
return text_wrap
def print_main_text(image, text, font):
rgba = (255, 255, 255, 245)
draw = ImageDraw.Draw(image)
x, y = generate_text_location(image.size, draw, text, font)
newy = y
lineheight = 1.25
lines = textwrap.wrap(text,
width=calculate_text_wrap(image.size, (x, y), font))
_, height = font.getsize("ABCDEFGHIJKLMOPRSTUVWXYZ$%&/()0123456789")
xs = []
for line in lines:
draw.text((x, newy), line, font=font, fill=rgba)
width, _ = font.getsize(line)
xs.append(x + width)
newy += (height * lineheight)
return (max(xs), (newy - height))
def print_author_text(image, author, font, text_coords):
rgba = (240, 240, 240, 240)
draw = ImageDraw.Draw(image)
line = "- %s" % author
offsetx, offsety = font.getsize(line)
x, y = text_coords
# calculate new coordinates
newx = x - offsetx
newy = y + 1.75 * offsety
draw.text((newx, newy), line, font=font, fill=rgba)
return (newx + offsetx, newy + offsety)
def generate_picture(text, author):
if len(text) > 69:
return None
image = get_background()
fontpath = get_font_path()
# text_factor is only less than 1.5 for very long texts.
# monstrous texts will not overflow the pic, but inf. loop the program.
# TODO test if dependent on source image size (<<100, >>1024?)
# TODO write own program with box model (place box, squeeze text into)
text_factor = min(1.5, max(0.5, (1 / (len(text) / 60.0)) * 1.75))
if debug: print(text_factor)
# print main text
main_font = get_font(fontpath, scale(randint(20, 34), text_factor))
text_coords = print_main_text(image, text, main_font)
# print author text
author_font = get_font(fontpath, scale(randint(17, 25), text_factor))
maxx, maxy = print_author_text(image, author, author_font, text_coords)
# check if text is out-of-bounds
backgroundwidth, backgroundheight = image.size
if maxy > backgroundheight * 0.95 or maxx > backgroundwidth * 0.95: # XXX
outname = str(abs(hash(text)))+".png"
image.save(outname)
print(outname)
return generate_picture(text, author) # "XXX" indeed. ._.
else:
outname = str(abs(hash(text)))+".png"
image.save(outname)
return outname
def get_quote(text, author="AnderZEL"):
return generate_picture(text, author)
debug = 0
if debug:
print(get_quote("lorem ipsum dolor sit amet, consectetur adipiscing elit. donec a diam lectus. sed sit amet ipsum mauris."))
print(get_quote("lorem ipsum dolor sit amet, consectetur adipiscing elit. donec a diam"))
print(get_quote("lorem ipsum dolor sit amet, consectetur adipiscing elit."))
print(get_quote("lorem ipsum dolor sit amet"))
print(get_quote("WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"))
print(get_quote("WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"))
print(get_quote("IIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIII"))
print(get_quote("i hate you guys so much that sounds so damn bad taken out of context to xD"))
"""
Take input from certain redditors for the text2quote
(and generate_cleansed) module/s and post the results
to imgur and reddit.
Use with a settings.json file.
"""
# Partially based on Groompbot by /u/AndrewNeo.
# Not really copied but come on how should I
# can't start with an empty page or whatever.
# TODO write imgur deletion links (not just paths) to file
# TODO improve logging.
# By calling logger methods with exc_info=True parameter, traceback is
# dumped to the logger.
# logger = logging.getLogger(__name__)
import sys
import logging
import json
import time # for time.sleep() and maybe measuring
from random import randint, choice
import logging
import praw
import pyimgur
import text2quote
import generate_cleansed as gc # credit: generate.py by tst (/u/tst___)
# ---------------------------------
# misc.
# ---------------------------------
def loadSettings():
"""Load settings from file."""
try:
settingsFile = open("settings.json", "r")
except IOError:
logging.exception("Error opening settings.json.")
sys.exit(1)
try:
settings = json.load(settingsFile)
settingsFile.close()
except ValueError:
logging.exception("Error parsing settings.json.")
sys.exit(1)
# Check integrity
for variable in ["reddit_username", "reddit_password", "reddit_ua",
"poet", "imgur_client", "imgur_secret"]:
if (len(settings[variable]) == 0):
logging.critical(variable+" not set.")
sys.exit(1)
return settings
# ---------------------------------
# text/comment/pm-processing and -output
# ---------------------------------
def format_message(link):
"""Format the results into a comment formatted for reddit."""
logging.info("Formatting message.")
lmsg = link
#lmsg += ("\n\n~~--------------------------------------------------~~"+
#lmsg += ("\n\n^I ^am ^a ^bot.")# | [About](...) | [Source]"+
#"(https://github.com/xjcl/...)")
lmsg += ("\n\n^(I am a bot." +
" Will respond to /u/anderzel comments." +
#" Will delete if downvoted." +
" Pics: pdphoto.org." +
" By: /u/xjcl, /u/tst__ [unknowingly] helped." +
" Suggested: /u/TheRealLemon.)")
return lmsg
# ---------------------------------
# reddit-specific
# ---------------------------------
def getReddit(settings):
"""Get a reference to Reddit."""
r = praw.Reddit(user_agent=settings["reddit_ua"])
try:
r.login(settings["reddit_username"], settings["reddit_password"])
except:
logging.exception("Error logging into Reddit.")
sys.exit(1)
return r
def delete_unpopular_coms(reddit, username, threshold=-1):
logging.info("Deleting comments with a score less than "+str(threshold)+".")
for comment in reddit.get_redditor(username).get_comments():
if comment.score < threshold:
logging.info("Deleting '"+comment.id+"'.")
logging.debug(comment.body)
comment.delete()
def listen(reddit, im, last_com, poet="anderzel"):
"""Check newest comments for quotable material."""
logging.debug("Searching through this userhis: "+poet)
imgur_links = []
new_start = True
lerr = logging.error # space is valuable
for comment in reddit.get_redditor(poet).get_comments(): # latest 25
success = False
if new_start:
new_last_com = comment.id
new_start = False
"""TEXT2QUOTE (local)"""
if comment.id == last_com:
break # already looked at the rest
elif randint(1, 4) in [1, 2, 3, 4]:
# if newlines are in the comment, it is probably too long
# (also text2quote doesn't really support newlines)
if "\n" not in comment.body:
logging.info("Generating image from: "+comment.body)
try:
aut = "AnderZEL" # a bit different from his reddit name
filename1 = text2quote.get_quote(comment.body, author=aut)
filename2 = gc.get_quote(comment.body, author=aut)
except Exception as e:
lerr("Unexpected error while generating image:"+str(e))
# pick random if both exist, pick the other if one is None
filename = choice((filename1 or filename2,
filename2 or filename1))
if filename:
path = "./" + filename
logging.info("Image generation succeeded!")
success = True
"""IMGUR"""
if success:
success = False # it's more of a 'temporary success'
logging.info("Uploading to imgur.")
try:
uploaded_image = im.upload_image(path, title=comment.body)
logging.info("Uploaded to "+str(uploaded_image.link))
imgur_links.append(uploaded_image.link)
success = True
except Exception as e:
lerr("Unexpected error while uploading image:"+str(e))
"""REDDIT"""
if success:
logging.info("Responding to '"+str(poet)+"' ("+comment.id+").")
try:
reply_msg = format_message(str(uploaded_image.link))
comment.reply(reply_msg)
logging.info("Comment succeeded!")
except praw.errors.RateLimitExceeded:
logging.error("Comment failed (RateLimitExceeded)." +
" You need more karma in that subreddit.")
except requests.exceptions.HTTPError as e: # XXX test this code
#except urllib2.HTTPError as e:
if e.code not in [429, 500, 502, 503, 504]:
raise
logging.error("Reddit is down (error "+e.code+").")
except Exception as e:
# Used here so ids saved in last_com will still
# be written to file.
lerr("Unexpected error while replying to comment:"+str(e))
logging.info("Receiving comment-id of last comment.")
return new_last_com, imgur_links
# ---------------------------------
# main
# ---------------------------------
def runBot():
"""Start a run of the bot."""
logging.info("Starting bot.")
settings = loadSettings()
logging.info("Logging into Reddit.")
reddit = getReddit(settings)
logging.info("Connecting to Imgur.")
client_id = settings["imgur_client"]
im = pyimgur.Imgur(client_id)
"""Search comments and post"""
"""last_com prevents responding to the
same comment twice"""
last_com = ""
try:
of = open('last_com', 'r')
last_com = next(of).strip()
of.close()
except IOError:
logging.info("last_com doesn't exits.")
except StopIteration:
logging.info("last_com can't be read (no newlines).")
lerror = logging.error
while True:
logging.info("Looking for new comments.")
try:
poet = settings["poet"]
last_com, imgur_links = listen(reddit, im, last_com, poet)
#del_unpop = delete_unpopular_coms
#del_unpop(reddit, settings["reddit_username"], threshold=-1)
except requests.exceptions.HTTPError as e: # XXX test this code
if e.code not in [429, 500, 502, 503, 504]:
raise
logging.error("Reddit is down (error "+e.code+").")
except Exception as e:
lerror("Error while listening to latest reddit comments:"+str(e))
logging.info("Writing last comment id to file.")
if last_com:
try:
sf = open('last_com', 'w')
sf.write(last_com+"\n")
sf.close()
except Exception as e:
lerror("Unexpected error while writing to last_com:"+str(e))
if imgur_links:
try:
sf = open('uploads', 'a')
for link in imgur_links:
sf.write(link+"\n")
sf.close()
except Exception as e:
lerror("Unexpected error while writing to imgur_links:"+str(e))
seconds = 200
logging.info("Done! (sleep for "+str(seconds)+")")
time.sleep(seconds)
if __name__ == "__main__":
# print to console
# TODO %(asctime)s
logging.basicConfig(format='%(levelname)s:%(message)s', level=logging.INFO)
# print to log file
#logging.basicConfig(filename='log.log',level=logging.DEBUG)
runBot()
logging.shutdown()
"""
A module for turning text into inspirational
quotes. (see function get_quote)
"""
# (heavily) modified from
# https://github.com/xsleonard/text2image/blob/master/text2image.py
import Image, ImageDraw, ImageFont #py2
import os
from random import choice, randint
def adjust_size(size):
"""Make space between newlines"""
return (size[0], int(size[1]*1.5))
def draw_with_shadow(draw, text, font, padding):
"""Fake text shadow by drawing with x+1, y+1."""
x, y = padding
shadowcolor = (208, 208, 208)
fillcolor = (255, 255, 255)
draw.text((x+1, y+1), text, font=font, fill=shadowcolor) # shadow
draw.text((x, y), text, font=font, fill=fillcolor) # text
def draw_on_image(img, text, fontpath, fontsize, padding=(0,0)):
"""Draw a line of text (no newlines) on an image in-place"""
font = ImageFont.truetype(fontpath, fontsize)
draw = ImageDraw.Draw(img)
draw_with_shadow(draw, text, font, padding)
def draw_on_lines(img, text, fontpath, fontsize):
"""
Draw lines of text (separated by newlines)
centered on an image in-place.
"""
# NOTE. size will refer to font size, img.size to image size
lines = text.split("\n")
font = ImageFont.truetype(fontpath, fontsize)
# calculate an ideal constant height fit for all lines
# the example text contains letters with HIGH and low text
# if i used size[1] instead, it would vary by up to ~20%
FONT_HEIGHT = adjust_size(font.getsize("ABCDEFygqp"))[1]
start_height = img.size[1]/2.0 - (len(lines)*FONT_HEIGHT)/2.0
#start_height -= 10 # adjust for human perception
top = int(start_height)
for line in lines:
if not line: # if empty line / spacing line
top += FONT_HEIGHT
continue
size = adjust_size(font.getsize(line))
left = img.size[0]/2.0 - size[0]/2.0
left = int(left)
draw_on_image(img, line, fontpath, fontsize, padding=(left, top))
top += FONT_HEIGHT
def cut_at(text, n):
"""Cut text at nearest space _before_ the nth place."""
if n >= len(text):
# for the last line, to be included fully
return len(text)
i = n
while i > 0:
if text[i] == " ":
return i
else:
i -= 1
return n # uncuttable, wrap text instead
def add_newlines(text, newlines):
"""
Add the desired numbers of newlines evenly,
i.e. try to make all lines about the same length.
Return a string with newlines, not the list of substrings.
(WARNING: This is programmed sloppily.
Notice how we measured the text width in the font first,
but rely on the (raw) text length now)
"""
ideal_length = len(text) / float(newlines+1)
cut_to = int(ideal_length) + 5
parts = []
while newlines > 0 or text:
cut_limit = cut_at(text, cut_to)
if not cut_limit:
raise
parts.append(text[:cut_limit])
text = text[cut_limit:]
newlines -= 1
return "\n".join(parts)
def get_random_system_font_linux():
"""Gets a random system font on Linux (only tested with Ubuntu 14.04)."""
#fonts that miss the letters of english text (os: ubuntu 14.04)
bullshit_fonts = ["kacst", "kacst-one", "sinhala", "droid",
"openoffice", # only contains symbola font
"fonts-japanese-gothic.ttf", # wtf
"ttf-indic-fonts-core", "ttf-khmeros-core", "ttf-punjabi-fonts"]
# TODO alternatively whitelist font directories, not blacklist
# TODO 'intelligently' look if they exist (see comment above)
# search for a random font two directories deep
ranfolder = bullshit_fonts[0]
fontpath_stem = "/usr/share/fonts/truetype/"
while ranfolder in bullshit_fonts:
ranfolder = choice(os.listdir(fontpath_stem))
ranfolder = ranfolder + "/"
fontfolder = fontpath_stem + ranfolder
ranname = choice(os.listdir(fontfolder))
#fontpath = "/usr/share/fonts/truetype/freefont/FreeSerif.ttf"
fontpath = fontpath_stem + ranfolder + ranname
return fontpath
def get_quote(raw_text, author="AnderZEL"):
"""
Take in a text_quote and write it on top of arandom image in
./images or ./backgrounds, save it, and return the file path.
"""
use_local_fonts = True
use_system_fonts = True
if use_system_fonts:
fontpath = get_random_system_font_linux()
if use_local_fonts:
alt_fontpath = "./fonts" + "/" + choice(os.listdir("./fonts"))
if fontpath:
fontpath = choice((fontpath, alt_fontpath, alt_fontpath))
else:
fontpath = alt_fontpath
image_dirs = ("./backgrounds", "./images")
image_dir = choice(image_dirs)
img_path = image_dir + "/" + choice(os.listdir(image_dir))
img = Image.open(img_path)
img_dimension = min(img.size)
fontsize = int( img_dimension / float(randint(13, 17) ) )
font = ImageFont.truetype(fontpath, fontsize)
size = adjust_size(font.getsize(raw_text))
if debug: print(size)
if size[0] > img_dimension * 3.8:
return None # not fitting on 4 lines -> too much text
if size[0] > img_dimension:
# TODO test for small / wide(!) images
newlines = size[0] // (img_dimension)
raw_text = add_newlines(raw_text, newlines)
if not raw_text:
return None
text = raw_text + "\n\n" + author
draw_on_lines(img, text, fontpath, fontsize)
outname = str(abs(hash(raw_text)))+".png"
img.save(outname)
return outname
debug=0
if debug:
print(get_quote("lorem ipsum dolor sit amet, consectetur adipiscing elit. donec a diam lectus. sed sit amet ipsum mauris."))
print(get_quote("lorem ipsum dolor sit amet, consectetur adipiscing elit. donec a diam"))
print(get_quote("lorem ipsum dolor sit amet, consectetur adipiscing elit."))
print(get_quote("lorem ipsum dolor sit amet"))
print(get_quote("WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"))
print(get_quote("WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"))
print(get_quote("IIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIII"))
print(get_quote("i hate you guys so much that sounds so damn bad taken out of context to xD"))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment