223 lines
9.9 KiB
Python
223 lines
9.9 KiB
Python
#!/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 = datetime.date(2013, 11, 11) + datetime.timedelta(weeks=self.index - 2)
|
|
|
|
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)
|