25 Commits

Author SHA1 Message Date
Klemek 9edff02dc0 Merge pull request #2 from Klemek/dev
v1.2
2020-04-27 21:06:15 +02:00
klemek f7759a93d0 updated docs 2020-04-27 21:04:53 +02:00
klemek 910a3abe6d v1.2 2020-04-27 21:03:24 +02:00
klemek 03fa2540e3 improved argument reading 2020-04-27 21:02:55 +02:00
klemek 4bb9bbb23a code cleaning 2020-04-27 19:14:28 +02:00
klemek 2223770fb8 bot: more input sanitization 2020-04-27 18:48:31 +02:00
klemek bb0d8853bb fixing circle-ci 2020-04-27 16:34:12 +02:00
klemek 19cfa1fb2f fixing circle-ci 2020-04-27 16:33:16 +02:00
klemek 1c44357bad fixed nearest word giving weird results 2020-04-27 16:27:07 +02:00
klemek 459b87a020 unit tests 2020-04-27 16:26:27 +02:00
klemek 1d05f87a5f fixed circle-ci 2020-04-27 15:13:03 +02:00
klemek 4c140461d0 test branch 2020-04-27 15:10:44 +02:00
klemek 1519e93d2e test branch 2020-04-27 15:09:44 +02:00
klemek b9a8300f3a fixed sending input unsafe 2020-04-27 15:07:00 +02:00
klemek fd8e0a6836 updated .gitignore 2020-04-27 14:40:00 +02:00
klemek 0e118871c1 docstring -> typing 2020-04-27 14:34:15 +02:00
klemek a533b64975 docstring -> typing 2020-04-27 14:34:01 +02:00
klemek f7ac7bc5ee docstring -> typing 2020-04-27 14:32:54 +02:00
klemek de22001504 code cleaning 2020-04-27 14:10:59 +02:00
klemek 8861f002ec reworked line breaks 2020-04-27 13:57:25 +02:00
klemek 2ff0309235 readme history 2020-04-27 11:50:00 +02:00
klemek b14793618d code cleaning 2020-04-27 11:49:47 +02:00
klemek a341fd517f utils: docstrings and unit tests 2020-04-27 00:36:38 +02:00
klemek 9739566e1b utils: docstrings and unit tests 2020-04-27 00:36:16 +02:00
klemek c87eb81e36 new dev version 2020-04-26 23:40:40 +02:00
18 changed files with 415 additions and 305 deletions
+21 -5
View File
@@ -1,5 +1,15 @@
version: 2.1
workflows:
main:
jobs:
- unit-tests
- build-docs:
filters:
branches:
only:
- master
jobs:
build-docs:
docker:
@@ -19,8 +29,14 @@ jobs:
git diff-index --quiet HEAD || git commit -m 'Automated README [ci skip]'
git push origin master
name: Building docs
workflows:
main:
jobs:
- build-docs
unit-tests:
docker:
- image: circleci/python:latest
steps:
- checkout
- run:
command: |
sudo pip install -r requirements.txt
sudo pip install pytest
python -m pytest ./tests/unit
name: Unit tests
+3 -1
View File
@@ -3,4 +3,6 @@
__pycache__
error_*.txt
tmp
.key*
.key*
*.pyc
.pytest_cache
+16 -1
View File
@@ -41,4 +41,19 @@ It includes:
## Discord bot
You can invite the bot on your server with [this link](https://discordapp.com/api/oauth2/authorize?client_id=704073533776723988&permissions=43072&scope=bot).
You can invite the bot on your server with [this link](https://discordapp.com/api/oauth2/authorize?client_id=704073533776723988&permissions=43072&scope=bot).
## History
* 1.2
* Reworked text fitting
* Unit testing
* More docs
* Bug fix
* 1.1
* More docs
* Bug fix
* Empty string fix
* 1.0
* Initial release
+44 -46
View File
@@ -8,12 +8,12 @@ import sys
from datetime import datetime
from dotenv import load_dotenv
from meme_otron import img_factory as imgf
from meme_otron import meme_db as db
from meme_otron import img_factory
from meme_otron import meme_db
from meme_otron import utils
from meme_otron import meme_otron
from meme_otron import VERSION
VERSION = "1.1"
DOC_URL = "https://github.com/klemek/meme-otron/tree/master/docs/README.md"
t0 = datetime.now()
logging.basicConfig(format="[%(asctime)s][%(levelname)s][%(module)s] %(message)s", level=logging.INFO)
@@ -26,20 +26,17 @@ if token is None:
logging.error("No token was loaded, please verify your .env file")
sys.exit(1)
imgf.load_fonts()
db.load_memes()
img_factory.load_fonts()
meme_db.load_memes()
client = discord.Client()
SENT = {}
def debug(message, txt):
def debug(message: discord.Message, txt: str):
"""
Print a log with the context of the current event
:param (discord.Message) message: message that triggered the event
:param (str) txt: text of the log
"""
logging.info(f"{message.guild} > #{message.channel}: {txt}")
@@ -60,13 +57,7 @@ async def on_ready():
logging.info(f'- {guild.name}(id: {guild.id})')
async def delete(message):
"""
TODO
:param (discord.Message) message:
:rtype: bool
"""
async def delete(message: discord.Message) -> bool:
try:
await message.delete()
return True
@@ -78,24 +69,22 @@ async def delete(message):
@client.event
async def on_message(message):
async def on_message(message: discord.Message):
"""
Called when a message is sent to any channel on any guild
:param (discord.Message) message: message sent
"""
# Ignore self messages
if message.author == client.user:
return
direct = message.channel.type == discord.ChannelType.private
is_direct = message.channel.type == discord.ChannelType.private
if not direct:
mid = f'{message.guild.id}/{message.channel.id}/{message.author.id}'
if not is_direct:
message_id = f'{message.guild.id}/{message.channel.id}/{message.author.id}'
else:
mid = message.author.id
message_id = message.author.id
if direct or client.user in message.mentions:
if is_direct or client.user in message.mentions:
message.content = re.sub(r'<@[^>]+>', '', message.content).strip()
args = utils.parse_arguments(message.content)
debug(message, str(args))
@@ -113,11 +102,11 @@ async def on_message(message):
return
if len(args) > 0 and args[0].lower().strip() == "list":
await message.channel.send(f"Here is a list of all known templates:\n"
f"```{', '.join(db.LIST)}```")
f"```{', '.join(meme_db.LIST)}```")
return
if len(args) > 0 and args[0].lower().strip() == "delete":
if mid in SENT and len(SENT[mid]) > 0 and await delete(SENT[mid][-1]):
if not direct:
if message_id in SENT and len(SENT[message_id]) > 0 and await delete(SENT[message_id][-1]):
if not is_direct:
await delete(message)
else:
await message.add_reaction("")
@@ -126,21 +115,30 @@ async def on_message(message):
left_wmark_text = None
if len(args) > 1 and message.author.display_name is not None:
left_wmark_text = f"By {message.author.display_name}"
logging.info(args[0])
meme_id = re.sub(r'[^A-Za-z0-9 _]', "", args[0]).strip()
args[0] = meme_id
img = meme_otron.compute(*args, left_wmark_text=left_wmark_text)
if img is None:
hint = db.find_nearest(args[0])
response = f":warning: Template `{args[0]}` not found\n"
if hint is not None:
response += f"Did you mean `{hint}`?\n"
if len(meme_id) == 0:
response = f":warning: Template not found\n"
else:
hint = meme_db.find_nearest(meme_id)
response = f":warning: Template `{meme_id}` not found\n"
if hint is not None:
response += f"Did you mean `{hint}`?\n"
response += f"You can find a more detailed help and a list of templates at:\n" \
f"<{DOC_URL}>"
await message.channel.send(response)
if len(response) >= 2000:
await message.channel.send(f"{message.author.mention} ... really?")
else:
await message.channel.send(response)
else:
with tempfile.NamedTemporaryFile(delete=False) as output:
img.save(output, format="JPEG")
response = None
if len(args) == 1:
meme = db.get_meme(args[0])
meme = meme_db.get_meme(meme_id)
response = f"Template `{meme.id}`:"
if len(meme.aliases) > 0:
response += f"\n- Aliases: `{'`, `'.join(meme.aliases)}`"
@@ -150,18 +148,18 @@ async def on_message(message):
f"\n```{meme.id} \"" + \
"\" \"".join([f"text {i + 1}" for i in range(meme.texts_len)]) + \
"\"```"
elif not direct:
elif not is_direct:
response = f"A meme by {message.author.mention}:"
if mid not in SENT:
SENT[mid] = []
if message_id not in SENT:
SENT[message_id] = []
response = await message.channel.send(response,
file=discord.File(filename="meme.jpg", fp=output.name))
SENT[mid] += [response]
SENT[message_id] += [response]
try:
os.remove(output.name)
except PermissionError:
pass
if not direct:
if not is_direct:
await delete(message)
@@ -171,14 +169,14 @@ while True:
client.run(token)
break # clean kill
except Exception as e:
t = datetime.now()
logging.error(f"Exception raised at {t:%Y-%m-%d %H:%M} : {repr(e)}")
fileName = f"error_{t:%Y-%m-%d_%H-%M-%S}.txt"
exception_time = datetime.now()
logging.error(f"Exception raised at {exception_time:%Y-%m-%d %H:%M} : {repr(e)}")
fileName = f"error_{exception_time:%Y-%m-%d_%H-%M-%S}.txt"
if os.path.exists(fileName):
logging.error("Two many errors, killing")
break
with open(fileName, 'w') as f:
f.write(f"Discord AI Dungeon 2 v{VERSION} started at {t0:%Y-%m-%d %H:%M}\r\n"
f"Exception raised at {t:%Y-%m-%d %H:%M}\r\n"
f"\r\n"
f"{traceback.format_exc()}")
with open(fileName, 'w') as exception_file:
exception_file.write(f"Meme-Otron v{VERSION} started at {t0:%Y-%m-%d %H:%M}\r\n"
f"Exception raised at {exception_time:%Y-%m-%d %H:%M}\r\n"
f"\r\n"
f"{traceback.format_exc()}")
+2
View File
@@ -25,6 +25,8 @@ Tag the bot and use the above syntax to get started. In addition, you can use th
To get the template info, just send the meme id without texts.
> Tip : You can use `\\n` in your texts to add a line break
Enjoy the full experience of this bot by using direct messages to keep your server free of spam.
## CLI features
+15 -15
View File
@@ -2,36 +2,36 @@ import os
import logging
import PIL
from os import path
from meme_otron import img_factory as imgf
from meme_otron import img_factory
from meme_otron import meme_db
from meme_otron import utils
logging.basicConfig(format="[%(asctime)s][%(levelname)s][%(module)s] %(message)s", level=logging.WARNING)
imgf.load_fonts()
img_factory.load_fonts()
meme_db.load_memes()
dst_dir = utils.relative_path(__file__, "templates")
prev_dir = utils.relative_path(__file__, "preview")
templates_dir = utils.relative_path(__file__, "templates")
preview_dir = utils.relative_path(__file__, "preview")
doc_file = utils.relative_path(__file__, "README.md")
COLUMNS = 3
IMG_HEIGHT = 400
def make_empty(target_dir):
def make_empty(target_dir: str):
if path.exists(target_dir):
for f in os.listdir(target_dir):
if path.isfile(path.join(target_dir, f)):
os.unlink(path.join(target_dir, f))
for file in os.listdir(target_dir):
if path.isfile(path.join(target_dir, file)):
os.unlink(path.join(target_dir, file))
else:
os.mkdir(target_dir)
make_empty(dst_dir)
make_empty(prev_dir)
make_empty(templates_dir)
make_empty(preview_dir)
ids = sorted(meme_db.LIST)
id_list = sorted(meme_db.LIST)
doc_content = "|" * (COLUMNS + 1) \
+ "\n|" + ":---:|" * COLUMNS
@@ -40,14 +40,14 @@ info_line = None
img_line = None
i = None
for i, meme_id in enumerate(ids):
for i, meme_id in enumerate(id_list):
meme = meme_db.get_meme(meme_id)
img = imgf.make(meme.template, meme.texts, debug=True)
img = img_factory.build_image(meme.template, meme.texts, debug=True)
if img is not None:
img.save(path.join(dst_dir, meme.template))
img.save(path.join(templates_dir, meme.template))
size = (round(img.size[0] * IMG_HEIGHT / img.size[1]), IMG_HEIGHT)
img2 = img.resize(size, resample=PIL.Image.LANCZOS)
img2.save(path.join(prev_dir, meme.template))
img2.save(path.join(preview_dir, meme.template))
if i % COLUMNS == 0:
if info_line is not None and img_line is not None:
doc_content += info_line + img_line
+1
View File
@@ -0,0 +1 @@
VERSION = "1.2"
+20 -22
View File
@@ -1,45 +1,43 @@
import logging
import sys
import os
from . import img_factory as imgf
from . import meme_db as db
from . import img_factory
from . import meme_db
from . import meme_otron
from . import utils
from . import VERSION
if __name__ == "__main__":
db.load_memes()
imgf.load_fonts()
meme_db.load_memes()
img_factory.load_fonts()
if len(sys.argv) <= 1 or sys.argv[1].lower().strip() == "help" or "-h" in sys.argv:
print("python -m meme_otron -h\n"
# TODO better arguments reading (-h, -o, -v)
if len(sys.argv) <= 1 or utils.read_argument(sys.argv, "help", "--help", "-h"):
print(f"Meme-Otron v{VERSION}"
"python -m meme_otron -h\n"
"python -m meme_otron (meme_id) \"[text 1]\" \"[text 2]\" ... > file.jpg\n"
"python -m meme_otron -o file.jpg (meme_id) \"[text 1]\" \"[text 2]\" ...",
file=sys.stderr)
sys.exit(1)
else:
output_f = None
if "-o" in sys.argv:
i = sys.argv.index("-o")
if len(sys.argv) >= i:
output_f = sys.argv[i + 1]
del sys.argv[i + 1]
del sys.argv[i]
output_file = utils.read_argument(sys.argv, "-o", "--output", valued=True, delete=True)
img = meme_otron.compute(*sys.argv[1:])
if img is None:
hint = db.find_nearest(sys.argv[1])
if hint is not None:
print(f"Did you mean '{hint}'?", file=sys.stderr)
proposal = meme_db.find_nearest(sys.argv[1])
if proposal is not None:
print(f"Did you mean '{proposal}'?", file=sys.stderr)
sys.exit(1)
if output_f is None:
if output_file is None:
with os.fdopen(os.dup(sys.stdout.fileno())) as output:
img.save(output, format="jpeg")
else:
try:
img.save(output_f)
print(f"Wrote '{output_f}'")
img.save(output_file)
print(f"Wrote '{output_file}'")
except OSError as e:
print(f"Cannot write '{output_f}': {e}", file=sys.stderr)
print(f"Cannot write '{output_file}': {e}", file=sys.stderr)
sys.exit(1)
except ValueError as e:
print(f"Cannot write '{output_f}': {e}", file=sys.stderr)
print(f"Cannot write '{output_file}': {e}", file=sys.stderr)
sys.exit(1)
+21 -58
View File
@@ -1,9 +1,11 @@
from typing import List, Optional, Tuple
from PIL import Image, ImageFont, ImageDraw
import os
import os.path as path
import logging
from . import utils
from .types import Text
FONT_DIR = utils.relative_path(__file__, "..", "fonts")
TEMPLATES_DIR = utils.relative_path(__file__, "..", "templates")
@@ -14,9 +16,6 @@ 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":
@@ -27,16 +26,7 @@ def load_fonts():
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:
"""
def build_image(template: str, texts: List[Text], debug: bool = False) -> Optional[Image.Image]:
try:
img = Image.open(path.join(TEMPLATES_DIR, template)).convert(mode='RGBA')
except OSError as e:
@@ -50,23 +40,13 @@ def make(template, texts, debug=False):
return img.convert(mode='RGB')
def draw_text(draw, img, text, debug=False):
"""
TODO
:param (PIL.ImageDraw.ImageDraw) draw: source image canvas
:param (PIL.Image.Image) img: source image
:param (Text) text:
:param (bool) debug:
"""
# TODO rotation
# https://stackoverflow.com/questions/245447/how-do-i-draw-text-at-an-angle-using-pythons-pil
def draw_text(draw: ImageDraw.ImageDraw, img: Image.Image, text: Text, debug: bool = False):
if text.text is not None and len(text.text.strip()) > 0:
text.init() # load default values
if text.font in FONTS:
text.text, font = fit_text(img.size, text)
if text.angle == 0:
draw.text(get_pos(img.size, text, font), text.text, fill=text.fill, align=text.align, font=font,
draw.text(get_text_pos(img.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] * img.size[0], text.y_range[0] * img.size[1]),
@@ -79,7 +59,7 @@ def draw_text(draw, img, text, debug=False):
center_y = (text.y_range[0] + text.y_range[1]) * img.size[1] / 2
txt_img = Image.new('RGBA', (width, height))
txt_draw = ImageDraw.Draw(txt_img)
txt_draw.text(get_pos(img.size, text, font, relative=True), text.text, fill=text.fill,
txt_draw.text(get_text_pos(img.size, text, font, relative=True), 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:
@@ -93,47 +73,30 @@ def draw_text(draw, img, text, debug=False):
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:
"""
def fit_text(size: Tuple[int, int], text: Text) -> Tuple[str, ImageFont.FreeTypeFont]:
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
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:
text_content = ""
while (text_size is None or text_size[0] >= max_width 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
n_lines = 0
while n_lines == 0 or (text_content is not None and text_size[0] >= max_width):
n_lines += 1
text_content = utils.justify_text(text.text, n_lines)
if text_content is not None:
text_size = font.getsize_multiline(text_content, stroke_width=text.stroke_width * font_size)
if text_content is None:
# max break attained
text_size = None # retry
return text_content, font
def get_pos(size, text, font, relative=False):
"""
TODO
:param (int,int) size: source image size
:param (Text) text:
:param (PIL.ImageFont.FreeTypeFont) font:
:rtype (int,int)
:return:
"""
def get_text_pos(size: Tuple[int, int], text: Text,
font: ImageFont.FreeTypeFont, relative: bool = False) -> Tuple[int, int]:
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])
+15 -41
View File
@@ -1,3 +1,4 @@
from typing import Optional
import json
import logging
@@ -13,19 +14,14 @@ LIST = []
logger = logging.getLogger("meme_db")
def load_memes(purge=False):
"""
TODO
:param (bool) purge:
"""
def load_memes(purge: bool = False):
global DATA, ALIASES
if purge:
DATA = {}
ALIASES = {}
try:
with open(DATA_FILE) as f:
content = "".join(f.readlines())
with open(DATA_FILE) as input_file:
content = "".join(input_file.readlines())
raw_data = json.loads(content)
if not (isinstance(raw_data, list)):
raise TypeError(f"Root is not a list")
@@ -39,14 +35,9 @@ def load_memes(purge=False):
logger.error(f"Invalid data file: {e}")
def load_item(i, item):
"""
TODO
:param (int) i:
:param (dict) item:
"""
def load_item(i: int, item: dict):
global LIST
# TODO reduce complexity
item_id = ""
try:
if not (isinstance(item, dict)):
@@ -72,13 +63,13 @@ def load_item(i, item):
raw_texts = utils.read_key(item, "texts", meme.texts, types=[dict], is_list=True)
if "texts" in item:
meme.texts = []
c = 1
current_text = 1
for j in range(len(raw_texts)):
raw_text = raw_texts[j]
try:
text = load_text(c, raw_text)
text = load_text(current_text, raw_text)
if text.text_ref is None:
c += 1
current_text += 1
elif text.text_ref < 1 or text.text_ref > len(meme.texts):
logger.warning(
f"Item '{item_id}'({i + 1}) / Text {j + 1}: invalid text reference {text.text_ref}")
@@ -94,7 +85,7 @@ def load_item(i, item):
text.style_ref -= 1
text.update(meme.texts[text.style_ref])
meme.texts += [text]
meme.texts_len = c - 1
meme.texts_len = current_text - 1
except TypeError as e:
logger.warning(f"Item '{item_id}'({i + 1}) / Text {j + 1}: {e}")
for text in meme.texts:
@@ -121,19 +112,9 @@ def load_item(i, item):
logger.warning(f"Item '{item_id}'({i + 1}): {e}")
def load_text(c, raw_text, text=None):
"""
TODO
:param (int) c:
:param (dict) raw_text:
:param (Text|None) text:
:raises TypeError:
:rtype: Text
:return:
"""
def load_text(current_text: int, raw_text: dict, text: Optional[Text] = None) -> Text:
if text is None:
text = Text(f"text {c}")
text = Text(f"text {current_text}")
text.font = utils.read_key_safe(raw_text, "font", text.font, types=[str])
text.x_range = utils.read_key_safe(raw_text, "x_range", types=[float, int], is_list=True, is_list_size=2)
text.y_range = utils.read_key_safe(raw_text, "y_range", types=[float, int], is_list=True, is_list_size=2)
@@ -156,14 +137,7 @@ def load_text(c, raw_text, text=None):
return text
def get_meme(name):
"""
TODO
:param (str) name:
:rtype: Meme|None
:return:
"""
def get_meme(name: str) -> Optional[Meme]:
name = name.lower().strip().replace(" ", "_")
if name in ALIASES:
return DATA[ALIASES[name]].clone()
@@ -171,6 +145,6 @@ def get_meme(name):
return None
def find_nearest(word):
def find_nearest(word: str) -> str:
word = word.lower().strip().replace(" ", "_")
return utils.find_nearest(word, ALIASES.keys())
return utils.find_nearest(word, list(ALIASES))
+1 -5
View File
@@ -23,8 +23,6 @@ left_wmark.y_range = [0.005, 0.995]
def parse_text(s):
"""
TODO
:param (str) s:
:rtype: str
"""
@@ -33,8 +31,6 @@ def parse_text(s):
def compute(*args, left_wmark_text=None, debug=False):
"""
TODO
:param (str) left_wmark_text:
:param (bool) debug:
:param (str) args:
@@ -63,4 +59,4 @@ def compute(*args, left_wmark_text=None, debug=False):
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)
return imgf.build_image(meme.template, meme.texts, debug=debug)
+5 -22
View File
@@ -1,3 +1,4 @@
from typing import Optional
from enum import IntEnum
import copy
@@ -6,9 +7,6 @@ DEFAULT_FONT_SIZE = 0.05
class Pos(IntEnum):
"""
TODO
"""
NW = 0
N = 1
NE = 2
@@ -21,11 +19,7 @@ class Pos(IntEnum):
class Meme:
"""
TODO
"""
def __init__(self, meme_id):
def __init__(self, meme_id: str):
self.id = meme_id
self.aliases = []
self.abstract = None
@@ -35,18 +29,15 @@ class Meme:
self.texts = None
self.texts_len = 0
def clone(self):
def clone(self) -> 'Meme':
return copy.deepcopy(self)
class Text:
"""
TODO
"""
base_properties = ["font", "font_size", "fill", "stroke_width",
"stroke_fill", "align", "position"]
def __init__(self, text=None):
def __init__(self, text: Optional[str] = None):
self.text = text
self.text_ref = None
@@ -66,20 +57,12 @@ class Text:
self.align = None
self.position = None
def update(self, base):
"""
TODO
:param (Text) base:
"""
def update(self, base: 'Text'):
for prop in Text.base_properties:
if getattr(self, prop) is None:
setattr(self, prop, getattr(base, prop))
def init(self):
"""
TODO
"""
if self.x_range is None:
self.x_range = (0, 1)
if self.y_range is None:
+103 -86
View File
@@ -1,53 +1,28 @@
import re
import sys
import os.path as path
from typing import List, Optional, Union
from Levenshtein import distance
def relative_path(file, *args):
"""
TODO
:param (str) file:
:param (str) args:
:rtype str
:return:
"""
def relative_path(file: str, *args: str) -> str:
return path.realpath(path.join(path.dirname(path.realpath(file)), *args))
def read_key_safe(d, k, default=None, *, types=None, is_list=False, is_list_size=None):
"""
TODO
:param (dict) d: source dict
:param (str) k: key to read
:param default: default value
:param (list of type|None) types: types to check
:param (bool) is_list: if the type is a list of types
:param (int|None) is_list_size: size of the list to enforce or None
:raises TypeError:
:return:
"""
def read_key_safe(d: dict, k: str, default=None, *,
types: Optional[List[type]] = None,
is_list: bool = False,
is_list_size: Optional[int] = None):
try:
return read_key(d, k, default, types=types, is_list=is_list, is_list_size=is_list_size)
except KeyError:
return default
def read_key(d, k, default=None, *, types=None, is_list=False, is_list_size=None):
"""
TODO
:param (dict) d: source dict
:param (str) k: key to read
:param default: default value
:param (list of type|None) types: types to check
:param (bool) is_list: if the type is a list of types
:param (int|None) is_list_size: size of the list to enforce or None
:raises TypeError:
:raises KeyError:
:return:
"""
def read_key(d: dict, k: str, default=None, *,
types: Optional[List[type]] = None,
is_list: bool = False,
is_list_size: Optional[int] = None):
if k in d:
v = d[k]
if types is not None:
@@ -62,20 +37,10 @@ def read_key(d, k, default=None, *, types=None, is_list=False, is_list_size=None
raise KeyError(k)
def check_type(obj, types, is_list=False, is_list_size=None):
"""
TODO
:param obj:
:param (list of type|None) types: types to check
:param (bool) is_list: if the type is a list of types
:param (int|None) is_list_size: size of the list to enforce or None
:raises TypeError:
:return:
"""
def check_type(obj, types: List[type], is_list: bool = False, is_list_size: Optional[int] = None):
if is_list:
if not is_list_of(obj, types, is_list_size):
if is_list_size is None:
if is_list_size is not None:
raise TypeError(f"not a list of {is_list_size} {types[0].__name__}")
else:
raise TypeError(f"not a list of {types[0].__name__}")
@@ -84,16 +49,7 @@ def check_type(obj, types, is_list=False, is_list_size=None):
raise TypeError(f"not a {types[0].__name__}")
def is_list_of(obj, types, length=None):
"""
TODO
:param obj:
:param (list of type) types:
:param (int) length:
:rtype: bool
:return:
"""
def is_list_of(obj, types: List[type], length: Optional[int] = None) -> bool:
if not (isinstance(obj, list)):
return False
for item in obj:
@@ -112,35 +68,96 @@ def is_list_of(obj, types, length=None):
args_regex = re.compile('"([^"]*)"|\'([^\']*)\'|([^ ]+)')
def parse_arguments(s):
"""
TODO
def parse_arguments(src: str) -> List[str]:
def get_found_match(m: list) -> str:
f = [g for g in m if len(g) > 0]
if len(f) > 0:
return f[0]
return ""
:param (str) s:
:rtype: list of str
:return:
"""
return [get_found_match(m) for m in args_regex.findall(s)]
return [get_found_match(m) for m in args_regex.findall(src)]
def get_found_match(m):
f = [g for g in m if len(g) > 0]
if len(f) > 0:
return f[0]
return ""
def find_nearest(word, wlist, threshold=5):
"""
TODO
:param (str) word:
:param (list of str) wlist:
:param (int) threshold:
:rtype: str | None
:return:
"""
found = min([(distance(word, w) - abs(len(w) - len(word)), w) for w in wlist], key=lambda v: v[0])
if found[0] > threshold:
def find_nearest(word: str, wlist: List[str], threshold: int = 5) -> Optional[str]:
distances = [
(distance(word, w), # distance
abs(len(w) - len(word)), # length diff
w)
for w in wlist]
distances.sort(key=lambda v: v[1]) # sort by length diff to get the closest (in length) first
found = min(distances, key=lambda v: v[0] - v[1]) # get the closest in lev. distance
if found[0] - found[1] > threshold: # distance is too much
return None
return found[1]
return found[2]
def justify_text(src: str, n_lines: int) -> Optional[str]:
spaces_indexes = find_all(src, " ")
if n_lines - 1 > len(spaces_indexes):
return None # impossible
if n_lines - 1 == len(spaces_indexes):
return replace_at(src, "\n", spaces_indexes, 1)
breaks_positions = [k * (len(src) - 1) / n_lines for k in range(1, n_lines)]
break_indexes = place_line_breaks(breaks_positions, spaces_indexes)
return replace_at(src, "\n", break_indexes, 1)
def find_all(src: str, pattern: str) -> List[int]:
indexes = []
i = safe_index(src, pattern)
while i is not None:
indexes += [i]
i = safe_index(src, pattern, i + 1)
return indexes
def replace_at(src: str, pattern: str, indexes: List[int], remove: int) -> str:
output = ""
start_index = 0
for i in indexes:
output += src[start_index:i] + pattern
start_index = i + remove
output += src[start_index:]
return output
def place_line_breaks(breaks_positions: List[float], spaces_indexes: List[int]) -> List[int]:
breaks_positions = breaks_positions[:]
breaks_indexes = []
dist = sys.maxsize
for i, value in enumerate(spaces_indexes):
if not len(breaks_positions):
break
if dist < abs(value - breaks_positions[0]):
breaks_indexes += [spaces_indexes[i - 1]]
breaks_positions.pop(0)
else:
dist = abs(value - breaks_positions[0])
if len(breaks_positions):
breaks_indexes += [spaces_indexes[-1]]
return breaks_indexes
def safe_index(src: Union[str, list], pattern, start: int = 0):
try:
return src.index(pattern, start)
except ValueError:
return None
def read_argument(args: List[str], *names: str, valued: bool = False, delete: bool = False):
for i, arg in enumerate(args):
if arg.lower() in names:
if delete:
del args[i]
i -= 1
if not valued:
return True
else:
v = None
if i + 1 < len(args):
v = args[i + 1]
if delete:
del args[i + 1]
return v
return None
View File
View File
View File
+145
View File
@@ -0,0 +1,145 @@
from unittest import TestCase
from meme_otron import utils
class Test(TestCase):
def test_relative_path(self):
self.assertEqual(__file__, utils.relative_path(__file__, ".", "test_utils.py"))
def test_is_list_of(self):
self.assertFalse(utils.is_list_of(None, [str]))
self.assertFalse(utils.is_list_of("", [int]))
self.assertFalse(utils.is_list_of(None, [float]))
self.assertTrue(utils.is_list_of([], [str]))
self.assertTrue(utils.is_list_of(["test", "test2"], [str]))
self.assertTrue(utils.is_list_of(["test", 2.0], [str, float]))
self.assertFalse(utils.is_list_of(["test", 2.0], [int]))
self.assertTrue(utils.is_list_of(["test", "test2"], [str], length=2))
self.assertFalse(utils.is_list_of(["test", "test2", "test3"], [str], length=2))
def test_check_type(self):
try:
utils.check_type("", [str])
except TypeError as e:
self.fail(str(e))
try:
utils.check_type(0, [str, float])
self.fail("no exception")
except TypeError as e:
self.assertEqual("not a str", str(e))
try:
utils.check_type("", [str], is_list=True)
self.fail("no exception")
except TypeError as e:
self.assertEqual("not a list of str", str(e))
try:
utils.check_type([1, 0.2, 2], [float, int], is_list=True)
utils.check_type([1, 0.2, 2], [float, int], is_list=True, is_list_size=3)
except TypeError as e:
self.fail(str(e))
try:
utils.check_type([1, 0.2, 2, 2.5], [float, str], is_list=True)
self.fail("no exception")
except TypeError as e:
self.assertEqual("not a list of float", str(e))
try:
utils.check_type([1, 0.2, 2, 2.5], [float, int], is_list=True, is_list_size=3)
self.fail("no exception")
except TypeError as e:
self.assertEqual("not a list of 3 float", str(e))
def test_read_key(self):
d = {
"test1": 5,
"test2": [1, 3, ""]
}
self.assertEqual(5, utils.read_key(d, "test1"))
self.assertEqual([1, 3, ""], utils.read_key(d, "test2"))
self.assertEqual("default", utils.read_key(d, "test3", "default"))
try:
utils.read_key(d, "test3")
self.fail("no exception")
except KeyError as e:
self.assertEqual("'test3'", str(e))
try:
utils.read_key(d, "test1", types=[str])
self.fail("no exception")
except TypeError as e:
self.assertEqual("'test1' is not a str", str(e))
try:
utils.read_key(d, "test2", types=[str, int], is_list=True, is_list_size=2)
self.fail("no exception")
except TypeError as e:
self.assertEqual("'test2' is not a list of 2 str", str(e))
def test_read_key_safe(self):
d = {
"test1": 5,
"test2": [1, 3, ""]
}
self.assertEqual(5, utils.read_key_safe(d, "test1"))
self.assertEqual([1, 3, ""], utils.read_key_safe(d, "test2"))
self.assertEqual("default", utils.read_key_safe(d, "test3", "default"))
self.assertIsNone(utils.read_key_safe(d, "test3"))
def test_find_nearest(self):
self.assertEqual("test", utils.find_nearest("tost", ["test", "example", "what"]))
self.assertIsNone(utils.find_nearest("unknown", ["test", "example", "what"], threshold=2))
self.assertEqual("test", utils.find_nearest("unknown", ["test", "example", "what"], threshold=200))
def test_parse_arguments(self):
self.assertEqual([], utils.parse_arguments(""))
self.assertEqual(["test"], utils.parse_arguments("test"))
self.assertEqual(["test1", "test2"], utils.parse_arguments("test1 test2"))
self.assertEqual(["test1", "test 2", "test 3"], utils.parse_arguments("test1 'test 2' \"test 3\""))
self.assertEqual(["test1", "", ""], utils.parse_arguments("test1 '' \"\""))
def test_safe_index(self):
self.assertEqual(0, utils.safe_index("a", "a"))
self.assertEqual(0, utils.safe_index([0], 0))
self.assertEqual(2, utils.safe_index("cbaa", "a"))
self.assertEqual(3, utils.safe_index("cbaa", "a", 3))
self.assertEqual(1, utils.safe_index(["a", 0, 0], 0))
self.assertEqual(2, utils.safe_index(["a", 0, 0], 0, 2))
self.assertIsNone(utils.safe_index("a", "b"))
self.assertIsNone(utils.safe_index("a", "a", 2))
self.assertIsNone(utils.safe_index(["a", 0, 0], 0, 3))
def test_find_all(self):
self.assertEqual([], utils.find_all("abc", "n"))
self.assertEqual([0], utils.find_all("abc", "a"))
self.assertEqual([0, 2], utils.find_all("aba", "a"))
def test_replace_at(self):
self.assertEqual("abcd", utils.replace_at("abc", "d", [3], 0))
self.assertEqual("abd", utils.replace_at("abc", "d", [2], 1))
self.assertEqual("ddd", utils.replace_at("abc", "d", [0, 1, 2], 1))
self.assertEqual("a nice_plac_", utils.replace_at("a nice place", "_", [6, 11], 1))
def test_break_text(self):
self.assertIsNone(utils.justify_text("abcd", 2))
self.assertIsNone(utils.justify_text("abcd efgh", 3))
self.assertEqual("abcd", utils.justify_text("abcd", 1))
self.assertEqual("abcd\nefgh", utils.justify_text("abcd efgh", 2))
self.assertEqual("ab cd\nef gh", utils.justify_text("ab cd ef gh", 2))
self.assertEqual("ab\ncd ef\ngh", utils.justify_text("ab cd ef gh", 3))
def test_best_fit(self):
self.assertEqual([5, 9, 15], utils.place_line_breaks([5.2, 14.3, 15.2], [3, 5, 9, 15, 18]))
self.assertEqual([5, 9, 15, 18], utils.place_line_breaks([5.2, 14.3, 14.5, 15.2], [3, 5, 9, 15, 18]))
self.assertEqual([5, 9, 15, 18], utils.place_line_breaks([5.2, 14.3, 14.5, 15.2], [3, 5, 9, 15, 18, 20]))
def test_read_argument(self):
self.assertIsNone(utils.read_argument(["test", "-o", "test"], "--output"))
self.assertTrue(utils.read_argument(["test", "-O", "test"], "--output", "-o"))
self.assertIsNone(utils.read_argument(["test", "-o"], "-o", valued=True))
self.assertEqual("test1", utils.read_argument(["test", "-o", "test1", "-o", "test2"], "-o", valued=True))
args = ["test", "-o", "test1"]
self.assertTrue(utils.read_argument(args, "-o", delete=True))
self.assertEqual(["test", "test1"], args)
args = ["test", "-o", "test1"]
self.assertEqual("test1", utils.read_argument(args, "-o", valued=True, delete=True))
self.assertEqual(["test"], args)
args = ["test", "-o"]
self.assertIsNone(utils.read_argument(args, "-o", valued=True, delete=True))
self.assertEqual(["test"], args)
+3 -3
View File
@@ -4,13 +4,13 @@ import time
import datetime
import logging
from os import path
from meme_otron import img_factory as imgf
from meme_otron import img_factory
from meme_otron import meme_db
from meme_otron import utils
logging.basicConfig(format="%(message)s", level=logging.WARNING)
imgf.load_fonts()
img_factory.load_fonts()
db_file = utils.relative_path(__file__, "..", meme_db.DATA_FILE)
templates_dir = utils.relative_path(__file__, "..", "templates")
@@ -30,7 +30,7 @@ while True:
count = 0
for meme_id in meme_db.LIST:
meme = meme_db.get_meme(meme_id)
img = imgf.make(meme.template, meme.texts, debug=True)
img = img_factory.build_image(meme.template, meme.texts, debug=True)
if img is not None:
img.save(path.join(dst_dir, meme.template))
count += 1