diff --git a/README.md b/README.md index 050cf3f..c459a12 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ This repository stores most of my common config files. It is designed to be deployable to pretty much any system. Assuming ideal conditions, any machine is one `sh ./install-` away from behaving like my personal workstation. +*I know its called ew**config**, but at this point, its more of a monorepo of scripts* + ## Setup The scripts in this repository have the following dependencies: diff --git a/configs/memegen/fonts/impact.ttf b/configs/memegen/fonts/impact.ttf new file mode 100644 index 0000000..114e6c1 Binary files /dev/null and b/configs/memegen/fonts/impact.ttf differ diff --git a/configs/memegen/templates/bernie-asking/config.json b/configs/memegen/templates/bernie-asking/config.json new file mode 100644 index 0000000..5a2f0f4 --- /dev/null +++ b/configs/memegen/templates/bernie-asking/config.json @@ -0,0 +1,17 @@ +{ + "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 + } + } +} \ No newline at end of file diff --git a/configs/memegen/templates/bernie-asking/template.png b/configs/memegen/templates/bernie-asking/template.png new file mode 100644 index 0000000..0d19bb4 Binary files /dev/null and b/configs/memegen/templates/bernie-asking/template.png differ diff --git a/install-linux.sh b/install-linux.sh index 5c258c9..4e65d1e 100644 --- a/install-linux.sh +++ b/install-linux.sh @@ -119,6 +119,9 @@ if [ -d ~/.var/app/org.prismlauncher.PrismLauncher ]; then flatpak override --user --filesystem=~/.config/minecraft org.prismlauncher.PrismLauncher fi +# Memegen +ln -nsf $EWCONFIG_ROOT/configs/memegen ~/.config/memegen + # -- Optional Configs -- set +x diff --git a/scripts/memegen b/scripts/memegen new file mode 100755 index 0000000..678bd81 --- /dev/null +++ b/scripts/memegen @@ -0,0 +1,226 @@ +#! /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("~/.config/memegen").expanduser() +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("--show", help="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"{args.template}-{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.png" + ) + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Save the image + image.save(output_path) + + # Show the image + if args.show: + subprocess.run(["xdg-open", str(output_path)]) + + return 0 + + +if __name__ == "__main__": + sys.exit(main())