initial commit

This commit is contained in:
klemek
2020-04-11 12:40:19 +02:00
commit 3fae24ba1e
93 changed files with 866 additions and 0 deletions
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+146
View File
@@ -0,0 +1,146 @@
from PIL import Image, ImageFont, ImageDraw
import os
import os.path as path
import logging
DEFAULT_FONT = "arial"
DEFAULT_FONT_SIZE = 0.05
FONT_DIR = "../fonts"
TEMPLATES_DIR = "../templates"
FONTS = {}
logger = logging.getLogger("img_factory")
def load_fonts():
"""
TODO
"""
for file in [f for f in os.listdir(FONT_DIR) if path.isfile(path.join(FONT_DIR, f))]:
split = path.splitext(file)
if split[-1] == ".ttf":
try:
FONTS[split[0]] = ImageFont.truetype(path.join(FONT_DIR, file))
logger.info(f"Loaded font '{split[0]}'")
except OSError:
logger.error(f"Could not load font '{split[0]}'")
def make(template, texts, debug=False):
"""
TODO
:param (str) template:
:param (list of Text) texts:
:param (bool) debug:
:rtype: PIL.Image.Image
:return:
"""
try:
img = Image.open(path.join(TEMPLATES_DIR, template))
except OSError as e:
logger.error(f"Could not read template file '{template}': {e}")
return None
draw = ImageDraw.Draw(img)
for text in texts:
draw_text(draw, img.size, text, debug=debug)
return img
def draw_text(draw, size, text, debug=False):
"""
TODO
:param (PIL.ImageDraw.ImageDraw) draw: source image canvas
:param (int,int) size: source image size
:param (Text) text:
:param (bool) debug:
"""
# TODO rotation
# https://stackoverflow.com/questions/245447/how-do-i-draw-text-at-an-angle-using-pythons-pil
if text.text is not None and len(text.text.strip()) > 0:
if text.font is None:
text.font = DEFAULT_FONT
if text.font in FONTS:
text.text, font = fit_text(size, text)
draw.text(get_pos(size, text, font), text.text, fill=text.fill, align=text.align, font=font,
stroke_width=round(text.stroke_width * font.size), stroke_fill=text.stroke_fill)
if debug:
draw.rectangle([(text.x_range[0] * size[0], text.y_range[0] * size[1]),
(text.x_range[1] * size[0], text.y_range[1] * size[1])],
None,
(128, 128, 128))
else:
logger.warning(f"Invalid font '{text.font}'")
def fit_text(size, text):
"""
:param (int,int) size: source image size
:param (Text) text:
:rtype: (str, PIL.ImageFont.FreeTypeFont)
:return:
"""
max_width = round(size[0] * (text.x_range[1] - text.x_range[0]))
max_height = round(size[1] * (text.y_range[1] - text.y_range[0]))
text_size = None
if text.font_size is None:
text.font_size = DEFAULT_FONT_SIZE
font_size = round(text.font_size * min(size)) + 1
font = FONTS[text.font]
t = ""
while (text_size is None or text_size[1] >= max_height) and font_size > 1:
font_size -= 1
font = font.font_variant(size=font_size)
words = text.text.split(" ")
t = ""
for word in words:
spacer = " "
if len(t) == 0:
spacer = ""
text_size = font.getsize_multiline(t + spacer + word, stroke_width=text.stroke_width * font_size)
if text_size[0] >= max_width:
t += "\n" + word
else:
t += spacer + word
text_size = font.getsize_multiline(t, stroke_width=text.stroke_width * font_size)
return t, font
def get_pos(size, text, font):
"""
TODO
:param (int,int) size: source image size
:param (Text) text:
:param (PIL.ImageFont.FreeTypeFont) font:
:rtype (int,int)
:return:
"""
min_x = round(text.x_range[0] * size[0])
max_x = round(text.x_range[1] * size[0])
min_y = round(text.y_range[0] * size[1])
max_y = round(text.y_range[1] * size[1])
pos_x = 0
pos_y = 0
text_size = font.getsize_multiline(text.text, stroke_width=text.stroke_width * font.size)
if text.position.value[0] == "S":
pos_y = min_y
elif text.position.value[0] == "C":
pos_y = round((min_y + max_y) / 2 - text_size[1] / 2)
elif text.position.value[0] == "N":
pos_y = max_y - text_size[1]
if text.position.value[1] == "W":
pos_x = min_x
elif text.position.value[1] == "C":
pos_x = round((min_x + max_x) / 2 - text_size[0] / 2)
elif text.position.value[1] == "E":
pos_x = max_x - text_size[0]
return pos_x, pos_y
+63
View File
@@ -0,0 +1,63 @@
import logging
import sys
import os
from .types import Text, Pos
from . import img_factory as imgf
from . import meme_db as db
logger = logging.getLogger("meme_otron")
right_wmark = Text("Made with meme-otron")
right_wmark.position = Pos.SE
right_wmark.fill = (128, 128, 128, 128)
right_wmark.font_size = 0.02
right_wmark.x_range = [0.005, 0.995]
right_wmark.y_range = [0.005, 0.995]
left_wmark = Text()
left_wmark.position = Pos.SW
left_wmark.fill = (128, 128, 128, 128)
left_wmark.font_size = 0.02
left_wmark.x_range = [0.005, 0.995]
left_wmark.y_range = [0.005, 0.995]
def compute(*args, left_wmark_text=None, debug=False):
"""
TODO
:param (str) left_wmark_text:
:param (bool) debug:
:param (str) args:
:rtype: PIL.Image.Image
:return:
"""
if len(args) < 1:
logger.warning("python3 meme_otron.py (meme_id) \"[text 1]\" \"[text 2]\" ... > file.jpg")
return None
meme_id = args[0]
meme = db.get_meme(meme_id)
if meme is None:
logger.warning(f"Meme template '{meme_id}' not found")
return None
if len(args) > 1:
for i in range(len(meme.texts)):
if i < len(args) - 1:
meme.texts[i].text = args[i + 1]
else:
meme.texts[i].text = ""
meme.texts += [right_wmark]
if left_wmark_text is not None:
left_wmark.text = left_wmark_text
meme.texts += [left_wmark]
return imgf.make(meme.template, meme.texts, debug=debug)
if __name__ == "__main__":
logging.basicConfig(format="[%(asctime)s][%(levelname)s][%(module)s] %(message)s", level=logging.DEBUG)
db.load_memes()
imgf.load_fonts()
img = compute(*sys.argv[1:])
with os.fdopen(os.dup(sys.stdout.fileno())) as output:
img.save(output, format="jpeg")
+173
View File
@@ -0,0 +1,173 @@
import json
import logging
from .types import Pos, Text, Meme
from . import utils
DATA_FILE = "../memes.json"
DATA = {}
ALIASES = {}
logger = logging.getLogger("meme_db")
def load_memes():
"""
TODO
"""
try:
with open(DATA_FILE) as f:
content = "".join(f.readlines())
raw_data = json.loads(content)
if not (isinstance(raw_data, list)):
raise TypeError(f"Root is not a list")
for i in range(len(raw_data)):
load_item(i, raw_data[i])
except OSError as e:
logger.error(f"Could not read data file: {e}")
except json.decoder.JSONDecodeError as e:
logger.error(f"Wrong JSON syntax '{DATA_FILE}': {e}")
except TypeError as e:
logger.error(f"Invalid data file: {e}")
def load_item(i, item):
"""
TODO
:param (int) i:
:param (dict) item:
"""
item_id = ""
try:
if not (isinstance(item, dict)):
raise TypeError(f"root is not a dict")
item_id = utils.read_key(item, "id")
if item_id in DATA:
raise NameError(f"id '{item_id}' already existing")
based_on = utils.read_key_safe(item, "based_on")
abstract = utils.read_key_safe(item, "abstract", False)
aliases = utils.read_key_safe(item, "aliases", [])
if not utils.is_list_of(aliases, [str]):
raise TypeError(f"'aliases' is not a list of str")
template = None
font = None
font_size = None
texts = None
if based_on is not None:
if based_on in DATA:
template = DATA[based_on].template
font = DATA[based_on].font
font_size = DATA[based_on].font_size
texts = DATA[based_on].clone_texts()
else:
raise NameError(f"Reference '{based_on}' not found in data, make sur it's placed before this one")
if not abstract:
template = utils.read_key(item, "template", template)
font = utils.read_key_safe(item, "font", font)
font_size = utils.read_key_safe(item, "font_size", font_size)
raw_texts = utils.read_key(item, "texts", texts)
if texts is None:
if not (isinstance(raw_texts, list)):
raise TypeError(f"'texts' is not a list")
texts = []
for j in range(len(raw_texts)):
raw_text = raw_texts[j]
try:
texts += [load_text(j, raw_text)]
except TypeError as e:
logger.warning(f"Item '{item_id}'({i}) / Text {j}: {e}")
if font is not None:
if not (isinstance(font, str)):
raise TypeError(f"'font' is not a str")
for text in texts:
if text.font is None:
text.font = font
if font_size is not None:
if not (isinstance(font_size, float)):
raise TypeError(f"'font_size' is not a float")
for text in texts:
if text.font_size is None:
text.font_size = font_size
if len(texts) == 0:
logger.warning(f"Item '{item_id}'({i}): no texts loaded")
else:
DATA[item_id] = Meme(item_id, aliases, abstract, template, font, font_size, texts)
for alias in aliases:
ALIASES[alias] = item_id
logger.info(f"Loaded meme '{item_id}' with {len(texts)} texts")
except KeyError as e:
logger.warning(f"Item '{item_id}'({i}): key {e} not found")
except TypeError as e:
logger.warning(f"Item '{item_id}'({i}): {e}")
except NameError as e:
logger.warning(f"Item '{item_id}'({i}): {e}")
def load_text(j, raw_text):
"""
TODO
:param (int) j:
:param (dict) raw_text:
:raises TypeError:
:rtype: Text
:return:
"""
if not (isinstance(raw_text, dict)):
raise TypeError(f"root is not a dict")
text = Text(f"text {j+1}")
if "font" in raw_text:
if not (isinstance(raw_text["font"], str)):
raise TypeError(f"'font' is not a str")
text.font = raw_text["font"]
if "x_range" in raw_text:
if not (utils.is_list_of(raw_text["x_range"], [int, float], 2)):
raise TypeError(f"'x_range' is not a list of 2 float")
text.x_range = raw_text["x_range"]
if "y_range" in raw_text:
if not (utils.is_list_of(raw_text["y_range"], [int, float], 2)):
raise TypeError(f"'y_range' is not a list of 2 float")
text.y_range = raw_text["y_range"]
if "position" in raw_text:
if raw_text["position"] not in [p.name for p in Pos]:
raise TypeError(f"'position' is not a valid position (ex: NW, E, SE, ...)")
text.position = [p for p in Pos if p.name == raw_text["position"]][0]
if "font_size" in raw_text:
if not (isinstance(raw_text["font_size"], float)):
raise TypeError(f"'font_size' is not a float")
text.font_size = raw_text["font_size"]
if "fill" in raw_text:
if not (utils.is_list_of(raw_text["fill"], [int], 3)):
raise TypeError(f"'fill' is not a list of 3 int")
text.fill = raw_text["fill"]
if "stroke_width" in raw_text:
if not (isinstance(raw_text["stroke_width"], float)):
raise TypeError(f"'stroke_width' is not a float")
text.stroke_width = raw_text["stroke_width"]
if "stroke_fill" in raw_text:
if not (utils.is_list_of(raw_text["stroke_fill"], [int], 3)):
raise TypeError(f"'stroke_fill' is not a list of 3 int")
text.stroke_fill = raw_text["stroke_fill"]
if "align" in raw_text:
if raw_text["align"] not in ["left", "center", "right"]:
raise TypeError(f"'align' is not 'left', 'center' or 'right'")
text.align = raw_text["align"]
return text
def get_meme(name):
"""
TODO
:param (str) name:
:rtype: Meme|None
:return:
"""
if name in DATA and not DATA[name].abstract:
return DATA[name].clone()
elif name in ALIASES:
return DATA[ALIASES[name]].clone()
else:
return None
+62
View File
@@ -0,0 +1,62 @@
from enum import Enum
import copy
class Pos(Enum):
"""
TODO
"""
NW = "NW"
N = "NC"
NE = "NE"
W = "CW"
CENTER = "CC"
E = "CE"
SW = "NW"
S = "NC"
SE = "NE"
class Meme:
"""
TODO
"""
def __init__(self, meme_id, aliases, abstract, template, font, font_size, texts):
self.id = meme_id
self.aliases = aliases
self.abstract = abstract
self.template = template
self.font = font
self.font_size = font_size
self.texts = texts
def clone_texts(self):
return copy.deepcopy(self.texts)
def clone(self):
return Meme(self.id,
self.aliases,
self.abstract,
self.template,
self.font,
self.font_size,
self.clone_texts())
class Text:
"""
TODO
"""
def __init__(self, text=None):
self.text = text
self.x_range = (0, 1)
self.y_range = (0, 1)
self.position = Pos.CENTER
self.font_size = None
self.fill = (0, 0, 0)
self.stroke_width = 0
self.stroke_fill = (0, 0, 0)
self.font = None
self.align = "center"
+83
View File
@@ -0,0 +1,83 @@
import re
def read_key_safe(d, k, default=None):
"""
TODO
:param (dict) d: source dict
:param (str) k: key to read
:param default: default value
:return:
"""
if k in d:
return d[k]
else:
return default
def read_key(d, k, default=None):
"""
TODO
:param (dict) d: source dict
:param (str) k: key to read
:param default: default value
:return:
"""
if k in d:
return d[k]
elif default is not None:
return default
else:
raise KeyError(k)
def is_list_of(obj, types, length=None):
"""
TODO
:param obj:
:param (list of type) types:
:param (int) length:
:rtype: bool
:return:
"""
if not (isinstance(obj, list)):
return False
for item in obj:
found = False
for t in types:
if isinstance(item, t):
found = True
break
if not found:
return False
if length is not None and len(obj) != length:
return False
return True
def is_url(s):
"""
TODO
:param (str) s:
:rtype: bool
:return:
"""
return False # TODO
args_regex = re.compile('"([^"]*)"|\'([^\']*)\'|([^ ]+)')
def parse_arguments(s):
"""
TODO
:param (str) s:
:rtype: list of str
:return:
"""
return [[g for g in m if len(g) > 0][0] for m in args_regex.findall(s)]