I'm still using Pelican to generate these notes. That's a tool that I created more than a decade ago, and it's still serving me well. Thanks to the team maintaining it nowadays!
One thing that I like with it is that it's highly extensible, with Python. As a result, there is a large amount of plugins for it.
Amongst others, Low Tech magazine did some work to lower the footprint of images, by using a technique named dithering. They provide the source of the plugin they did for doing that.
I've taken it and updated it to do things a little differently:
- Instead of looking for images in the content and updating the references, I'm converting all the images I can find. It's faster and works well for me.
- I've also made it output colored dithered images, using the NA16 palette by Nauris, as I think it looks better.
- Some other unimportant changes, like using
pathlib.Path.
It results in images like these ones (with a file size reduced by ~4):

Here's the code, provided under GPLv3. Thanks to Roel Roscam Abbing for the original code.
Run it with uvx like this (yeah, I know, and it sucks):
uvx --with git+https://www.github.com/hbldh/hitherdither --with Pillow --with beautifulsoup4 pelican
from pathlib import Path
import json
import logging
from pelican import signals
logger = logging.getLogger(__name__)
try:
from PIL import Image
import hitherdither
enabled = True
except:
logging.warning("Unable to load PIL or hitherdither, disabling dither plugin")
enabled = False
DEFAULT_THRESHOLD = [9, 9, 9]
DEFAULT_DITHER_PALETTE = [
(0, 0, 0), # black
(29, 43, 83), # dark blue
(126, 37, 83), # dark purple
(0, 135, 81), # dark green
(171, 82, 54), # brown
(95, 87, 79), # dark grey
(194, 195, 199), # light grey
(255, 241, 232), # white
(255, 0, 77), # red
(255, 163, 0), # orange
(255, 236, 39), # yellow
(0, 228, 54), # green
(41, 173, 255), # blue
(131, 118, 156), # lavender
(255, 119, 168), # pink
(255, 204, 170), # peach
] # Pico-8 16-color palette
DEFAULT_RESIZE_OUTPUT = True
DEFAULT_MAX_SIZE = (800, 800)
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"}
CACHE_FILE = ".dither_cache.json"
def dither(pelican):
global enabled
if not enabled:
return
output_path = Path(pelican.settings["OUTPUT_PATH"])
cache_path = output_path / CACHE_FILE
# Load cache: {rel_path: mtime_after_dither}
cache = {}
if cache_path.exists():
try:
cache = json.loads(cache_path.read_text())
except Exception:
cache = {}
resize = pelican.settings.get("RESIZE", DEFAULT_RESIZE_OUTPUT)
image_size = pelican.settings.get("SIZE", DEFAULT_MAX_SIZE)
palette = hitherdither.palette.Palette(
pelican.settings.get("DITHER_PALETTE", DEFAULT_DITHER_PALETTE)
)
threshold = pelican.settings.get("THRESHOLD", DEFAULT_THRESHOLD)
for output_file in output_path.rglob("*"):
if not output_file.is_file():
continue
if output_file.suffix.lower() not in IMAGE_EXTENSIONS:
continue
rel_path = str(output_file.relative_to(output_path))
current_mtime = output_file.stat().st_mtime
# Skip if already dithered (file hasn't been replaced since last dither)
if rel_path in cache and current_mtime <= cache[rel_path]:
logger.debug(f"Dither plugin: skipping already dithered {rel_path}")
continue
logger.debug(f"Dither plugin: dithering {rel_path}")
try:
img = Image.open(output_file).convert("RGB")
if resize:
img.thumbnail(image_size, Image.LANCZOS)
img_dithered = hitherdither.ordered.bayer.bayer_dithering(
img, palette, threshold, order=8
)
# JPEG doesn't support palette mode, convert back to RGB
if output_file.suffix.lower() in (".jpg", ".jpeg"):
img_dithered = img_dithered.convert("RGB")
img_dithered.save(output_file, optimize=True)
cache[rel_path] = output_file.stat().st_mtime
except Exception as e:
logger.warning(f"Dither plugin: failed to dither {rel_path}: {e}")
cache_path.write_text(json.dumps(cache))
def register():
signals.finalized.connect(dither)