moved memegen to https://github.com/ewpratten/memegen
This commit is contained in:
parent
8d872a499c
commit
23606d667b
@ -1,5 +0,0 @@
|
||||
# Mini Apps
|
||||
|
||||
This directory contains things that are too complex to be considered a "script", yet too personalized to be useful to anyone else.
|
||||
|
||||
I may occasionally promote things from here to actual public repos someday.
|
@ -1,228 +0,0 @@
|
||||
#! /usr/bin/env python
|
||||
import argparse
|
||||
import sys
|
||||
import logging
|
||||
import json
|
||||
import subprocess
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from typing import List, Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
CONFIG_DIR = Path(__file__).parent.parent / "data"
|
||||
DEFAULT_OUTPUT_DIR = Path("~/Pictures/memes").expanduser()
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemeTemplate:
|
||||
image_path: Path
|
||||
config: Dict[str, Any]
|
||||
|
||||
|
||||
class HorizontalAlignment(Enum):
|
||||
LEFT = "left"
|
||||
CENTER = "center"
|
||||
RIGHT = "right"
|
||||
|
||||
|
||||
class VerticalAlignment(Enum):
|
||||
TOP = "top"
|
||||
CENTER = "center"
|
||||
BOTTOM = "bottom"
|
||||
|
||||
|
||||
def discover_templates() -> List[str]:
|
||||
# Find all directories in the templates directory
|
||||
return [p.name for p in (CONFIG_DIR / "templates").glob("*") if p.is_dir()]
|
||||
|
||||
|
||||
def load_template(name: str) -> MemeTemplate:
|
||||
logger.info(f"Loading template: {name}")
|
||||
|
||||
# Find the template directory
|
||||
template_dir = CONFIG_DIR / "templates" / name
|
||||
if not template_dir.exists():
|
||||
logger.error(f"Template {name} does not exist")
|
||||
sys.exit(1)
|
||||
|
||||
return MemeTemplate(
|
||||
image_path=template_dir / "template.png",
|
||||
config=json.loads((template_dir / "config.json").read_text()),
|
||||
)
|
||||
|
||||
|
||||
def calc_width_from_image(width_str: str, image_width: int) -> int:
|
||||
if width_str.endswith("%"):
|
||||
return int(image_width * int(width_str[:-1]) / 100)
|
||||
else:
|
||||
return int(width_str)
|
||||
|
||||
|
||||
def render_text_on_image(image: Image, text: str, zone: str, config: Dict[str, Any]):
|
||||
# NOTE: This must handle text with newlines
|
||||
# Get the zone config
|
||||
zone_config = config["zones"][zone]
|
||||
horizontal_alignment = HorizontalAlignment(zone_config["horizontal_align"])
|
||||
vertical_alignment = VerticalAlignment(zone_config["vertical_align"])
|
||||
text_width = calc_width_from_image(zone_config["width"], image.width)
|
||||
max_line_height = zone_config["max_line_height"]
|
||||
font_path = CONFIG_DIR / "fonts" / config["font"]
|
||||
|
||||
# Create the font
|
||||
font = None
|
||||
font_size = 1
|
||||
while True:
|
||||
font = ImageFont.truetype(str(font_path), font_size)
|
||||
|
||||
# Split the text into lines
|
||||
lines = text.splitlines()
|
||||
bounding_boxes = []
|
||||
for line in lines:
|
||||
bounding_boxes.append(font.getbbox(line))
|
||||
|
||||
# Calculate the height of the text
|
||||
line_height = max([bbox[3] for bbox in bounding_boxes])
|
||||
total_height = sum(
|
||||
[bbox[3] + zone_config["line_spacing"] for bbox in bounding_boxes]
|
||||
)
|
||||
max_width = max([bbox[2] for bbox in bounding_boxes])
|
||||
|
||||
# If we have a max line height, ensure we don't exceed it
|
||||
if max_line_height and line_height > max_line_height:
|
||||
font_size -= 1
|
||||
break
|
||||
|
||||
# Don't exceed the width
|
||||
if max_width > text_width:
|
||||
font_size -= 1
|
||||
break
|
||||
|
||||
# Increment the font size
|
||||
font_size += 1
|
||||
|
||||
# Determine the starting Y position
|
||||
y = zone_config["vertical_offset"]
|
||||
if vertical_alignment == VerticalAlignment.CENTER:
|
||||
y += (image.height - total_height) / 2
|
||||
elif vertical_alignment == VerticalAlignment.BOTTOM:
|
||||
y += image.height - total_height
|
||||
|
||||
# Render each line onto the image
|
||||
draw = ImageDraw.Draw(image)
|
||||
for line in text.splitlines():
|
||||
# Calculate the x position
|
||||
if horizontal_alignment == HorizontalAlignment.LEFT:
|
||||
x = zone_config["horizontal_offset"]
|
||||
elif horizontal_alignment == HorizontalAlignment.CENTER:
|
||||
x = ((image.width - font.getbbox(line)[2]) / 2) + zone_config[
|
||||
"horizontal_offset"
|
||||
]
|
||||
elif horizontal_alignment == HorizontalAlignment.RIGHT:
|
||||
x = (image.width - font.getbbox(line)[2]) + zone_config["horizontal_offset"]
|
||||
else:
|
||||
raise ValueError(f"Invalid horizontal alignment: {horizontal_alignment}")
|
||||
|
||||
# Render the text
|
||||
draw.text(
|
||||
(x, y),
|
||||
line,
|
||||
fill=tuple(config["fill_color"]),
|
||||
stroke_fill=tuple(config["stroke_color"]),
|
||||
stroke_width=config["stroke_width"],
|
||||
font=font,
|
||||
)
|
||||
|
||||
# Increment the y position
|
||||
y += line_height + zone_config["line_spacing"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# Handle program arguments
|
||||
ap = argparse.ArgumentParser(prog="memegen", description="Generates memes")
|
||||
ap.add_argument(
|
||||
"template", help="The template to use", choices=discover_templates()
|
||||
)
|
||||
ap.add_argument("--top-text", help="Top text (if applicable)")
|
||||
ap.add_argument("--bottom-text", help="Bottom text (if applicable)")
|
||||
ap.add_argument(
|
||||
"--keep-case", help="Keep the case of the text", action="store_true"
|
||||
)
|
||||
ap.add_argument("--output", "-o", help="Output file path")
|
||||
ap.add_argument(
|
||||
"--no-show", help="Don't show the image after creation", action="store_true"
|
||||
)
|
||||
ap.add_argument(
|
||||
"-v", "--verbose", help="Enable verbose logging", action="store_true"
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(levelname)s: %(message)s",
|
||||
)
|
||||
|
||||
# Load the template
|
||||
template = load_template(args.template)
|
||||
template_supports_top_text = "top" in template.config["zones"]
|
||||
template_supports_bottom_text = "bottom" in template.config["zones"]
|
||||
|
||||
# Ensure we have text
|
||||
if args.top_text and not template_supports_top_text:
|
||||
logger.error(f"Template {args.template} does not support top text")
|
||||
sys.exit(1)
|
||||
if args.bottom_text and not template_supports_bottom_text:
|
||||
logger.error(f"Template {args.template} does not support bottom text")
|
||||
sys.exit(1)
|
||||
if not args.top_text and not args.bottom_text:
|
||||
logger.error("No text provided")
|
||||
if not all([template_supports_top_text, template_supports_bottom_text]):
|
||||
required_text = "top" if template_supports_top_text else "bottom"
|
||||
logger.error(
|
||||
f"Template {args.template} requires the --{required_text}-text argument"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Transform the text
|
||||
# fmt:off
|
||||
top_text = args.top_text.upper() if args.top_text and (not args.keep_case) else args.top_text
|
||||
bottom_text = args.bottom_text.upper() if args.bottom_text and (not args.keep_case) else args.bottom_text
|
||||
top_text = top_text.replace("\\n", "\n").replace("\\N", "\n") if top_text else None
|
||||
bottom_text = bottom_text.replace("\\n", "\n").replace("\\N", "\n") if bottom_text else None
|
||||
# fmt: on
|
||||
|
||||
# Load the image
|
||||
image = Image.open(template.image_path)
|
||||
|
||||
# Render the text
|
||||
if top_text:
|
||||
render_text_on_image(image, top_text, "top", template.config)
|
||||
if bottom_text:
|
||||
render_text_on_image(image, bottom_text, "bottom", template.config)
|
||||
|
||||
# Build the output path
|
||||
output_path = (
|
||||
Path(args.output)
|
||||
if args.output
|
||||
else (
|
||||
DEFAULT_OUTPUT_DIR
|
||||
/ f"meme-{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.{args.template}.png"
|
||||
)
|
||||
)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Save the image
|
||||
image.save(output_path)
|
||||
|
||||
# Show the image
|
||||
if not args.no_show:
|
||||
subprocess.run(["xdg-open", str(output_path)])
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
Binary file not shown.
@ -1,17 +0,0 @@
|
||||
{
|
||||
"font": "impact.ttf",
|
||||
"fill_color": [255, 255, 255],
|
||||
"stroke_color": [0, 0, 0],
|
||||
"stroke_width": 2,
|
||||
"zones": {
|
||||
"bottom": {
|
||||
"horizontal_align": "center",
|
||||
"horizontal_offset": 0,
|
||||
"vertical_align": "bottom",
|
||||
"vertical_offset": -50,
|
||||
"width": "80%",
|
||||
"max_line_height": 50,
|
||||
"line_spacing": 5
|
||||
}
|
||||
}
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 414 KiB |
@ -1,34 +0,0 @@
|
||||
{
|
||||
"font": "impact.ttf",
|
||||
"fill_color": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"stroke_color": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"stroke_width": 0,
|
||||
"zones": {
|
||||
"top": {
|
||||
"horizontal_align": "right",
|
||||
"horizontal_offset": -5,
|
||||
"vertical_align": "top",
|
||||
"vertical_offset": 75,
|
||||
"width": "45%",
|
||||
"max_line_height": 50,
|
||||
"line_spacing": 5
|
||||
},
|
||||
"bottom": {
|
||||
"horizontal_align": "right",
|
||||
"horizontal_offset": -5,
|
||||
"vertical_align": "bottom",
|
||||
"vertical_offset": -75,
|
||||
"width": "45%",
|
||||
"max_line_height": 50,
|
||||
"line_spacing": 5
|
||||
}
|
||||
}
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 107 KiB |
@ -1,17 +0,0 @@
|
||||
{
|
||||
"font": "impact.ttf",
|
||||
"fill_color": [255, 255, 255],
|
||||
"stroke_color": [0, 0, 0],
|
||||
"stroke_width": 2,
|
||||
"zones": {
|
||||
"top": {
|
||||
"horizontal_align": "center",
|
||||
"horizontal_offset": 0,
|
||||
"vertical_align": "top",
|
||||
"vertical_offset": 5,
|
||||
"width": "80%",
|
||||
"max_line_height": 80,
|
||||
"line_spacing": 5
|
||||
}
|
||||
}
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 166 KiB |
Loading…
x
Reference in New Issue
Block a user