Switch image references to use picture elements and AVIF encoded files

Up to now, image references, eg. #link("img:​:5f39dd4e")[::ImageAlt] have functioned like so:

This has worked well, but I’ve been wanting to add AVIF support in order to take advantage of its better compression performance. AVIF is not universally supported, so a fallback (eg. JPEG) is necessary. The HTML \<picture> element is well suited to this. The Djot image syntax has been bypassed, image references are now directly replaced with a final HTML snippet like so,

python
replacement = f"""`​`​`=html
\<picture>
\<source srcset="{Path(ref_slug).with_suffix(".avif")}" type="image/avif" />
\<img alt="{ref_text}" src="{ref_slug}">
\</picture>
`​`​`"""

The actual AVIF creation support was added using pillow_avif-plugin, a plugin for the Pillow library I was already using for compressing JPEGs. Most of the relevant code is in the process_image_parallel function,

python
def process_image_parallel(input_data: Tuple[Path, Path, int, str]) -> None:
workaround_import = pillow_avif.AvifImagePlugin
input_image, output_path, output_width, uid = input_data
lock_path = output_path.with_suffix(".lock")
lock = FileLock(str(lock_path))

# Define AVIF output path
avif_output_path = output_path.with_suffix(".avif")

# Check if AVIF support is available
avif_available = "AVIF" in Image.SAVE

try:
with lock:
if output_path.exists() and avif_output_path.exists():
return

os.makedirs(output_path.parent, exist_ok=True)

with Image.open(input_image) as im:
original_format = im.format
im = ImageOps.exif_transpose(im)
output_height = int(im.size[1] * (output_width / im.size[0]))

with im.resize(
(output_width, output_height), Image.Resampling.LANCZOS
) as output_image:
# Save JPEG version
if (
original_format != "JPEG"
and str(output_path).endswith("jpg")
and output_image.mode in ("RGBA", "P")
):
output_image = output_image.convert("RGB")

output_image.save(output_path, quality=85, optimize=True)

# Save AVIF version only if support is available
if avif_available:
try:
if output_image.mode in ("RGBA", "P"):
avif_image = output_image.convert("RGB")
else:
avif_image = output_image.copy()

avif_image.save(
avif_output_path,
format="AVIF",
quality=60, # Lower quality for better compression, still maintains good visual quality
speed=5, # Slowest speed = best compression (0 is slowest, 10 is fastest)
bits=10, # Use 10-bit color depth for better quality-to-size ratio
compress_level=8, # Highest compression level (range 0-8)
color_space="bt709", # Use YUV BT.709 color space
chroma=0, # 4:4:4 chroma sampling (0=4:4:4, 1=4:2:0, 2=4:2:2)
num_threads=0, # Use all available CPU threads for encoding
)
logger.debug(
f"Processed image: {input_image} -> {output_path} and {avif_output_path}"
)
except Exception as e:
logger.error(
f"Error saving AVIF version of {input_image}: {e}"
)
else:
logger.error(
"AVIF support not available. Skipping AVIF conversion."
)
logger.debug(f"Processed image: {input_image} -> {output_path}")

except OSError as e:
logger.error(f"OS error processing {input_image}: {e}")
except Exception as e:
logger.error(f"Error processing {input_image}: {e}")
finally:
if lock_path.exists():
try:
lock_path.unlink()
except OSError:
pass

It was also necessary to update my gallery/lightbox JavaScript code, as the previous version was loading the JPEG fallback when the lightbox opened, wasting bandwidth.

js
class Lightbox {
constructor(galleryElement) {
this.galleryElement = galleryElement;
this.galleryItems = this.galleryElement.querySelectorAll('img, picture');
this.currentIndex = 0;
this.overlay = null;
this.content = null;
this.image = null;
this.caption = null;
this.closeButton = null;
this.prevButton = null;
this.nextButton = null;
this.isOpen = false;
this.init();
this.imageCount = galleryElement.querySelectorAll('img').length;
}

init() {
this.createLightbox();
this.addEventListeners();
}

createLightbox() {
this.overlay = document.createElement('div');
this.overlay.className = 'lightbox-overlay';
this.overlay.innerHTML = `
\<div class="lightbox-content">
\<img src="" alt="" class="lightbox-image">
\<p class="lightbox-caption">\</p>
\<button class="lightbox-prev">&lt;\</button>
\<button class="lightbox-next">&gt;\</button>
\</div>
\<button class="lightbox-close">&times;\</button>
`;
document.body.appendChild(this.overlay);

this.content = this.overlay.querySelector('.lightbox-content');
this.image = this.overlay.querySelector('.lightbox-image');
this.caption = this.overlay.querySelector('.lightbox-caption');
this.closeButton = this.overlay.querySelector('.lightbox-close');
this.prevButton = this.overlay.querySelector('.lightbox-prev');
this.nextButton = this.overlay.querySelector('.lightbox-next');
}

getImageDetails(element) {
if (element.tagName.toLowerCase() === 'picture') {
const img = element.querySelector('img');
return {
// currentSrc gives us the source that the browser actually loaded
src: img.currentSrc || img.src,
alt: img.alt
};
} else {
return {
src: element.currentSrc || element.src,
alt: element.alt
};
}
}

addEventListeners() {
this.galleryItems.forEach((item, index) => {
// For picture elements, we need to attach the listener to the img inside
const clickTarget = item.tagName.toLowerCase() === 'picture'
? item.querySelector('img')
: item;

clickTarget.addEventListener('click', (e) => {
e.preventDefault();
this.openLightbox(index);
});
});

this.overlay.addEventListener('click', (e) => {
if (!this.content.contains(e.target) || e.target === this.content) {
this.closeLightbox();
}
});

this.closeButton.addEventListener('click', () => this.closeLightbox());

document.addEventListener('keydown', (e) => {
if (this.isOpen) {
if (e.key === 'Escape') this.closeLightbox();
if (e.key === 'ArrowLeft') this.showPrevious();
if (e.key === 'ArrowRight') this.showNext();
}
});

window.addEventListener('popstate', () => {
if (this.isOpen) {
this.closeLightbox(false);
}
});

this.prevButton.addEventListener('click', () => this.showPrevious());
this.nextButton.addEventListener('click', () => this.showNext());
}

openLightbox(index) {
this.currentIndex = index;
this.updateLightboxContent();
this.overlay.style.display = 'block';
document.body.style.overflow = 'hidden';
this.isOpen = true;
history.pushState({ lightboxOpen: true }, '');
}

closeLightbox(pushState = true) {
this.overlay.style.display = 'none';
document.body.style.overflow = '';
this.isOpen = false;
if (pushState) {
history.pushState({ lightboxOpen: false }, '');
}
}

updateLightboxContent() {
const currentItem = this.galleryItems[this.currentIndex];
const { src, alt } = this.getImageDetails(currentItem);

this.image.src = src;
this.image.alt = alt;
this.caption.textContent = alt;

// Use imageCount instead of galleryItems.length
if (this.imageCount <= 1) {
this.prevButton.style.display = 'none';
this.nextButton.style.display = 'none';
} else {
this.prevButton.style.display = 'block';
this.nextButton.style.display = 'block';
}
}

showPrevious() {
this.currentIndex = (this.currentIndex - 1 + this.galleryItems.length) % this.galleryItems.length;
this.updateLightboxContent();
}

showNext() {
this.currentIndex = (this.currentIndex + 1) % this.galleryItems.length;
this.updateLightboxContent();
}
}

// Initialize the lightbox for each gallery
document.addEventListener('DOMContentLoaded', () => {
const galleries = document.querySelectorAll('.gallery');
galleries.forEach(gallery => new Lightbox(gallery));
});

The result: higher image quality (raised image default width from 1400px to 1600px) and half the file-size, eg. my nonsense page has been cut from 7.8MB transferred to load, to 3.45MB. Or, to compare only the actual images, 6.8MB to 2.94MB, a 57% saving. At higher quality! Madness.

Coupled with the previous change β€” Convert heavy PNGs to JPEG where appropriate β€” that page has been cut from 15.9 to 3.45MB. Happy with that.