31 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
klemek ff337ecea7 1.1 fixed arguments parsing 2020-04-26 23:38:01 +02:00
klemek cd329a9001 new dev version 2020-04-26 23:30:33 +02:00
klemek a7a22861f6 v1.1 2020-04-26 23:29:09 +02:00
klemek 8855479ecd updated docs 2020-04-26 23:28:48 +02:00
klemek c42f6452fd sample message fix 2020-04-26 23:28:30 +02:00
klemek b3165f7acc New branch 2020-04-26 23:10:46 +02:00
18 changed files with 418 additions and 299 deletions
+21 -5
View File
@@ -1,5 +1,15 @@
version: 2.1 version: 2.1
workflows:
main:
jobs:
- unit-tests
- build-docs:
filters:
branches:
only:
- master
jobs: jobs:
build-docs: build-docs:
docker: docker:
@@ -19,8 +29,14 @@ jobs:
git diff-index --quiet HEAD || git commit -m 'Automated README [ci skip]' git diff-index --quiet HEAD || git commit -m 'Automated README [ci skip]'
git push origin master git push origin master
name: Building docs name: Building docs
unit-tests:
workflows: docker:
main: - image: circleci/python:latest
jobs: steps:
- build-docs - 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__ __pycache__
error_*.txt error_*.txt
tmp tmp
.key* .key*
*.pyc
.pytest_cache
+16 -1
View File
@@ -41,4 +41,19 @@ It includes:
## Discord bot ## 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
+45 -47
View File
@@ -8,12 +8,12 @@ import sys
from datetime import datetime from datetime import datetime
from dotenv import load_dotenv from dotenv import load_dotenv
from meme_otron import img_factory as imgf from meme_otron import img_factory
from meme_otron import meme_db as db from meme_otron import meme_db
from meme_otron import utils from meme_otron import utils
from meme_otron import meme_otron from meme_otron import meme_otron
from meme_otron import VERSION
VERSION = "1.0"
DOC_URL = "https://github.com/klemek/meme-otron/tree/master/docs/README.md" DOC_URL = "https://github.com/klemek/meme-otron/tree/master/docs/README.md"
t0 = datetime.now() t0 = datetime.now()
logging.basicConfig(format="[%(asctime)s][%(levelname)s][%(module)s] %(message)s", level=logging.INFO) 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") logging.error("No token was loaded, please verify your .env file")
sys.exit(1) sys.exit(1)
imgf.load_fonts() img_factory.load_fonts()
db.load_memes() meme_db.load_memes()
client = discord.Client() client = discord.Client()
SENT = {} SENT = {}
def debug(message, txt): def debug(message: discord.Message, txt: str):
""" """
Print a log with the context of the current event 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}") logging.info(f"{message.guild} > #{message.channel}: {txt}")
@@ -60,13 +57,7 @@ async def on_ready():
logging.info(f'- {guild.name}(id: {guild.id})') logging.info(f'- {guild.name}(id: {guild.id})')
async def delete(message): async def delete(message: discord.Message) -> bool:
"""
TODO
:param (discord.Message) message:
:rtype: bool
"""
try: try:
await message.delete() await message.delete()
return True return True
@@ -78,24 +69,22 @@ async def delete(message):
@client.event @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 Called when a message is sent to any channel on any guild
:param (discord.Message) message: message sent
""" """
# Ignore self messages # Ignore self messages
if message.author == client.user: if message.author == client.user:
return return
direct = message.channel.type == discord.ChannelType.private is_direct = message.channel.type == discord.ChannelType.private
if not direct: if not is_direct:
mid = f'{message.guild.id}/{message.channel.id}/{message.author.id}' message_id = f'{message.guild.id}/{message.channel.id}/{message.author.id}'
else: 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() message.content = re.sub(r'<@[^>]+>', '', message.content).strip()
args = utils.parse_arguments(message.content) args = utils.parse_arguments(message.content)
debug(message, str(args)) debug(message, str(args))
@@ -113,11 +102,11 @@ async def on_message(message):
return return
if len(args) > 0 and args[0].lower().strip() == "list": if len(args) > 0 and args[0].lower().strip() == "list":
await message.channel.send(f"Here is a list of all known templates:\n" await message.channel.send(f"Here is a list of all known templates:\n"
f"```{', '.join(db.LIST)}```") f"```{', '.join(meme_db.LIST)}```")
return return
if len(args) > 0 and args[0].lower().strip() == "delete": 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 message_id in SENT and len(SENT[message_id]) > 0 and await delete(SENT[message_id][-1]):
if not direct: if not is_direct:
await delete(message) await delete(message)
else: else:
await message.add_reaction("") await message.add_reaction("")
@@ -126,21 +115,30 @@ async def on_message(message):
left_wmark_text = None left_wmark_text = None
if len(args) > 1 and message.author.display_name is not None: if len(args) > 1 and message.author.display_name is not None:
left_wmark_text = f"By {message.author.display_name}" 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) img = meme_otron.compute(*args, left_wmark_text=left_wmark_text)
if img is None: if img is None:
hint = db.find_nearest(args[0]) if len(meme_id) == 0:
response = f":warning: Template `{args[0]}` not found\n" response = f":warning: Template not found\n"
if hint is not None: else:
response += f"Did you mean `{hint}`?\n" 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" \ response += f"You can find a more detailed help and a list of templates at:\n" \
f"<{DOC_URL}>" 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: else:
with tempfile.NamedTemporaryFile(delete=False) as output: with tempfile.NamedTemporaryFile(delete=False) as output:
img.save(output, format="JPEG") img.save(output, format="JPEG")
response = None response = None
if len(args) == 1: if len(args) == 1:
meme = db.get_meme(args[0]) meme = meme_db.get_meme(meme_id)
response = f"Template `{meme.id}`:" response = f"Template `{meme.id}`:"
if len(meme.aliases) > 0: if len(meme.aliases) > 0:
response += f"\n- Aliases: `{'`, `'.join(meme.aliases)}`" response += f"\n- Aliases: `{'`, `'.join(meme.aliases)}`"
@@ -148,20 +146,20 @@ async def on_message(message):
response += f"\n- More info: <{meme.info}>" response += f"\n- More info: <{meme.info}>"
response += f"\n- Use:" \ response += f"\n- Use:" \
f"\n```{meme.id} \"" + \ f"\n```{meme.id} \"" + \
"\" \"".join([f"text {i}" for i in range(meme.texts_len)]) + \ "\" \"".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}:" response = f"A meme by {message.author.mention}:"
if mid not in SENT: if message_id not in SENT:
SENT[mid] = [] SENT[message_id] = []
response = await message.channel.send(response, response = await message.channel.send(response,
file=discord.File(filename="meme.jpg", fp=output.name)) file=discord.File(filename="meme.jpg", fp=output.name))
SENT[mid] += [response] SENT[message_id] += [response]
try: try:
os.remove(output.name) os.remove(output.name)
except PermissionError: except PermissionError:
pass pass
if not direct: if not is_direct:
await delete(message) await delete(message)
@@ -171,14 +169,14 @@ while True:
client.run(token) client.run(token)
break # clean kill break # clean kill
except Exception as e: except Exception as e:
t = datetime.now() exception_time = datetime.now()
logging.error(f"Exception raised at {t:%Y-%m-%d %H:%M} : {repr(e)}") logging.error(f"Exception raised at {exception_time:%Y-%m-%d %H:%M} : {repr(e)}")
fileName = f"error_{t:%Y-%m-%d_%H-%M-%S}.txt" fileName = f"error_{exception_time:%Y-%m-%d_%H-%M-%S}.txt"
if os.path.exists(fileName): if os.path.exists(fileName):
logging.error("Two many errors, killing") logging.error("Two many errors, killing")
break break
with open(fileName, 'w') as f: with open(fileName, 'w') as exception_file:
f.write(f"Discord AI Dungeon 2 v{VERSION} started at {t0:%Y-%m-%d %H:%M}\r\n" exception_file.write(f"Meme-Otron 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"Exception raised at {exception_time:%Y-%m-%d %H:%M}\r\n"
f"\r\n" f"\r\n"
f"{traceback.format_exc()}") f"{traceback.format_exc()}")
+4
View File
@@ -23,6 +23,10 @@ Tag the bot and use the above syntax to get started. In addition, you can use th
* Use `list` to get a list of all meme ids * Use `list` to get a list of all meme ids
* Use `delete` to delete the last message sent by the bot (directed to you) * Use `delete` to delete the last message sent by the bot (directed to you)
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. Enjoy the full experience of this bot by using direct messages to keep your server free of spam.
## CLI features ## CLI features
+15 -15
View File
@@ -2,36 +2,36 @@ import os
import logging import logging
import PIL import PIL
from os import path 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 meme_db
from meme_otron import utils from meme_otron import utils
logging.basicConfig(format="[%(asctime)s][%(levelname)s][%(module)s] %(message)s", level=logging.WARNING) 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() meme_db.load_memes()
dst_dir = utils.relative_path(__file__, "templates") templates_dir = utils.relative_path(__file__, "templates")
prev_dir = utils.relative_path(__file__, "preview") preview_dir = utils.relative_path(__file__, "preview")
doc_file = utils.relative_path(__file__, "README.md") doc_file = utils.relative_path(__file__, "README.md")
COLUMNS = 3 COLUMNS = 3
IMG_HEIGHT = 400 IMG_HEIGHT = 400
def make_empty(target_dir): def make_empty(target_dir: str):
if path.exists(target_dir): if path.exists(target_dir):
for f in os.listdir(target_dir): for file in os.listdir(target_dir):
if path.isfile(path.join(target_dir, f)): if path.isfile(path.join(target_dir, file)):
os.unlink(path.join(target_dir, f)) os.unlink(path.join(target_dir, file))
else: else:
os.mkdir(target_dir) os.mkdir(target_dir)
make_empty(dst_dir) make_empty(templates_dir)
make_empty(prev_dir) make_empty(preview_dir)
ids = sorted(meme_db.LIST) id_list = sorted(meme_db.LIST)
doc_content = "|" * (COLUMNS + 1) \ doc_content = "|" * (COLUMNS + 1) \
+ "\n|" + ":---:|" * COLUMNS + "\n|" + ":---:|" * COLUMNS
@@ -40,14 +40,14 @@ info_line = None
img_line = None img_line = None
i = 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) 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: 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) size = (round(img.size[0] * IMG_HEIGHT / img.size[1]), IMG_HEIGHT)
img2 = img.resize(size, resample=PIL.Image.LANCZOS) 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 i % COLUMNS == 0:
if info_line is not None and img_line is not None: if info_line is not None and img_line is not None:
doc_content += info_line + img_line 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 sys
import os import os
from . import img_factory as imgf from . import img_factory
from . import meme_db as db from . import meme_db
from . import meme_otron from . import meme_otron
from . import utils
from . import VERSION
if __name__ == "__main__": if __name__ == "__main__":
db.load_memes() meme_db.load_memes()
imgf.load_fonts() img_factory.load_fonts()
if len(sys.argv) <= 1 or sys.argv[1].lower().strip() == "help" or "-h" in sys.argv: # TODO better arguments reading (-h, -o, -v)
print("python -m meme_otron -h\n"
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 (meme_id) \"[text 1]\" \"[text 2]\" ... > file.jpg\n"
"python -m meme_otron -o file.jpg (meme_id) \"[text 1]\" \"[text 2]\" ...", "python -m meme_otron -o file.jpg (meme_id) \"[text 1]\" \"[text 2]\" ...",
file=sys.stderr) file=sys.stderr)
sys.exit(1) sys.exit(1)
else: else:
output_f = None output_file = utils.read_argument(sys.argv, "-o", "--output", valued=True, delete=True)
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]
img = meme_otron.compute(*sys.argv[1:]) img = meme_otron.compute(*sys.argv[1:])
if img is None: if img is None:
hint = db.find_nearest(sys.argv[1]) proposal = meme_db.find_nearest(sys.argv[1])
if hint is not None: if proposal is not None:
print(f"Did you mean '{hint}'?", file=sys.stderr) print(f"Did you mean '{proposal}'?", file=sys.stderr)
sys.exit(1) sys.exit(1)
if output_f is None: if output_file is None:
with os.fdopen(os.dup(sys.stdout.fileno())) as output: with os.fdopen(os.dup(sys.stdout.fileno())) as output:
img.save(output, format="jpeg") img.save(output, format="jpeg")
else: else:
try: try:
img.save(output_f) img.save(output_file)
print(f"Wrote '{output_f}'") print(f"Wrote '{output_file}'")
except OSError as e: 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) sys.exit(1)
except ValueError as e: 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) sys.exit(1)
+21 -58
View File
@@ -1,9 +1,11 @@
from typing import List, Optional, Tuple
from PIL import Image, ImageFont, ImageDraw from PIL import Image, ImageFont, ImageDraw
import os import os
import os.path as path import os.path as path
import logging import logging
from . import utils from . import utils
from .types import Text
FONT_DIR = utils.relative_path(__file__, "..", "fonts") FONT_DIR = utils.relative_path(__file__, "..", "fonts")
TEMPLATES_DIR = utils.relative_path(__file__, "..", "templates") TEMPLATES_DIR = utils.relative_path(__file__, "..", "templates")
@@ -14,9 +16,6 @@ logger = logging.getLogger("img_factory")
def load_fonts(): def load_fonts():
"""
TODO
"""
for file in [f for f in os.listdir(FONT_DIR) if path.isfile(path.join(FONT_DIR, f))]: for file in [f for f in os.listdir(FONT_DIR) if path.isfile(path.join(FONT_DIR, f))]:
split = path.splitext(file) split = path.splitext(file)
if split[-1] == ".ttf": if split[-1] == ".ttf":
@@ -27,16 +26,7 @@ def load_fonts():
logger.error(f"Could not load font '{split[0]}'") logger.error(f"Could not load font '{split[0]}'")
def make(template, texts, debug=False): def build_image(template: str, texts: List[Text], debug: bool = False) -> Optional[Image.Image]:
"""
TODO
:param (str) template:
:param (list of Text) texts:
:param (bool) debug:
:rtype: PIL.Image.Image
:return:
"""
try: try:
img = Image.open(path.join(TEMPLATES_DIR, template)).convert(mode='RGBA') img = Image.open(path.join(TEMPLATES_DIR, template)).convert(mode='RGBA')
except OSError as e: except OSError as e:
@@ -50,23 +40,13 @@ def make(template, texts, debug=False):
return img.convert(mode='RGB') return img.convert(mode='RGB')
def draw_text(draw, img, text, debug=False): def draw_text(draw: ImageDraw.ImageDraw, img: Image.Image, text: Text, debug: bool = 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
if text.text is not None and len(text.text.strip()) > 0: if text.text is not None and len(text.text.strip()) > 0:
text.init() # load default values text.init() # load default values
if text.font in FONTS: if text.font in FONTS:
text.text, font = fit_text(img.size, text) text.text, font = fit_text(img.size, text)
if text.angle == 0: 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) stroke_width=round(text.stroke_width * font.size), stroke_fill=text.stroke_fill)
if debug: if debug:
draw.rectangle([(text.x_range[0] * img.size[0], text.y_range[0] * img.size[1]), 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 center_y = (text.y_range[0] + text.y_range[1]) * img.size[1] / 2
txt_img = Image.new('RGBA', (width, height)) txt_img = Image.new('RGBA', (width, height))
txt_draw = ImageDraw.Draw(txt_img) 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), align=text.align, font=font, stroke_width=round(text.stroke_width * font.size),
stroke_fill=text.stroke_fill) stroke_fill=text.stroke_fill)
if debug: if debug:
@@ -93,47 +73,30 @@ def draw_text(draw, img, text, debug=False):
logger.warning(f"Invalid font '{text.font}'") logger.warning(f"Invalid font '{text.font}'")
def fit_text(size, text): def fit_text(size: Tuple[int, int], text: Text) -> Tuple[str, ImageFont.FreeTypeFont]:
"""
: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_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])) max_height = round(size[1] * (text.y_range[1] - text.y_range[0]))
text_size = None text_size = None
font_size = round(text.font_size * min(size)) + 1 font_size = round(text.font_size * min(size)) + 1
font = FONTS[text.font] font = FONTS[text.font]
t = "" text_content = ""
while (text_size is None or text_size[1] >= max_height) and font_size > 1: 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_size -= 1
font = font.font_variant(size=font_size) font = font.font_variant(size=font_size)
words = text.text.split(" ") n_lines = 0
t = "" while n_lines == 0 or (text_content is not None and text_size[0] >= max_width):
for word in words: n_lines += 1
spacer = " " text_content = utils.justify_text(text.text, n_lines)
if len(t) == 0: if text_content is not None:
spacer = "" text_size = font.getsize_multiline(text_content, stroke_width=text.stroke_width * font_size)
text_size = font.getsize_multiline(t + spacer + word, stroke_width=text.stroke_width * font_size) if text_content is None:
if text_size[0] >= max_width: # max break attained
t += "\n" + word text_size = None # retry
else: return text_content, font
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, relative=False): def get_text_pos(size: Tuple[int, int], text: Text,
""" font: ImageFont.FreeTypeFont, relative: bool = False) -> Tuple[int, int]:
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]) min_x = round(text.x_range[0] * size[0])
max_x = round(text.x_range[1] * size[0]) max_x = round(text.x_range[1] * size[0])
min_y = round(text.y_range[0] * size[1]) min_y = round(text.y_range[0] * size[1])
+15 -41
View File
@@ -1,3 +1,4 @@
from typing import Optional
import json import json
import logging import logging
@@ -13,19 +14,14 @@ LIST = []
logger = logging.getLogger("meme_db") logger = logging.getLogger("meme_db")
def load_memes(purge=False): def load_memes(purge: bool = False):
"""
TODO
:param (bool) purge:
"""
global DATA, ALIASES global DATA, ALIASES
if purge: if purge:
DATA = {} DATA = {}
ALIASES = {} ALIASES = {}
try: try:
with open(DATA_FILE) as f: with open(DATA_FILE) as input_file:
content = "".join(f.readlines()) content = "".join(input_file.readlines())
raw_data = json.loads(content) raw_data = json.loads(content)
if not (isinstance(raw_data, list)): if not (isinstance(raw_data, list)):
raise TypeError(f"Root is not a 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}") logger.error(f"Invalid data file: {e}")
def load_item(i, item): def load_item(i: int, item: dict):
"""
TODO
:param (int) i:
:param (dict) item:
"""
global LIST global LIST
# TODO reduce complexity
item_id = "" item_id = ""
try: try:
if not (isinstance(item, dict)): 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) raw_texts = utils.read_key(item, "texts", meme.texts, types=[dict], is_list=True)
if "texts" in item: if "texts" in item:
meme.texts = [] meme.texts = []
c = 1 current_text = 1
for j in range(len(raw_texts)): for j in range(len(raw_texts)):
raw_text = raw_texts[j] raw_text = raw_texts[j]
try: try:
text = load_text(c, raw_text) text = load_text(current_text, raw_text)
if text.text_ref is None: if text.text_ref is None:
c += 1 current_text += 1
elif text.text_ref < 1 or text.text_ref > len(meme.texts): elif text.text_ref < 1 or text.text_ref > len(meme.texts):
logger.warning( logger.warning(
f"Item '{item_id}'({i + 1}) / Text {j + 1}: invalid text reference {text.text_ref}") 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.style_ref -= 1
text.update(meme.texts[text.style_ref]) text.update(meme.texts[text.style_ref])
meme.texts += [text] meme.texts += [text]
meme.texts_len = c - 1 meme.texts_len = current_text - 1
except TypeError as e: except TypeError as e:
logger.warning(f"Item '{item_id}'({i + 1}) / Text {j + 1}: {e}") logger.warning(f"Item '{item_id}'({i + 1}) / Text {j + 1}: {e}")
for text in meme.texts: for text in meme.texts:
@@ -121,19 +112,9 @@ def load_item(i, item):
logger.warning(f"Item '{item_id}'({i + 1}): {e}") logger.warning(f"Item '{item_id}'({i + 1}): {e}")
def load_text(c, raw_text, text=None): def load_text(current_text: int, raw_text: dict, text: Optional[Text] = None) -> Text:
"""
TODO
:param (int) c:
:param (dict) raw_text:
:param (Text|None) text:
:raises TypeError:
:rtype: Text
:return:
"""
if text is None: 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.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.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) 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 return text
def get_meme(name): def get_meme(name: str) -> Optional[Meme]:
"""
TODO
:param (str) name:
:rtype: Meme|None
:return:
"""
name = name.lower().strip().replace(" ", "_") name = name.lower().strip().replace(" ", "_")
if name in ALIASES: if name in ALIASES:
return DATA[ALIASES[name]].clone() return DATA[ALIASES[name]].clone()
@@ -171,6 +145,6 @@ def get_meme(name):
return None return None
def find_nearest(word): def find_nearest(word: str) -> str:
word = word.lower().strip().replace(" ", "_") 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): def parse_text(s):
""" """
TODO
:param (str) s: :param (str) s:
:rtype: str :rtype: str
""" """
@@ -33,8 +31,6 @@ def parse_text(s):
def compute(*args, left_wmark_text=None, debug=False): def compute(*args, left_wmark_text=None, debug=False):
""" """
TODO
:param (str) left_wmark_text: :param (str) left_wmark_text:
:param (bool) debug: :param (bool) debug:
:param (str) args: :param (str) args:
@@ -63,4 +59,4 @@ def compute(*args, left_wmark_text=None, debug=False):
if left_wmark_text is not None: if left_wmark_text is not None:
left_wmark.text = left_wmark_text left_wmark.text = left_wmark_text
meme.texts += [left_wmark] 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 from enum import IntEnum
import copy import copy
@@ -6,9 +7,6 @@ DEFAULT_FONT_SIZE = 0.05
class Pos(IntEnum): class Pos(IntEnum):
"""
TODO
"""
NW = 0 NW = 0
N = 1 N = 1
NE = 2 NE = 2
@@ -21,11 +19,7 @@ class Pos(IntEnum):
class Meme: class Meme:
""" def __init__(self, meme_id: str):
TODO
"""
def __init__(self, meme_id):
self.id = meme_id self.id = meme_id
self.aliases = [] self.aliases = []
self.abstract = None self.abstract = None
@@ -35,18 +29,15 @@ class Meme:
self.texts = None self.texts = None
self.texts_len = 0 self.texts_len = 0
def clone(self): def clone(self) -> 'Meme':
return copy.deepcopy(self) return copy.deepcopy(self)
class Text: class Text:
"""
TODO
"""
base_properties = ["font", "font_size", "fill", "stroke_width", base_properties = ["font", "font_size", "fill", "stroke_width",
"stroke_fill", "align", "position"] "stroke_fill", "align", "position"]
def __init__(self, text=None): def __init__(self, text: Optional[str] = None):
self.text = text self.text = text
self.text_ref = None self.text_ref = None
@@ -66,20 +57,12 @@ class Text:
self.align = None self.align = None
self.position = None self.position = None
def update(self, base): def update(self, base: 'Text'):
"""
TODO
:param (Text) base:
"""
for prop in Text.base_properties: for prop in Text.base_properties:
if getattr(self, prop) is None: if getattr(self, prop) is None:
setattr(self, prop, getattr(base, prop)) setattr(self, prop, getattr(base, prop))
def init(self): def init(self):
"""
TODO
"""
if self.x_range is None: if self.x_range is None:
self.x_range = (0, 1) self.x_range = (0, 1)
if self.y_range is None: if self.y_range is None:
+103 -79
View File
@@ -1,53 +1,28 @@
import re import re
import sys
import os.path as path import os.path as path
from typing import List, Optional, Union
from Levenshtein import distance from Levenshtein import distance
def relative_path(file, *args): def relative_path(file: str, *args: str) -> str:
"""
TODO
:param (str) file:
:param (str) args:
:rtype str
:return:
"""
return path.realpath(path.join(path.dirname(path.realpath(file)), *args)) 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): def read_key_safe(d: dict, k: str, default=None, *,
""" types: Optional[List[type]] = None,
TODO is_list: bool = False,
is_list_size: Optional[int] = None):
: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:
"""
try: try:
return read_key(d, k, default, types=types, is_list=is_list, is_list_size=is_list_size) return read_key(d, k, default, types=types, is_list=is_list, is_list_size=is_list_size)
except KeyError: except KeyError:
return default return default
def read_key(d, k, default=None, *, types=None, is_list=False, is_list_size=None): def read_key(d: dict, k: str, default=None, *,
""" types: Optional[List[type]] = None,
TODO is_list: bool = False,
is_list_size: Optional[int] = None):
: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:
"""
if k in d: if k in d:
v = d[k] v = d[k]
if types is not None: 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) raise KeyError(k)
def check_type(obj, types, is_list=False, is_list_size=None): def check_type(obj, types: List[type], is_list: bool = False, is_list_size: Optional[int] = 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:
"""
if is_list: if is_list:
if not is_list_of(obj, types, is_list_size): 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__}") raise TypeError(f"not a list of {is_list_size} {types[0].__name__}")
else: else:
raise TypeError(f"not a list of {types[0].__name__}") 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__}") raise TypeError(f"not a {types[0].__name__}")
def is_list_of(obj, types, length=None): def is_list_of(obj, types: List[type], length: Optional[int] = None) -> bool:
"""
TODO
:param obj:
:param (list of type) types:
:param (int) length:
:rtype: bool
:return:
"""
if not (isinstance(obj, list)): if not (isinstance(obj, list)):
return False return False
for item in obj: for item in obj:
@@ -112,28 +68,96 @@ def is_list_of(obj, types, length=None):
args_regex = re.compile('"([^"]*)"|\'([^\']*)\'|([^ ]+)') args_regex = re.compile('"([^"]*)"|\'([^\']*)\'|([^ ]+)')
def parse_arguments(s): def parse_arguments(src: str) -> List[str]:
""" def get_found_match(m: list) -> str:
TODO f = [g for g in m if len(g) > 0]
if len(f) > 0:
return f[0]
return ""
:param (str) s: return [get_found_match(m) for m in args_regex.findall(src)]
:rtype: list of str
:return:
"""
return [[g for g in m if len(g) > 0][0] for m in args_regex.findall(s)]
def find_nearest(word, wlist, threshold=5): def find_nearest(word: str, wlist: List[str], threshold: int = 5) -> Optional[str]:
""" distances = [
TODO (distance(word, w), # distance
abs(len(w) - len(word)), # length diff
:param (str) word: w)
:param (list of str) wlist: for w in wlist]
:param (int) threshold: distances.sort(key=lambda v: v[1]) # sort by length diff to get the closest (in length) first
:rtype: str | None found = min(distances, key=lambda v: v[0] - v[1]) # get the closest in lev. distance
:return: if found[0] - found[1] > threshold: # distance is too much
"""
found = min([(distance(word, w) - abs(len(w) - len(word)), w) for w in wlist], key=lambda v: v[0])
if found[0] > threshold:
return None 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 datetime
import logging import logging
from os import path 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 meme_db
from meme_otron import utils from meme_otron import utils
logging.basicConfig(format="%(message)s", level=logging.WARNING) 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) db_file = utils.relative_path(__file__, "..", meme_db.DATA_FILE)
templates_dir = utils.relative_path(__file__, "..", "templates") templates_dir = utils.relative_path(__file__, "..", "templates")
@@ -30,7 +30,7 @@ while True:
count = 0 count = 0
for meme_id in meme_db.LIST: for meme_id in meme_db.LIST:
meme = meme_db.get_meme(meme_id) 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: if img is not None:
img.save(path.join(dst_dir, meme.template)) img.save(path.join(dst_dir, meme.template))
count += 1 count += 1