#!/usr/bin/env python3 # This CLI tool allows for the download and packaging of various internet comics into .cbz files. # Copyright (C) 2025 Markil 3 # http://www.singlepilot.net # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . 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)