From 057da7c2c72395a2139d1995233034700d2c3360 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Thu, 23 Apr 2026 11:36:13 +0200 Subject: [PATCH] feature: add support for image galleries using `glightbox` (#290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Timothée Mazzucotelli Signed-off-by: squidfunk --- python/zensical/extensions/glightbox.py | 255 ++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 python/zensical/extensions/glightbox.py diff --git a/python/zensical/extensions/glightbox.py b/python/zensical/extensions/glightbox.py new file mode 100644 index 0000000..40e0cba --- /dev/null +++ b/python/zensical/extensions/glightbox.py @@ -0,0 +1,255 @@ +# Copyright (c) 2025-2026 Zensical and contributors + +# SPDX-License-Identifier: MIT +# All contributions are certified under the DCO + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Any, cast +from xml.etree.ElementTree import Element, ParseError, fromstring, tostring + +from markdown.extensions import Extension +from markdown.postprocessors import RawHtmlPostprocessor +from markdown.treeprocessors import Treeprocessor + +if TYPE_CHECKING: + from markdown import Markdown + +# ----------------------------------------------------------------------------- +# Constants +# ----------------------------------------------------------------------------- + +_IMG_RE = re.compile(r"]*?>", re.IGNORECASE | re.DOTALL) +"""Match images in stashed raw HTML blocks.""" + +# ----------------------------------------------------------------------------- +# Classes +# ----------------------------------------------------------------------------- + + +class GlightboxTreeprocessor(Treeprocessor): + """Wraps image elements in anchor tags to enable GLightbox functionality.""" + + SKIP_CLASSES: frozenset[str] = frozenset( + {"emojione", "twemoji", "gemoji", "off-glb"} + ) + + def __init__(self, md: Markdown | None, config: dict[str, object]) -> None: + super().__init__(md) + self.config = config + + def run(self, root: Element) -> None: + """Walk the element tree and wrap images with anchors.""" + skip_classes = self.SKIP_CLASSES | frozenset( + cast("list[str]", self.config.get("skip_classes") or []) + ) + + # Iterate over all images in the tree and wrap them with anchors + for img in list(root.iter("img")): + if not self._should_skip(img, skip_classes): + self._wrap_with_anchor(img, root) + + def _should_skip(self, img: Element, skip_classes: frozenset[str]) -> bool: + """Return if this image should be excluded from wrapping.""" + classes = set(img.get("class", "").split()) + if classes & skip_classes: + return True + + # If manual mode is enabled, only wrap images explicitly marked + return not self.config.get("auto") and "on-glb" not in classes + + def _wrap_with_anchor(self, img: Element, root: Element) -> None: + """Wrap an image with an anchor.""" + parent = self._find_parent(root, img) + if parent is None or parent.tag == "a": + return + + # Wrap image with anchor + idx = list(parent).index(img) + el = self._build_anchor(img) + parent.remove(img) + el.append(img) + parent.insert(idx, el) + + def _build_anchor(self, img: Element) -> Element: + """Construct the anchor from image attributes.""" + el = Element("a") + el.set("class", "glightbox") + el.set("href", img.get("data-src") or img.get("src") or "") + el.set("data-type", "image") + el.set("data-width", str(self.config.get("width", "auto"))) + el.set("data-height", str(self.config.get("height", "auto"))) + + # Set image title + auto_caption: bool = bool(self.config.get("auto_caption", False)) + title = img.get("data-title") or ( + img.get("alt") if auto_caption else None + ) + if title: + el.set("data-title", title) + + # Set image description + if description := img.get("data-description"): + el.set("data-description", description) + + # Set image description position + caption_position = img.get("data-caption-position") or str( + self.config.get("caption_position", "bottom") + ) + el.set("data-desc-position", caption_position) + + # Set gallery grouping + if gallery := self._resolve_gallery(img): + el.set("data-gallery", gallery) + + # Return element + return el + + def _resolve_gallery(self, img: Element) -> str: + """Determine gallery group for an image.""" + src = img.get("data-src") or img.get("src") or "" + + # If auto-themed grouping is enabled, group images by light/dark mode + # hints in the URL (e.g. from GitHub's light/dark mode image syntax) + if self.config.get("auto_themed"): + if "#only-light" in src or "#gh-light-mode-only" in src: + return "light" + if "#only-dark" in src or "#gh-dark-mode-only" in src: + return "dark" + + # Explicit gallery grouping takes precedence over auto-themed grouping + return img.get("data-gallery") or "" + + def _find_parent(self, root: Element, target: Element) -> Element | None: + """Return the direct parent of target within the element tree.""" + return next( + (parent for parent in root.iter() if target in list(parent)), + None, + ) + + +class GlightboxPostprocessor(RawHtmlPostprocessor): + """Wraps stashed images in anchors, delegating to the treeprocessor. + + This postprocessor uses a regular expression to find image tags in stashed + raw HTML blocks and applies the same wrapping logic as the treeprocessor. + Using a regular expression is cheaper and more resilient than trying to + parse and modify the HTML with an actual parser. + """ + + def __init__(self, md: Markdown | None, config: dict[str, object]) -> None: + super().__init__(md) + self._processor = GlightboxTreeprocessor(md, config) + self._skip_classes = GlightboxTreeprocessor.SKIP_CLASSES | frozenset( + cast("list[str]", config.get("skip_classes") or []) + ) + + def run(self, text: str) -> str: + """Wrap images in stashed HTML blocks, then reinstate all raw HTML.""" + for i, raw in enumerate(self.md.htmlStash.rawHtmlBlocks): + self.md.htmlStash.rawHtmlBlocks[i] = _IMG_RE.sub( + self._maybe_wrap, raw + ) + + # Delegate to parent postprocessor to reinstate raw HTML + return super().run(text) + + def _maybe_wrap(self, m: re.Match[str]) -> str: + """Wrap a single matched image, delegating to the treeprocessor.""" + raw = m.group(0) + try: + fragment = raw if raw.endswith("/>") else raw[:-1] + "/>" + img = fromstring(fragment) # noqa: S314 + except ParseError: + return raw + + # Skip if image should not be wrapped + if self._processor._should_skip(img, self._skip_classes): + return raw + + # Wrap image in anchor and return as string + anchor = self._processor._build_anchor(img) + anchor.append(img) + return tostring(anchor, encoding="unicode", method="html") + + +# ----------------------------------------------------------------------------- + + +class GlightboxExtension(Extension): + """Markdown extension that wraps images in GLightbox anchor tags. + + This extension provides both a treeprocessor to wrap images in the normal + Markdown flow and a postprocessor to handle images that are stashed as raw + HTML, ensuring that all images are properly wrapped regardless of how they + are processed by Markdown. + """ + + def __init__(self, **kwargs: object) -> None: + """Initialize the extension.""" + self.config: dict[str, list[object]] = { + "width": ["auto", "Width of the lightbox overlay."], + "height": ["auto", "Height of the lightbox overlay."], + "skip_classes": [ + [], + "List of image CSS classes to exclude from lightbox wrapping.", + ], + "auto": [ + True, + "Only wrap images that explicitly carry the on-glb CSS class.", + ], + "auto_themed": [ + False, + "Group light/dark mode images into separate galleries.", + ], + "auto_caption": [ + False, + "Use img alt attribute as the caption when no title is set.", + ], + "caption_position": [ + "bottom", + "Default caption position: bottom, top, left, or right.", + ], + } + super().__init__(**kwargs) + + def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 + """Register Markdown extension.""" + md.registerExtension(self) + + # Register treeprocessor + treeprocessor = GlightboxTreeprocessor(md, self.getConfigs()) + md.treeprocessors.register(treeprocessor, "glightbox", 15) + + # Register postprocessor just before raw_html + postprocessor = GlightboxPostprocessor(md, self.getConfigs()) + md.postprocessors.register(postprocessor, "glightbox_raw", 35) + + +# ----------------------------------------------------------------------------- +# Functions +# ----------------------------------------------------------------------------- + + +def makeExtension(**kwargs: Any) -> GlightboxExtension: # noqa: N802 + """Register Markdown extension.""" + return GlightboxExtension(**kwargs)