diff --git a/.gitignore b/.gitignore index 36b13f1..002f56e 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,6 @@ cython_debug/ # PyPI configuration file .pypirc + +# VS Code +.vscode/ \ No newline at end of file diff --git a/Exploring_Near_Earth_Objects/database.py b/Exploring_Near_Earth_Objects/database.py index 910932c..52e0ae6 100644 --- a/Exploring_Near_Earth_Objects/database.py +++ b/Exploring_Near_Earth_Objects/database.py @@ -21,6 +21,7 @@ class NEODatabase: help fetch NEOs by primary designation or by name and to help speed up querying for close approaches that match criteria. """ + def __init__(self, neos, approaches): """Create a new `NEODatabase`. @@ -42,9 +43,16 @@ class NEODatabase: self._neos = neos self._approaches = approaches - # TODO: What additional auxiliary data structures will be useful? + self._neo_by_designation = {neo.designation: neo for neo in self._neos} + self._neo_by_name = {neo.name: neo for neo in self._neos} - # TODO: Link together the NEOs and their close approaches. + for approach in self._approaches: + # Link approach.neo to the corresponding NearEarthObject, namely + # the one whose designation matches approach._designation. + approach.neo = self._neo_by_designation[approach._designation] + # Add this approach to the corresponding NearEarthObject's + # .approaches collection. + self._neo_by_designation[approach._designation].approaches.append(approach) def get_neo_by_designation(self, designation): """Find and return an NEO by its primary designation. @@ -59,8 +67,7 @@ class NEODatabase: :param designation: The primary designation of the NEO to search for. :return: The `NearEarthObject` with the desired primary designation, or `None`. """ - # TODO: Fetch an NEO by its primary designation. - return None + return self._neo_by_designation.get(designation, None) def get_neo_by_name(self, name): """Find and return an NEO by its name. @@ -76,8 +83,7 @@ class NEODatabase: :param name: The name, as a string, of the NEO to search for. :return: The `NearEarthObject` with the desired name, or `None`. """ - # TODO: Fetch an NEO by its name. - return None + return self._neo_by_name.get(name, None) def query(self, filters=()): """Query close approaches to generate those that match a collection of filters. @@ -93,6 +99,7 @@ class NEODatabase: :param filters: A collection of filters capturing user-specified criteria. :return: A stream of matching `CloseApproach` objects. """ - # TODO: Generate `CloseApproach` objects that match all of the filters. for approach in self._approaches: - yield approach + cases = [filter_i(approach) for filter_i in filters] + if all(cases): + yield approach diff --git a/Exploring_Near_Earth_Objects/extract.py b/Exploring_Near_Earth_Objects/extract.py index 59f7192..8f11f7d 100644 --- a/Exploring_Near_Earth_Objects/extract.py +++ b/Exploring_Near_Earth_Objects/extract.py @@ -12,9 +12,9 @@ line, and uses the resulting collections to build an `NEODatabase`. You'll edit this file in Task 2. """ + import csv import json - from models import NearEarthObject, CloseApproach @@ -24,8 +24,16 @@ def load_neos(neo_csv_path): :param neo_csv_path: A path to a CSV file containing data about near-Earth objects. :return: A collection of `NearEarthObject`s. """ - # TODO: Load NEO data from the given CSV file. - return () + neos = [] + with open(neo_csv_path, "r") as csv_file: + reader = csv.reader(csv_file) + next(reader) + for row in reader: + neo = NearEarthObject( + designation=row[3], name=row[4], diameter=row[15], hazardous=row[7] + ) + neos.append(neo) + return neos def load_approaches(cad_json_path): @@ -34,5 +42,17 @@ def load_approaches(cad_json_path): :param cad_json_path: A path to a JSON file containing data about close approaches. :return: A collection of `CloseApproach`es. """ - # TODO: Load close approach data from the given JSON file. - return () + approaches = [] + with open(cad_json_path, "r") as json_file: + cad = json.load(json_file) + for item in cad["data"]: + # 0: pdes, 3: cd, 4: dist, 7: v_rel + approach = CloseApproach( + designation=item[0], + time=item[3], + distance=float(item[4]), + velocity=float(item[7]), + ) + + approaches.append(approach) + return approaches diff --git a/Exploring_Near_Earth_Objects/filters.py b/Exploring_Near_Earth_Objects/filters.py index 61e09b3..4554412 100644 --- a/Exploring_Near_Earth_Objects/filters.py +++ b/Exploring_Near_Earth_Objects/filters.py @@ -16,7 +16,9 @@ iterator. You'll edit this file in Tasks 3a and 3c. """ + import operator +import itertools class UnsupportedCriterionError(NotImplementedError): @@ -38,6 +40,7 @@ class AttributeFilter: Concrete subclasses can override the `get` classmethod to provide custom behavior to fetch a desired attribute from the given `CloseApproach`. """ + def __init__(self, op, value): """Construct a new `AttributeFilter` from an binary predicate and a reference value. @@ -72,12 +75,57 @@ class AttributeFilter: return f"{self.__class__.__name__}(op=operator.{self.op.__name__}, value={self.value})" +class DistanceFilter(AttributeFilter): + """Subclass of AttributeFilter to filter by distance attribute.""" + + @classmethod + def get(cls, approach): + return approach.distance + + +class VelocityFilter(AttributeFilter): + """Subclass of AttributeFilter to filter by velocity attribute.""" + + @classmethod + def get(cls, approach): + return approach.velocity + + +class DateFilter(AttributeFilter): + """Subclass of AttributeFilter to filter by date attribute.""" + + @classmethod + def get(cls, approach): + return approach.time.date() + + +class DiameterFilter(AttributeFilter): + """Subclass of AttributeFilter to filter by diameter attribute.""" + + @classmethod + def get(cls, approach): + return approach.neo.diameter + + +class HazardousFilter(AttributeFilter): + """Subclass of AttributeFilter to filter by hazardous attribute.""" + + @classmethod + def get(cls, approach): + return approach.neo.hazardous + + def create_filters( - date=None, start_date=None, end_date=None, - distance_min=None, distance_max=None, - velocity_min=None, velocity_max=None, - diameter_min=None, diameter_max=None, - hazardous=None + date=None, + start_date=None, + end_date=None, + distance_min=None, + distance_max=None, + velocity_min=None, + velocity_max=None, + diameter_min=None, + diameter_max=None, + hazardous=None, ): """Create a collection of filters from user-specified criteria. @@ -108,8 +156,30 @@ def create_filters( :param hazardous: Whether the NEO of a matching `CloseApproach` is potentially hazardous. :return: A collection of filters for use with `query`. """ - # TODO: Decide how you will represent your filters. - return () + filters = [] + + if date: + filters.append(DateFilter(operator.eq, date)) + if start_date: + filters.append(DateFilter(operator.ge, start_date)) + if end_date: + filters.append(DateFilter(operator.le, end_date)) + if distance_min: + filters.append(DistanceFilter(operator.ge, distance_min)) + if distance_max: + filters.append(DistanceFilter(operator.le, distance_max)) + if velocity_min: + filters.append(VelocityFilter(operator.ge, velocity_min)) + if velocity_max: + filters.append(VelocityFilter(operator.le, velocity_max)) + if diameter_min: + filters.append(DiameterFilter(operator.ge, diameter_min)) + if diameter_max: + filters.append(DiameterFilter(operator.le, diameter_max)) + if hazardous is not None: + filters.append(HazardousFilter(operator.eq, hazardous)) + + return filters def limit(iterator, n=None): @@ -121,5 +191,4 @@ def limit(iterator, n=None): :param n: The maximum number of values to produce. :yield: The first (at most) `n` values from the iterator. """ - # TODO: Produce at most `n` values from the given iterator. - return iterator + return itertools.islice(iterator, n) if n else iterator diff --git a/Exploring_Near_Earth_Objects/models.py b/Exploring_Near_Earth_Objects/models.py index 302b029..9a566bf 100644 --- a/Exploring_Near_Earth_Objects/models.py +++ b/Exploring_Near_Earth_Objects/models.py @@ -17,7 +17,10 @@ quirks of the data set, such as missing names and unknown diameters. You'll edit this file in Task 1. """ + from helpers import cd_to_datetime, datetime_to_str +import math +from datetime import datetime class NearEarthObject: @@ -32,22 +35,20 @@ class NearEarthObject: initialized to an empty collection, but eventually populated in the `NEODatabase` constructor. """ - # TODO: How can you, and should you, change the arguments to this constructor? - # If you make changes, be sure to update the comments in this file. + def __init__(self, **info): """Create a new `NearEarthObject`. :param info: A dictionary of excess keyword arguments supplied to the constructor. """ - # TODO: Assign information from the arguments passed to the constructor - # onto attributes named `designation`, `name`, `diameter`, and `hazardous`. - # You should coerce these values to their appropriate data type and - # handle any edge cases, such as a empty name being represented by `None` - # and a missing diameter being represented by `float('nan')`. - self.designation = '' - self.name = None - self.diameter = float('nan') - self.hazardous = False + self.designation = info.get("designation") + self.name = None if info.get("name") == "" else info.get("name") + self.diameter = ( + float("nan") + if info.get("diameter") in (None, "") + else float(info.get("diameter")) + ) + self.hazardous = info.get("hazardous", "N") == "Y" # Create an empty initial collection of linked approaches. self.approaches = [] @@ -55,20 +56,36 @@ class NearEarthObject: @property def fullname(self): """Return a representation of the full name of this NEO.""" - # TODO: Use self.designation and self.name to build a fullname for this object. - return '' + if self.name: + return f"{self.designation} {self.name}" + return f"{self.designation}" def __str__(self): """Return `str(self)`.""" - # TODO: Use this object's attributes to return a human-readable string representation. - # The project instructions include one possibility. Peek at the __repr__ - # method for examples of advanced string formatting. - return f"A NearEarthObject ..." + hazardous_str = ( + "is potentially hazardous" + if self.hazardous + else "is not potentially hazardous" + ) + if not math.isnan(self.diameter): + return f"NEO {self.fullname} has a diameter of {self.diameter:.3f} and {hazardous_str}." + return f"NEO {self.fullname} has an unknown diameter and {hazardous_str}." def __repr__(self): """Return `repr(self)`, a computer-readable string representation of this object.""" - return f"NearEarthObject(designation={self.designation!r}, name={self.name!r}, " \ - f"diameter={self.diameter:.3f}, hazardous={self.hazardous!r})" + return ( + f"NearEarthObject(designation={self.designation!r}, name={self.name!r}, " + f"diameter={self.diameter:.3f}, hazardous={self.hazardous!r})" + ) + + def serialize(self): + """Serialize this `NearEarthObject` into a dictionary.""" + return { + "designation": self.designation, + "name": self.name, + "diameter_km": self.diameter, + "potentially_hazardous": self.hazardous, + } class CloseApproach: @@ -84,21 +101,20 @@ class CloseApproach: private attribute, but the referenced NEO is eventually replaced in the `NEODatabase` constructor. """ - # TODO: How can you, and should you, change the arguments to this constructor? - # If you make changes, be sure to update the comments in this file. + def __init__(self, **info): """Create a new `CloseApproach`. :param info: A dictionary of excess keyword arguments supplied to the constructor. """ - # TODO: Assign information from the arguments passed to the constructor - # onto attributes named `_designation`, `time`, `distance`, and `velocity`. - # You should coerce these values to their appropriate data type and handle any edge cases. - # The `cd_to_datetime` function will be useful. - self._designation = '' - self.time = None # TODO: Use the cd_to_datetime function for this attribute. - self.distance = 0.0 - self.velocity = 0.0 + self._designation = info.get("designation") + + self.time = info.get("time") + if self.time: + self.time = cd_to_datetime(info.get("time")) + + self.distance = info.get("distance", float("nan")) + self.velocity = info.get("velocity", float("nan")) # Create an attribute for the referenced NEO, originally None. self.neo = None @@ -116,19 +132,23 @@ class CloseApproach: formatted string that can be used in human-readable representations and in serialization to CSV and JSON files. """ - # TODO: Use this object's `.time` attribute and the `datetime_to_str` function to - # build a formatted representation of the approach time. - # TODO: Use self.designation and self.name to build a fullname for this object. - return '' + return f"{datetime_to_str(self.time)}" def __str__(self): """Return `str(self)`.""" - # TODO: Use this object's attributes to return a human-readable string representation. - # The project instructions include one possibility. Peek at the __repr__ - # method for examples of advanced string formatting. - return f"A CloseApproach ..." + return f"On {self.time_str}, {self.neo.fullname} approaches Earth at a distance of {self.distance:.2f} au and a velocity of {self.velocity:.2f} km/s." def __repr__(self): """Return `repr(self)`, a computer-readable string representation of this object.""" - return f"CloseApproach(time={self.time_str!r}, distance={self.distance:.2f}, " \ - f"velocity={self.velocity:.2f}, neo={self.neo!r})" + return ( + f"CloseApproach(time={self.time_str!r}, distance={self.distance:.2f}, " + f"velocity={self.velocity:.2f}, neo={self.neo!r})" + ) + + def serialize(self): + """Serialize this `CloseApproach` into a dictionary.""" + return { + "datetime_utc": self.time_str, + "distance_au": self.distance, + "velocity_km_s": self.velocity, + } diff --git a/Exploring_Near_Earth_Objects/write.py b/Exploring_Near_Earth_Objects/write.py index 3b180ee..b170340 100644 --- a/Exploring_Near_Earth_Objects/write.py +++ b/Exploring_Near_Earth_Objects/write.py @@ -10,6 +10,7 @@ extension determines which of these functions is used. You'll edit this file in Part 4. """ + import csv import json @@ -25,10 +26,24 @@ def write_to_csv(results, filename): :param filename: A Path-like object pointing to where the data should be saved. """ fieldnames = ( - 'datetime_utc', 'distance_au', 'velocity_km_s', - 'designation', 'name', 'diameter_km', 'potentially_hazardous' + "datetime_utc", + "distance_au", + "velocity_km_s", + "designation", + "name", + "diameter_km", + "potentially_hazardous", ) - # TODO: Write the results to a CSV file, following the specification in the instructions. + with open(filename, "w") as csv_file: + writer = csv.DictWriter(csv_file, fieldnames) + writer.writeheader() + for result in results: + row = {**result.serialize(), **result.neo.serialize()} + row["name"] = "" if row["name"] is None else row["name"] + row["diameter_km"] = ( + "" if row["diameter_km"] is float("nan") else row["diameter_km"] + ) + writer.writerow(row) def write_to_json(results, filename): @@ -42,4 +57,25 @@ def write_to_json(results, filename): :param results: An iterable of `CloseApproach` objects. :param filename: A Path-like object pointing to where the data should be saved. """ - # TODO: Write the results to a JSON file, following the specification in the instructions. + data = [] + for result in results: + item = {**result.serialize(), **result.neo.serialize()} + item["name"] = "" if item["name"] is None else item["name"] + item["diameter_km"] = ( + "" if item["diameter_km"] is float("nan") else item["diameter_km"] + ) + data.append( + { + "datetime_utc": item["datetime_utc"], + "distance_au": item["distance_au"], + "velocity_km_s": item["velocity_km_s"], + "neo": { + "designation": item["designation"], + "name": item["name"], + "diameter_km": item["diameter_km"], + "potentially_hazardous": item["potentially_hazardous"], + }, + } + ) + with open(filename, "w") as json_file: + json.dump(data, json_file, indent=2) diff --git a/Meme_Generator/MemeEngine/MemeEngine.py b/Meme_Generator/MemeEngine/MemeEngine.py new file mode 100644 index 0000000..b1ad530 --- /dev/null +++ b/Meme_Generator/MemeEngine/MemeEngine.py @@ -0,0 +1,35 @@ +"""Meme engine module for handling the generation of meme images.""" + +import random +from PIL import Image, ImageFont, ImageDraw + + +class MemeEngine: + """Class to generate memes from images and text.""" + + def __init__(self, output_path): + """Initialize meme engine with path to save generated memes.""" + self.output_path = output_path + + def make_meme(self, img_path, text, author, width=500) -> str: + """Generate a meme image with given text and author.""" + output_file = f"{self.output_path}/{random.randint(0,10000)}.jpg" + message = f"{text}\n- {author}" + + try: + with Image.open(img_path) as img: + # Resize image while maintaining aspect ratio + width = 500 if img.size[0] > 500 else img.size[0] + ratio = width / (img.size[0] * 1.0) + height = ratio * img.size[1] + img = img.resize((int(width), int(height)), Image.NEAREST) + draw = ImageDraw.Draw(img) + font = ImageFont.truetype( + "./_data/font/calibri_regular.ttf", int(height / 20) + ) + draw.text((20, 20), message, font=font, fill="white") + img.save(output_file) + except Exception as err: + print(f"Error: {err}") + + return output_file diff --git a/Meme_Generator/MemeEngine/__init__.py b/Meme_Generator/MemeEngine/__init__.py new file mode 100644 index 0000000..1846263 --- /dev/null +++ b/Meme_Generator/MemeEngine/__init__.py @@ -0,0 +1 @@ +from .MemeEngine import MemeEngine diff --git a/Meme_Generator/QuoteEngine/CSVIngestor.py b/Meme_Generator/QuoteEngine/CSVIngestor.py new file mode 100644 index 0000000..6c36e8d --- /dev/null +++ b/Meme_Generator/QuoteEngine/CSVIngestor.py @@ -0,0 +1,24 @@ +"""Module for ingesting CSV files containing quotes.""" + +from typing import List +from .IngestorInterface import IngestorInterface +from .QuoteModel import QuoteModel + +import pandas as pd + + +class CSVIngestor(IngestorInterface): + """Subclass for ingesting CSV files.""" + + allowed_extensions = ["csv"] + + @classmethod + def parse(cls, path: str) -> List[QuoteModel]: + """Parse the CSV file to extract quotes.""" + if not cls.can_ingest(path): + raise Exception("Invalid ingest path") + quotes = [] + df = pd.read_csv(path, header=0, sep=",", names=["body", "author"]) + for _, row in df.iterrows(): + quotes.append(QuoteModel(row["body"], row["author"])) + return quotes diff --git a/Meme_Generator/QuoteEngine/DocxIngestor.py b/Meme_Generator/QuoteEngine/DocxIngestor.py new file mode 100644 index 0000000..af33efb --- /dev/null +++ b/Meme_Generator/QuoteEngine/DocxIngestor.py @@ -0,0 +1,25 @@ +"""Module for ingesting Docx files containing quotes.""" + +import docx +from typing import List +from .IngestorInterface import IngestorInterface +from .QuoteModel import QuoteModel + + +class DocxIngestor(IngestorInterface): + """Subclass for ingesting Docx files.""" + + allowed_extensions = ["docx"] + + @classmethod + def parse(cls, path: str) -> List[QuoteModel]: + """Parse the Docx file to extract quotes.""" + if not cls.can_ingest(path): + raise Exception("Invalid ingest path") + quotes = [] + doc = docx.Document(path) + for para in doc.paragraphs: + if para.text != "": + parts = para.text.split(" - ") + quotes.append(QuoteModel(parts[0], parts[1])) + return quotes diff --git a/Meme_Generator/QuoteEngine/Ingestor.py b/Meme_Generator/QuoteEngine/Ingestor.py new file mode 100644 index 0000000..946e1be --- /dev/null +++ b/Meme_Generator/QuoteEngine/Ingestor.py @@ -0,0 +1,22 @@ +"""Ingestor module to select appropriate ingestor based on file type.""" + +from typing import List +from .IngestorInterface import IngestorInterface +from .QuoteModel import QuoteModel +from .CSVIngestor import CSVIngestor +from .TextIngestor import TextIngestor +from .DocxIngestor import DocxIngestor +from .PDFIngestor import PDFIngestor + + +class Ingestor(IngestorInterface): + """Subclass to select appropriate ingestor.""" + + ingestors = [CSVIngestor, TextIngestor, DocxIngestor, PDFIngestor] + + @classmethod + def parse(cls, path: str) -> List[QuoteModel]: + """Select the appropriate ingestor to parse the file.""" + for ingestor in cls.ingestors: + if ingestor.can_ingest(path): + return ingestor.parse(path) diff --git a/Meme_Generator/QuoteEngine/IngestorInterface.py b/Meme_Generator/QuoteEngine/IngestorInterface.py new file mode 100644 index 0000000..4367a6b --- /dev/null +++ b/Meme_Generator/QuoteEngine/IngestorInterface.py @@ -0,0 +1,23 @@ +"""Ingestor Interface module for quote ingestion.""" + +from abc import ABC, abstractmethod +from typing import List +from .QuoteModel import QuoteModel + + +class IngestorInterface(ABC): + """Base Ingestor Interface.""" + + allowed_extensions = [] + + @classmethod + def can_ingest(cls, path: str) -> bool: + """Check if the ingestor can ingest the file based on its extension.""" + ext = path.split(".")[-1] + return ext in cls.allowed_extensions + + @classmethod + @abstractmethod + def parse(cls, path: str) -> List[QuoteModel]: + """Abstract method to parse the file and return a list of QuoteModel objects.""" + pass diff --git a/Meme_Generator/QuoteEngine/PDFIngestor.py b/Meme_Generator/QuoteEngine/PDFIngestor.py new file mode 100644 index 0000000..317d703 --- /dev/null +++ b/Meme_Generator/QuoteEngine/PDFIngestor.py @@ -0,0 +1,40 @@ +"""Module for ingesting PDF files containing quotes.""" + +import os +import random +import subprocess +from typing import List +from .IngestorInterface import IngestorInterface +from .QuoteModel import QuoteModel + + +class PDFIngestor(IngestorInterface): + """Subclass for ingesting PDF files.""" + + allowed_extensions = ["pdf"] + + @classmethod + def parse(cls, path: str) -> List[QuoteModel]: + """Parse the PDF file to extract quotes.""" + if not cls.can_ingest(path): + raise Exception("Invalid ingest path") + + quotes = [] + tmp = f"./tmp/{random.randint(0, 10000)}.txt" + try: + # pdftotext + call = subprocess.call(["pdftotext", path, tmp]) + with open(tmp, "r") as file: + lines = file.readlines() + except FileNotFoundError as err: + print(f"Error: {err}") + else: + for line in lines: + line = line.strip() + if line: + parts = line.split(" - ") + quotes.append(QuoteModel(parts[0], parts[1])) + finally: + if os.path.exists(tmp): + os.remove(tmp) + return quotes diff --git a/Meme_Generator/QuoteEngine/QuoteModel.py b/Meme_Generator/QuoteEngine/QuoteModel.py new file mode 100644 index 0000000..409881a --- /dev/null +++ b/Meme_Generator/QuoteEngine/QuoteModel.py @@ -0,0 +1,14 @@ +"""QuoteModel module for representing a quote with its body and author.""" + + +class QuoteModel: + """Quote model class.""" + + def __init__(self, body, author): + """Initialize the QuoteModel object.""" + self.body = body + self.author = author + + def __repr__(self): + """String representation of the QuoteModel object.""" + return f"{self.body} - {self.author}" diff --git a/Meme_Generator/QuoteEngine/TextIngestor.py b/Meme_Generator/QuoteEngine/TextIngestor.py new file mode 100644 index 0000000..e32220f --- /dev/null +++ b/Meme_Generator/QuoteEngine/TextIngestor.py @@ -0,0 +1,26 @@ +"""Module for ingesting text files containing quotes.""" + +from typing import List +from .IngestorInterface import IngestorInterface +from .QuoteModel import QuoteModel + + +class TextIngestor(IngestorInterface): + """Subcalss for ingesting text files.""" + + allowed_extensions = ["txt"] + + @classmethod + def parse(cls, path: str) -> List[QuoteModel]: + """Parse the text file to extract quotes.""" + if not cls.can_ingest(path): + raise Exception("Invalid ingest path") + quotes = [] + with open(path, "r") as file: + for line in file.readlines(): + line = line.strip() + if line: + parts = line.split(" - ", 1) + if len(parts) == 2: + quotes.append(QuoteModel(parts[0], parts[1])) + return quotes diff --git a/Meme_Generator/QuoteEngine/__init__.py b/Meme_Generator/QuoteEngine/__init__.py new file mode 100644 index 0000000..bd01df2 --- /dev/null +++ b/Meme_Generator/QuoteEngine/__init__.py @@ -0,0 +1,7 @@ +from .IngestorInterface import IngestorInterface +from .CSVIngestor import CSVIngestor +from .DocxIngestor import DocxIngestor +from .PDFIngestor import PDFIngestor +from .TextIngestor import TextIngestor +from .Ingestor import Ingestor +from .QuoteModel import QuoteModel diff --git a/Meme_Generator/README.md b/Meme_Generator/README.md new file mode 100644 index 0000000..4875191 --- /dev/null +++ b/Meme_Generator/README.md @@ -0,0 +1,39 @@ +## Meme Generator Project + +### Overview +The goal of this project is to build a "meme generator" – a multimedia application to dynamically generate memes, including an image with an overlaid quote. + + +### Set up environment +```sh +$ sudo apt-get install -y xpdf +$ pip install -r requirements.txt +``` + +### Run program + +Python test \ +Usage: `python3 meme.py --path [image_path] --body [quote_body] --author [quote_author]` +```sh +$ mkdir tmp +$ python3 meme.py +``` + +Flask test +```sh +$ mkdir static +$ python3 app.py +``` +Open link `http://127.0.0.1:5000` in web browser + +### Primary modules + +`QuoteEngine`: Meme engine module for handling the generation of meme images.\ +`MemeEngine`: QuoteModel module for representing a quote with its body and author. + +### Sub modules + +`CSVIngestor`: Module for ingesting CSV files containing quotes. \ +`DocxIngestor`: Module for ingesting Docx files containing quotes. \ +`TextIngestor`: Module for ingesting text files containing quotes. \ +`PDFIngestor`: Module for ingesting PDF files containing quotes. \ No newline at end of file diff --git a/Meme_Generator/_data/DogQuotes/DogQuotesCSV.csv b/Meme_Generator/_data/DogQuotes/DogQuotesCSV.csv new file mode 100644 index 0000000..1fbe43f --- /dev/null +++ b/Meme_Generator/_data/DogQuotes/DogQuotesCSV.csv @@ -0,0 +1,3 @@ +body,author +Chase the mailman,Skittle +"When in doubt, go shoe-shopping",Mr. Paws \ No newline at end of file diff --git a/Meme_Generator/_data/DogQuotes/DogQuotesDOCX.docx b/Meme_Generator/_data/DogQuotes/DogQuotesDOCX.docx new file mode 100644 index 0000000..3f8310e Binary files /dev/null and b/Meme_Generator/_data/DogQuotes/DogQuotesDOCX.docx differ diff --git a/Meme_Generator/_data/DogQuotes/DogQuotesPDF.pdf b/Meme_Generator/_data/DogQuotes/DogQuotesPDF.pdf new file mode 100644 index 0000000..33c8dbd Binary files /dev/null and b/Meme_Generator/_data/DogQuotes/DogQuotesPDF.pdf differ diff --git a/Meme_Generator/_data/DogQuotes/DogQuotesTXT.txt b/Meme_Generator/_data/DogQuotes/DogQuotesTXT.txt new file mode 100644 index 0000000..0d98b65 --- /dev/null +++ b/Meme_Generator/_data/DogQuotes/DogQuotesTXT.txt @@ -0,0 +1,2 @@ +To bork or not to bork - Bork +He who smelt it... - Stinky \ No newline at end of file diff --git a/Meme_Generator/_data/SimpleLines/SimpleLines.csv b/Meme_Generator/_data/SimpleLines/SimpleLines.csv new file mode 100644 index 0000000..208e3e7 --- /dev/null +++ b/Meme_Generator/_data/SimpleLines/SimpleLines.csv @@ -0,0 +1,6 @@ +body,author +Line 1,Author 1 +Line 2,Author 2 +Line 3,Author 3 +Line 4,Author 4 +Line 5,Author 5 \ No newline at end of file diff --git a/Meme_Generator/_data/SimpleLines/SimpleLines.docx b/Meme_Generator/_data/SimpleLines/SimpleLines.docx new file mode 100644 index 0000000..fe0785e Binary files /dev/null and b/Meme_Generator/_data/SimpleLines/SimpleLines.docx differ diff --git a/Meme_Generator/_data/SimpleLines/SimpleLines.pdf b/Meme_Generator/_data/SimpleLines/SimpleLines.pdf new file mode 100644 index 0000000..352ade6 Binary files /dev/null and b/Meme_Generator/_data/SimpleLines/SimpleLines.pdf differ diff --git a/Meme_Generator/_data/SimpleLines/SimpleLines.txt b/Meme_Generator/_data/SimpleLines/SimpleLines.txt new file mode 100644 index 0000000..8e288aa --- /dev/null +++ b/Meme_Generator/_data/SimpleLines/SimpleLines.txt @@ -0,0 +1,7 @@ +"Line 1" - Author 1 +"Line 2" - Author 2 +"Line 3" - Author 3 +"Line 4" - Author 4 +"Line 5" - Author 5 + + \ No newline at end of file diff --git a/Meme_Generator/_data/font/calibri_regular.ttf b/Meme_Generator/_data/font/calibri_regular.ttf new file mode 100644 index 0000000..2fede68 Binary files /dev/null and b/Meme_Generator/_data/font/calibri_regular.ttf differ diff --git a/Meme_Generator/_data/photos/dog/xander_1.jpg b/Meme_Generator/_data/photos/dog/xander_1.jpg new file mode 100644 index 0000000..78a8e58 Binary files /dev/null and b/Meme_Generator/_data/photos/dog/xander_1.jpg differ diff --git a/Meme_Generator/_data/photos/dog/xander_2.jpg b/Meme_Generator/_data/photos/dog/xander_2.jpg new file mode 100644 index 0000000..a7db90c Binary files /dev/null and b/Meme_Generator/_data/photos/dog/xander_2.jpg differ diff --git a/Meme_Generator/_data/photos/dog/xander_3.jpg b/Meme_Generator/_data/photos/dog/xander_3.jpg new file mode 100644 index 0000000..e9865c4 Binary files /dev/null and b/Meme_Generator/_data/photos/dog/xander_3.jpg differ diff --git a/Meme_Generator/_data/photos/dog/xander_4.jpg b/Meme_Generator/_data/photos/dog/xander_4.jpg new file mode 100644 index 0000000..ae05060 Binary files /dev/null and b/Meme_Generator/_data/photos/dog/xander_4.jpg differ diff --git a/Meme_Generator/app.py b/Meme_Generator/app.py new file mode 100644 index 0000000..7e44397 --- /dev/null +++ b/Meme_Generator/app.py @@ -0,0 +1,112 @@ +import random +import os +import requests +from flask import Flask, render_template, abort, request + +from QuoteEngine import Ingestor +from MemeEngine import MemeEngine + +# Create the Flask application object. "__name__" tells Flask where to find templates and static files. +app = Flask(__name__) + +# Create a global MemeEngine instance that will write generated memes into "./static" +# so that the images can be served by the web server. +meme = MemeEngine("./static") + + +def setup(): + """Load all resources""" + + quote_files = [ + "./_data/DogQuotes/DogQuotesTXT.txt", + "./_data/DogQuotes/DogQuotesDOCX.docx", + "./_data/DogQuotes/DogQuotesPDF.pdf", + "./_data/DogQuotes/DogQuotesCSV.csv", + ] + + quotes = [] + for file in quote_files: + if Ingestor.parse(file) is not None: + quotes.append(Ingestor.parse(file)) + + images_path = "./_data/photos/dog/" + + imgs = [] + for root, _, files in os.walk(images_path): + imgs = [os.path.join(root, name) for name in files] + + return quotes, imgs + + +# Call setup() once at import time to preload quotes and images. +# These will be reused on every request. +quotes, imgs = setup() + + +@app.route("/") +def meme_rand(): + """Generate a random meme + + Steps: + - Pick a random image from imgs + - Pick a random "quote list" from quotes + - Pick a random quote from that list + - Generate a meme image with that quote and image + - Render a template to show the meme + """ + img = random.choice(imgs) + quote_list = random.choice(quotes) + quote = random.choice(quote_list) + path = meme.make_meme(img, quote.body, quote.author) + return render_template("meme.html", path=path) + + +@app.route("/create", methods=["GET"]) +def meme_form(): + """User input for meme information + + This route renders a form where the user can input: + - image_url: URL of the source image + - body: quote text + - author: quote author + """ + return render_template("meme_form.html") + + +@app.route("/create", methods=["POST"]) +def meme_post(): + """Create a user defined meme + + This route: + - Reads form data sent via POST from the meme_form page + - Downloads the image from the provided URL + - Saves it to a temporary file + - Passes that file to MemeEngine to generate a meme + - Deletes the temporary file + - Renders the final meme + """ + + image_url = request.form.get("image_url", "").strip() + body = request.form.get("body", "").strip() + author = request.form.get("author", "").strip() + image = requests.get(image_url, timeout=5) + + try: + tmp_file = f"tmp/{random.randint(0, 10000)}.jpg" + with open(tmp_file, "wb") as file: + file.write(image.content) + except: + print("Failed to generate meme") + path = None + if os.path.exists(tmp_file): + os.remove(tmp_file) + else: + path = meme.make_meme(tmp_file, body, author) + if os.path.exists(tmp_file): + os.remove(tmp_file) + finally: + return render_template("meme.html", path=path) + + +if __name__ == "__main__": + app.run() diff --git a/Meme_Generator/meme.py b/Meme_Generator/meme.py new file mode 100644 index 0000000..3163595 --- /dev/null +++ b/Meme_Generator/meme.py @@ -0,0 +1,54 @@ +import os +import random +import argparse +from MemeEngine import MemeEngine +from QuoteEngine import QuoteModel, Ingestor + + +def generate_meme(path=None, body=None, author=None): + """Generate a meme given an path and a quote""" + img = None + quote = None + + if path is None: + images = "./_data/photos/dog/" + imgs = [] + for root, dirs, files in os.walk(images): + imgs = [os.path.join(root, name) for name in files] + + img = random.choice(imgs) + else: + img = path[0] + + if body is None: + quote_files = [ + "./_data/DogQuotes/DogQuotesTXT.txt", + "./_data/DogQuotes/DogQuotesDOCX.docx", + "./_data/DogQuotes/DogQuotesPDF.pdf", + "./_data/DogQuotes/DogQuotesCSV.csv", + ] + quotes = [] + for f in quote_files: + quotes.extend(Ingestor.parse(f)) + + quote = random.choice(quotes) + else: + if author is None: + raise Exception("Author Required if Body is Used") + quote = QuoteModel(body, author) + + meme = MemeEngine("./tmp") + + path = meme.make_meme(img, quote.body, quote.author) + + return path + + +if __name__ == "__main__": + """Add path, quote, and author arguments for CLI, then print meme generation.""" + parser = argparse.ArgumentParser(description="Meme Generator") + parser.add_argument("--path", type=str, help="Image path") + parser.add_argument("--body", type=str, help="Quote adding to meme") + parser.add_argument("--author", type=str, help="Author adding to meme") + args = parser.parse_args() + print(generate_meme(args.path, args.body, args.author)) diff --git a/Meme_Generator/requirements.txt b/Meme_Generator/requirements.txt new file mode 100644 index 0000000..61a9f42 --- /dev/null +++ b/Meme_Generator/requirements.txt @@ -0,0 +1,29 @@ +black==25.12.0 +blinker==1.9.0 +certifi==2025.11.12 +charset-normalizer==3.4.4 +click==8.3.1 +Flask==3.1.2 +idna==3.11 +itsdangerous==2.2.0 +Jinja2==3.1.6 +lxml==6.0.2 +MarkupSafe==3.0.3 +mypy_extensions==1.1.0 +numpy==2.2.6 +packaging==25.0 +pandas==2.3.3 +pathspec==0.12.1 +pillow==12.1.0 +platformdirs==4.5.1 +python-dateutil==2.9.0.post0 +python-docx==1.2.0 +pytokens==0.3.0 +pytz==2025.2 +requests==2.32.5 +six==1.17.0 +tomli==2.3.0 +typing_extensions==4.15.0 +tzdata==2025.3 +urllib3==2.6.2 +Werkzeug==3.1.4 diff --git a/Meme_Generator/templates/base.html b/Meme_Generator/templates/base.html new file mode 100644 index 0000000..2151ddf --- /dev/null +++ b/Meme_Generator/templates/base.html @@ -0,0 +1,37 @@ + + + + {% block title %}{% endblock %} + + + + + {% block body %}{% endblock %} + + + + + diff --git a/Meme_Generator/templates/meme.html b/Meme_Generator/templates/meme.html new file mode 100644 index 0000000..01132b9 --- /dev/null +++ b/Meme_Generator/templates/meme.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} +{% block title %}Meme Generator{% endblock %} +{% block body %} + +{% endblock %} \ No newline at end of file diff --git a/Meme_Generator/templates/meme_form.html b/Meme_Generator/templates/meme_form.html new file mode 100644 index 0000000..4c165f5 --- /dev/null +++ b/Meme_Generator/templates/meme_form.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}Meme Generator{% endblock %} +{% block body %} +
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+{% endblock %} \ No newline at end of file