#!/usr/bin/env python3 # Copyright (c) 2025 Markil 3 import logging import datetime import textwrap import json import re from urllib.parse import urlparse from pathlib import Path from tempfile import NamedTemporaryFile import requests from PIL import Image, ImageDraw, ImageFont from bs4 import BeautifulSoup from comic_download.comic_strip import Comic, ComicStrip, register_comic_class class ExistentialComic(Comic): """ The Existential Comics strip """ def __init__(self): super().__init__("existential_comics") @classmethod def create_from_url(cls, url): return cls() def _load_data(self): self.title = "Existential Comics" self.author = "Corey Mohler" self.image_urls = { "header": "https://static.existentialcomics.com/title.jpg", "safety": "https://static.existentialcomics.com/safety.png" } self.description = "A webcomic of romance, sarcasm, math, and language." result = requests.get("https://existentialcomics.com/") result.raise_for_status() content = BeautifulSoup(result.content, features="lxml") # The home page automatically fetches the latest comic, and we # can grab the regular URL (and thus the index) from there. index = content.find("meta", property="og:url") if index and index["content"]: self.latest_identifier = int(index["content"].split('/')[-1]) else: raise ValueError("Invalid comic data") def get_first_strip(self): return 1 def get_latest_strip(self): return self.latest_identifier def get_all_strips(self) -> list: return list(range(self.get_first_strip(), self.get_latest_strip() + 1)) def __new__(cls): if not hasattr(cls, 'instance'): cls.instance = super(ExistentialComic, cls).__new__(cls) return cls.instance class ExistentialComicStrip(ComicStrip): """ A comic strip object that represents a single Existential Comic strip. """ def __init__(self, comic, index: int): super().__init__(comic, index) self.url = f"https://existentialcomics.com/comic/{index}" self.title = None @property def index(self): # Alias return self.identifier def _load_data(self): logging.info("Loading data for comic %s from %s", self.index, self.url) result = requests.get(self.url) result.raise_for_status() content = BeautifulSoup(result.content, features="lxml") title = content.find("meta", property="og:title") if title and title["content"]: self.title = title["content"] for img in content.find_all("img", class_="comicImg"): self.image_urls.append(f'http:{img["src"]}') if img.get("title"): self.captions.append(img["title"]) else: self.captions.append("") final_caption = "" explanation = content.find(id="explanation") if explanation: self.captions.extend(explanation.get_text().split("\n")) philosophers = content.find(id="philosophers-comic") if philosophers: self.captions.extend(philosophers.get_text().split("\n")) if final_caption: self.captions.append(final_caption) self.date = max(datetime.date(2013, 11, 11) + datetime.timedelta(weeks=self.index - 2), datetime.date(2013, 11, 12)) if self.date >= datetime.date(2023, 11, 13): self.safe = (self.date - datetime.date(2023, 11, 13)).days else: self.safe = (self.date - datetime.date(2013, 11, 12)).days def _transform_images(self, base_path: Path): """ Takes the raw image data from #download_data and transforms it to add a title, captions, etc. This function is not idempotent. """ # Marks out various bounding boxes of strip-global texts (i.e. how much space the title takes up) title_size = 18 safety_size = 24 caption_size = 15 f_size = (1000, 1500) title_font = ImageFont.truetype(Path(Path(__file__).parent, "Lucida Sans Bold.ttf"), title_size) safety_font = ImageFont.truetype(Path(Path(__file__).parent, "Lucida Sans Bold.ttf"), safety_size) caption_font = ImageFont.truetype(Path(Path(__file__).parent, "Lucida Sans.ttf"), caption_size) title_box = title_font.getbbox(self.title) title_box = (title_box[2] - title_box[0], title_box[3] - title_box[1]) caption_spacing = 4 safe_box = safety_font.getbbox(str(self.safe)) safe_box = (safe_box[2] - safe_box[0], safe_box[3] - safe_box[1]) url_box = caption_font.getbbox(self.url) url_box = (url_box[2] - url_box[0], url_box[3] - url_box[1]) date_box = caption_font.getbbox(self.date.isoformat()) date_box = (date_box[2] - date_box[0], date_box[3] - date_box[1]) text_margin = 10 img_size = (0, 0) self.transformed_images = [] header_img = Image.open(self.comic.images["header"].name) safety_img = Image.open(self.comic.images["safety"].name) for i in range(len(self.images)): img = Image.open(self.images[i].name) if self.captions[i]: # Take measurements of the page caption, if one is present. # Pillow's bbox functions do not handle newline characters properly, so we have to # split the text, find the widest line, and use that. caption = textwrap.wrap(self.captions[i], width = img.width // caption_size) caption_lines = len(caption) caption_box = caption_font.getbbox(max(caption, key=len)) caption_box = (caption_box[2] - caption_box[0], (caption_box[3] - caption_box[1] + caption_spacing) * len(caption)) caption = "\n".join(caption) else: caption_box = (0, 0) # The first page requires extra vertical space for the headers if i == 0: f_offset = header_img.height + safety_img.height + max(date_box[1], url_box[1]) + text_margin # Grab the image size while we are at it, for use outside of the loop img_size = img.size else: f_offset = 0 f_img_size = (img.width, f_offset + img.height + caption_box[1] + text_margin) f_img = Image.new("RGBA", f_img_size, (255, 255, 255, 255)) # Draw the headers as needed if i == 0: f_img.paste(header_img, (0, 0)) f_img.paste(safety_img, (0, header_img.height)) # Draw the actual comic f_img.paste(img, (0, f_offset)) draw = ImageDraw.Draw(f_img) if i == 0: # Draw the title draw.text(((img.width - title_box[0]) // 2, header_img.height + 20), self.title, (0, 0, 0), font=title_font) # Draw the safety text draw.text(((safety_img.width - safe_box[0]) // 2, header_img.height + 20), str(self.safe), (0, 0, 0), font=safety_font) # Draw the URL draw.text((text_margin, header_img.height + safety_img.height + text_margin), self.url, (0, 0, 0), font=caption_font) # Draw the date draw.text((img.width - date_box[0] - text_margin, header_img.height + safety_img.height + text_margin), self.date.isoformat(), (0, 0, 0), align="right", font=caption_font) # Draw the caption if self.captions[i]: draw.multiline_text(((img.width - caption_box[0]) // 2, f_offset + img.height), caption, (0, 0, 0), spacing=caption_spacing, font=caption_font, align="center") # Save the image f_image_path = NamedTemporaryFile(mode='wb', suffix=f"f_{i}.png", prefix=self.get_identifier_string(), delete = False) f_image_path.close() f_img.save(f_image_path.name) self.transformed_images.append(f_image_path) if len(self.captions) > len(self.image_urls): # Add the explanation to a new page explanation = [] # Pillow's bbox functions do not handle newline characters properly, so we have to # split the text, find the widest line, and use that. for paragraph in self.captions[len(self.image_urls):]: explanation.extend(textwrap.wrap(paragraph.strip(), width = img_size[0] // caption_size)) explanation_box = [caption_font.getbbox(ex) for ex in explanation] explanation_box = [(box[2] - box[0], box[3] - box[1]) for box in explanation_box] # Using a comparison function (i.e. max) on a tuple just compares the first value of each explanation_box = [max(explanation_box)[0], (explanation_box[0][1] + caption_spacing) * len(explanation_box)] explanation = "\n".join(explanation) f_img = Image.new("RGBA", (img_size[0], explanation_box[1] + text_margin * 4), (255, 255, 255, 255)) draw = ImageDraw.Draw(f_img) # Adds a background rectangle, like on the website draw.rounded_rectangle(((img_size[0] - explanation_box[0] - text_margin * 2) // 2, text_margin, (img_size[0] + explanation_box[0] + text_margin * 2) // 2, explanation_box[1] + text_margin * 3), 20, (200, 200, 200), (0, 0, 0), 2) draw.multiline_text(((img_size[0] - explanation_box[0]) // 2, text_margin * 2), explanation, (0, 0, 0), align="center", font=caption_font, spacing=caption_spacing) # Save the image f_image_path = NamedTemporaryFile(mode='wb', suffix=f"f_ex.png", prefix=self.get_identifier_string(), delete = False) f_image_path.close() f_img.save(f_image_path.name) self.transformed_images.append(f_image_path) ExistentialComic.strip_cls = ExistentialComicStrip register_comic_class("existentialcomics.com", ExistentialComic)