Last active
August 29, 2015 14:05
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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")) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
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