Source code for lightbox.lightbox

# SPDX-License-Identifier: GPL-3.0-or-later

"""
Lightbox extension for Sphinx.
Enhances standard Sphinx image and figure nodes with click-to-enlarge HTML
overlays, optional per-document gallery navigation, and lightweight JavaScript
progressive enhancement for keyboard accessibility.

The legacy ``.. lightbox::`` directive remains available for 0.5.x
compatibility; standard ``image`` and ``figure`` directives are the public
authoring interface.
"""

from __future__ import annotations

import hashlib
import os
import posixpath
import re
import shutil
from html import escape as html_escape
from pathlib import Path
from typing import Any, cast

from docutils import nodes
from docutils.parsers.rst import directives
from sphinx.application import Sphinx
from sphinx.locale import get_translation
from sphinx.transforms.post_transforms import SphinxPostTransform
from sphinx.util import logging
from sphinx.util.docutils import SphinxDirective
from sphinx.util.texescape import escape as latex_escape

logger = logging.getLogger(__name__)

__version__ = "0.6.0"

_MESSAGE_CATALOG = "sphinx-lightbox"
_LOCALE_DIR = Path(__file__).resolve().parent / "locales"
_ = get_translation(_MESSAGE_CATALOG)



[docs]
class LightboxContainer(nodes.General, nodes.Element):
    """Outer wrapper grouping the trigger thumbnail and the overlay."""




[docs]
class LightboxTrigger(nodes.General, nodes.Element):
    """Thumbnail image that opens the lightbox when clicked."""




[docs]
class LightboxOverlay(nodes.General, nodes.Element):
    """Full-size image overlay with caption and close control."""



class LightboxCollector(nodes.General, nodes.Element):
    """
    Wrapper around the collector and fallback image node.
    Triggers Sphinx's ImageCollector for HTML builds and supplies the plain
    image fallback for non-HTML, non-LaTeX builders.
    """


_LIGHTBOX_CLASS = "lightbox"
_NO_LIGHTBOX_CLASS = "no-lightbox"
_POLICIES = {"explicit", "all", "none"}
_GALLERY_MODES = {"document", "none"}
_LIGHTBOX_ENV_VERSION = 2
_SAFE_ID_PART_RE = re.compile(r"[^A-Za-z0-9_.:-]+")
_URI_SCHEME_RE = re.compile(r"^[a-z][a-z0-9+.-]*:")
_SAFE_CSS_WIDTH_RE = re.compile(r"^(?:auto|0|[0-9]+(?:\.[0-9]+)?(?:%|px|em|rem|vw|vh|vmin|vmax)?)$")
_SAFE_STYLE_CHARS_RE = re.compile(r"^[0-9A-Za-z\s.:;,%()+*/-]+$")
_UNSAFE_CSS_TOKENS = ("url(", "expression(", "@import", "\\")


# ---------------------------------------------------------------------------
# Shared Helpers
# ---------------------------------------------------------------------------


def skip_departure(self: Any, node: nodes.Node) -> None:
    """Empty departure function to satisfy Sphinx requirements."""
    pass


def visit_noop(self: Any, node: nodes.Node) -> None:
    """Do-nothing visit function."""
    pass


def _visit_skip(self: Any, node: nodes.Node) -> None:
    """Visit function that skips the node and all its children."""
    raise nodes.SkipNode


def _is_remote_or_data_uri(uri: str) -> bool:
    """Return whether an image URI should stay outside lightbox processing."""
    normalized = uri.strip().lower()
    return normalized.startswith("//") or _URI_SCHEME_RE.match(normalized) is not None


def _is_lightbox_html_builder(builder: Any) -> bool:
    """Return whether a builder supports the interactive lightbox output."""
    # EPUB inherits Sphinx's HTML format marker, but its self-contained output
    # must use the collector image fallback rather than interactive overlays.
    return getattr(builder, "format", "") == "html" and getattr(builder, "name", "") != "epub"


def _safe_html_id_part(value: str, fallback: str = "document") -> str:
    """Return a conservative string for generated HTML id fragments."""
    safe_value = _SAFE_ID_PART_RE.sub("-", value).strip("-")
    return safe_value or fallback


def _accessible_image_name(alt_text: str, uri: str) -> str:
    """Return explicit alt text or a readable fallback derived from the filename."""
    if alt_text.strip():
        return alt_text.strip()
    stem = posixpath.splitext(posixpath.basename(uri.rstrip("/")))[0]
    # Translators: Generic accessible name used only when neither alt text nor
    # a usable image filename is available.
    return re.sub(r"[-_]+", " ", stem).strip() or _("Image")


def _source_image_path(srcdir: str, uri: str) -> str | None:
    """Resolve an image URI to an absolute source path confined to srcdir."""
    if not srcdir or _is_remote_or_data_uri(uri):
        return None

    rel_uri = uri.lstrip("/")
    image_path = os.path.realpath(
        os.path.abspath(os.path.join(srcdir, rel_uri.replace("/", os.sep)))
    )
    safe_srcdir = os.path.realpath(os.path.abspath(srcdir))
    try:
        if os.path.commonpath([safe_srcdir, image_path]) != safe_srcdir:
            return None
    except ValueError:
        return None
    return image_path


def _sanitize_css_width(width: str) -> str:
    """Return a safe CSS width value for inline thumbnail sizing."""
    width = width.strip()
    if _SAFE_CSS_WIDTH_RE.fullmatch(width):
        return width
    logger.warning(
        _("Invalid lightbox thumbnail width {width!r}; falling back to '100%'.").format(
            width=width
        ),
        type="lightbox",
        subtype="invalid_style",
    )
    return "100%"


def _sanitize_style_attr(style: str) -> str:
    """Return a defensive inline style value for internally generated styles."""
    style = style.strip()
    lowered = style.lower()
    if _SAFE_STYLE_CHARS_RE.fullmatch(style) and not any(
        token in lowered for token in _UNSAFE_CSS_TOKENS
    ):
        return style
    logger.warning(
        _("Invalid lightbox inline style suppressed."),
        type="lightbox",
        subtype="invalid_style",
    )
    return ""


def _resolve_output_uri(builder: Any, uri: str) -> str:
    """Resolve a source-relative image URI to its HTML output path."""
    if hasattr(builder, "images") and _has_image_uri(builder.images, uri):
        imgpath = getattr(builder, "imgpath", "_images")
        output_uri = builder.images[uri]
        if isinstance(output_uri, tuple):
            output_uri = output_uri[1]
        return f"{imgpath}/{output_uri}"

    duplicate_uri = _resolve_duplicate_output_uri(builder, uri)
    if duplicate_uri:
        imgpath = getattr(builder, "imgpath", "_images")
        return f"{imgpath}/{duplicate_uri}"

    env = getattr(builder, "env", None)
    env_images = getattr(env, "images", None)
    if _has_image_uri(env_images, uri):
        imgpath = getattr(builder, "imgpath", "_images")
        output_uri = cast(Any, env_images)[uri]
        if isinstance(output_uri, tuple):
            output_uri = output_uri[1]
        return f"{imgpath}/{output_uri}"
    return uri


def _resolve_duplicate_output_uri(builder: Any, uri: str) -> str:
    """Return Sphinx's copied filename when identical source images are deduped."""
    env = getattr(builder, "env", None)
    env_images = getattr(env, "images", None)
    builder_images = getattr(builder, "images", None)
    srcdir = getattr(env, "srcdir", getattr(builder, "srcdir", ""))
    env_image_items = getattr(env_images, "items", None)
    if not srcdir or not builder_images or not callable(env_image_items):
        return ""

    source_digest = _image_digest(srcdir, uri)
    if source_digest is None:
        return ""

    for candidate_uri, _target in env_image_items():
        if candidate_uri == uri or not _has_image_uri(builder_images, candidate_uri):
            continue
        if _image_digest(srcdir, candidate_uri) != source_digest:
            continue
        output_uri = builder_images[candidate_uri]
        if isinstance(output_uri, tuple):
            output_uri = output_uri[1]
        return str(output_uri)

    return ""


def _image_digest(srcdir: str, uri: str) -> str | None:
    """Return a digest for a source image URI, constrained to the source tree."""
    image_path = _source_image_path(srcdir, uri)
    if image_path is None:
        return None
    try:
        with open(image_path, "rb") as image_file:
            return hashlib.sha256(image_file.read()).hexdigest()
    except OSError:
        return None


def _has_image_uri(images: Any, uri: str) -> bool:
    """Return whether an image mapping contains a URI, tolerating test mocks."""
    try:
        return uri in images
    except TypeError:
        return False


def _builder_inited(app: Sphinx) -> None:
    """Register the extension's static path natively with Sphinx."""
    static_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "static"))
    if static_dir not in app.config.html_static_path:
        app.config.html_static_path.append(static_dir)


def _lightbox_images_by_doc(env: Any) -> dict[str, set[str]]:
    """Return recorded lightbox image URIs keyed by Sphinx docname."""
    raw_value: Any = getattr(env, "lightbox_image_uris_by_doc", {})
    if not isinstance(raw_value, dict):
        return {}

    image_uris_by_doc: dict[str, set[str]] = {}
    for docname, uris in raw_value.items():
        if not isinstance(docname, str):
            continue
        try:
            image_uris_by_doc[docname] = {str(uri) for uri in uris}
        except TypeError:
            continue
    return image_uris_by_doc


def _all_lightbox_image_uris(env: Any) -> set[str]:
    """Return all lightbox image URIs recorded in the build environment."""
    image_uris: set[str] = set()
    for uris in _lightbox_images_by_doc(env).values():
        image_uris.update(uris)
    return image_uris


def _register_lightbox_image(env: Any, docname: str, uri: str) -> None:
    """Record a source image URI that lightbox HTML directly references."""
    normalized_uri = uri.lstrip("/")
    if not normalized_uri:
        return

    image_uris_by_doc = _lightbox_images_by_doc(env)
    image_uris_by_doc.setdefault(docname, set()).add(normalized_uri)
    env.lightbox_image_uris_by_doc = image_uris_by_doc


def _purge_lightbox_images(app: Sphinx, env: Any, docname: str) -> None:
    """Remove cached lightbox image metadata for a rebuilt or removed document."""
    image_uris_by_doc = _lightbox_images_by_doc(env)
    if docname in image_uris_by_doc:
        del image_uris_by_doc[docname]
        env.lightbox_image_uris_by_doc = image_uris_by_doc


def _merge_lightbox_images(app: Sphinx, env: Any, docnames: set[str], other: Any) -> None:
    """Merge per-document lightbox image metadata from parallel read workers."""
    image_uris_by_doc = _lightbox_images_by_doc(env)
    other_image_uris_by_doc = _lightbox_images_by_doc(other)

    for docname in docnames:
        if docname in other_image_uris_by_doc:
            image_uris_by_doc.setdefault(docname, set()).update(other_image_uris_by_doc[docname])

    env.lightbox_image_uris_by_doc = image_uris_by_doc


def _copy_missing_lightbox_images(app: Sphinx, exception: Exception | None) -> None:
    """Copy lightbox assets that Sphinx skipped after standard image transforms."""
    if exception is not None or not _is_lightbox_html_builder(app.builder):
        return

    image_uris = _all_lightbox_image_uris(app.env)
    env_images = getattr(app.env, "images", None)
    # imagedir, not imgpath: imgpath is relative to the last written document and
    # can escape outdir when joined to it. imagedir is the builder's stable output
    # location, but still enforce containment for custom HTML builders.
    outdir = os.path.realpath(os.path.abspath(app.outdir))
    image_dir = os.path.realpath(
        os.path.abspath(os.path.join(outdir, getattr(app.builder, "imagedir", "_images")))
    )
    try:
        if os.path.commonpath([outdir, image_dir]) != outdir:
            logger.warning(
                _(
                    "Refusing to copy lightbox images outside the output directory: '{directory}'"
                ).format(directory=image_dir),
                type="lightbox",
                subtype="unsafe_image_dir",
            )
            return
    except ValueError:
        return
    os.makedirs(image_dir, exist_ok=True)
    missing_targets = _missing_html_image_targets(app.outdir)

    for uri, output_uri in getattr(env_images, "items", lambda: [])():
        if isinstance(output_uri, tuple):
            output_uri = output_uri[1]
        if uri not in image_uris and str(output_uri) not in missing_targets:
            continue
        if not _has_image_uri(env_images, uri):
            continue
        source_path = _source_image_path(app.env.srcdir, uri)
        if source_path is None:
            continue
        target_filename = os.path.basename(str(output_uri))
        if not target_filename:
            continue
        target_path = os.path.realpath(os.path.abspath(os.path.join(image_dir, target_filename)))
        try:
            if os.path.commonpath([image_dir, target_path]) != image_dir:
                continue
        except ValueError:
            continue
        if os.path.exists(target_path):
            continue
        try:
            shutil.copyfile(source_path, target_path)
        except OSError as exc:
            logger.warning(
                _("Could not copy lightbox image '{source}' to '{target}': {error}").format(
                    source=source_path,
                    target=target_path,
                    error=exc,
                ),
                type="lightbox",
                subtype="copy_image",
            )


def _missing_html_image_targets(outdir: str) -> set[str]:
    """Return image filenames referenced by HTML but absent from the output tree."""
    missing_targets: set[str] = set()
    image_src = re.compile(r'<img\b[^>]*\bsrc="([^"]+)"')
    for root, _dirs, files in os.walk(outdir):
        for filename in files:
            if not filename.endswith(".html"):
                continue
            html_path = os.path.join(root, filename)
            try:
                with open(html_path, encoding="utf-8") as html_file:
                    html = html_file.read()
            except OSError:
                continue
            for match in image_src.finditer(html):
                src = match.group(1).split("#", 1)[0].split("?", 1)[0]
                if _is_remote_or_data_uri(src):
                    continue
                target_path = os.path.abspath(os.path.join(root, src))
                if not os.path.exists(target_path):
                    missing_targets.add(os.path.basename(target_path))
    return missing_targets


def _figure_child_text(image: nodes.image, node_type: type[nodes.Element]) -> str:
    """Return plain text from a specific child type on the image's figure."""
    if not isinstance(image.parent, nodes.figure):
        return ""
    for child in image.parent:
        if isinstance(child, node_type):
            return child.astext()
    return ""


def _image_classes(app: Sphinx, image: nodes.image) -> str:
    """Return user classes, excluding lightbox control classes."""
    default_class = cast(str, app.config.lightbox_default_class).strip()
    classes = cast(list[str], image.get("classes", []))
    image_classes = [cls for cls in classes if cls not in {_LIGHTBOX_CLASS, _NO_LIGHTBOX_CLASS}]
    return " ".join([default_class, *image_classes]).strip()


def _policy(app: Sphinx, config_name: str) -> str:
    """Return a validated image/figure transform policy from Sphinx config."""
    policy = cast(str, getattr(app.config, config_name)).lower()
    if policy not in _POLICIES:
        logger.warning(
            _(
                "Invalid {config_name} value {policy!r}; expected one of 'explicit', "
                "'all', or 'none'. Falling back to 'explicit'."
            ).format(config_name=config_name, policy=policy),
            type="lightbox",
            subtype="invalid_config",
        )
        return "explicit"
    return policy


def _gallery_mode(app: Sphinx) -> str:
    """Return a validated gallery mode from Sphinx config."""
    mode = cast(str, app.config.lightbox_gallery).lower()
    if mode not in _GALLERY_MODES:
        logger.warning(
            _(
                "Invalid lightbox_gallery value {mode!r}; expected 'document' or 'none'. "
                "Falling back to 'document'."
            ).format(mode=mode),
            type="lightbox",
            subtype="invalid_config",
        )
        return "document"
    return mode


def _is_transform_candidate(app: Sphinx, image: nodes.image) -> bool:
    """Decide whether a standard image node should be converted to a lightbox."""
    uri = image.get("uri", "")
    if not uri or _is_remote_or_data_uri(uri):
        return False

    classes = cast(list[str], image.get("classes", []))
    if _NO_LIGHTBOX_CLASS in classes:
        return False
    if isinstance(image.parent, (nodes.reference, nodes.TextElement, LightboxContainer)):
        return False
    ancestor = image.parent
    while ancestor is not None:
        if isinstance(ancestor, LightboxContainer):
            return False
        ancestor = ancestor.parent

    config_name = (
        "lightbox_figures" if isinstance(image.parent, nodes.figure) else "lightbox_images"
    )
    policy = _policy(app, config_name)
    if policy == "explicit" and app.config.lightbox_all_images:
        policy = "all"

    if policy == "none":
        return False
    if policy == "all":
        return True
    return _LIGHTBOX_CLASS in classes


def _container_checkbox_id(container: LightboxContainer) -> str:
    """Return the checkbox id for a lightbox container."""
    for child in container:
        if isinstance(child, (LightboxTrigger, LightboxOverlay)) and "checkbox_id" in child:
            return cast(str, child["checkbox_id"])
    return ""


def _set_container_checkbox_id(container: LightboxContainer, checkbox_id: str) -> None:
    """Set one checkbox id on every control node in a lightbox container."""
    for child in container:
        if isinstance(child, (LightboxTrigger, LightboxOverlay)):
            child["checkbox_id"] = checkbox_id


def _overlay_for_container(container: LightboxContainer) -> LightboxOverlay | None:
    """Return the overlay child for a lightbox container."""
    for child in container:
        if isinstance(child, LightboxOverlay):
            return child
    return None


def _clear_gallery_metadata(overlay: LightboxOverlay) -> None:
    """Remove gallery attributes so disabled or single-item galleries render plainly."""
    for key in (
        "gallery_id",
        "gallery_index",
        "gallery_count",
        "gallery_prev_target",
        "gallery_next_target",
    ):
        if key in overlay:
            del overlay[key]


def _collector_for_image(uri: str, alt_text: str) -> LightboxCollector:
    """Return an image node collected for HTML and rendered by fallback builders."""
    collector_uri = uri if uri.startswith("/") else f"/{uri}"
    candidate_uri = uri.lstrip("/")
    fallback_img = nodes.image(uri=collector_uri, alt=alt_text)
    fallback_img["candidates"] = {"*": candidate_uri}
    collector = LightboxCollector()
    collector += fallback_img
    return collector



[docs]
def assign_lightbox_gallery(app: Sphinx, doctree: nodes.document, docname: str) -> None:
    """Assign per-document gallery metadata to lightbox overlays."""
    containers = [
        container
        for container in doctree.findall(LightboxContainer)
        if _overlay_for_container(container) is not None
    ]
    for container in containers:
        overlay = _overlay_for_container(container)
        if overlay is not None:
            _clear_gallery_metadata(overlay)

    if _gallery_mode(app) == "none" or len(containers) <= 1:
        return

    safe_docname = _safe_html_id_part(docname)
    gallery_id = f"lightbox-gallery-{safe_docname}"
    gallery_wrap = bool(app.config.lightbox_gallery_wrap)

    for index, container in enumerate(containers):
        overlay = _overlay_for_container(container)
        if overlay is None:
            continue

        overlay["gallery_id"] = gallery_id
        overlay["gallery_index"] = index + 1
        overlay["gallery_count"] = len(containers)

        previous_container = None
        next_container = None
        if index > 0:
            previous_container = containers[index - 1]
        elif gallery_wrap:
            previous_container = containers[-1]

        if index < len(containers) - 1:
            next_container = containers[index + 1]
        elif gallery_wrap:
            next_container = containers[0]

        if previous_container is not None:
            overlay["gallery_prev_target"] = _container_checkbox_id(previous_container)
        if next_container is not None:
            overlay["gallery_next_target"] = _container_checkbox_id(next_container)




[docs]
def transform_lightbox_images(app: Sphinx, doctree: nodes.document, docname: str) -> None:
    """Convert standard image/figure nodes with class ``lightbox`` for HTML builds."""
    if not _is_lightbox_html_builder(app.builder):
        return

    safe_docname = _safe_html_id_part(docname)
    checkbox_prefix = f"lightbox-{safe_docname}-"
    used_html_ids: set[str] = set()
    for element in doctree.findall(nodes.Element):
        used_html_ids.update(cast(list[str], element.get("ids", [])))
    next_serial = 1

    def allocate_checkbox_id() -> str:
        nonlocal next_serial
        checkbox_id = f"{checkbox_prefix}{next_serial}"
        while checkbox_id in used_html_ids:
            next_serial += 1
            checkbox_id = f"{checkbox_prefix}{next_serial}"
        used_html_ids.add(checkbox_id)
        next_serial += 1
        return checkbox_id

    # Compatibility-directive IDs are assigned during parsing, before later
    # ``:name:`` targets are known. Normalize them here, when the complete
    # document is available, so they cannot create duplicate HTML ids.
    for container in doctree.findall(LightboxContainer):
        checkbox_id = _container_checkbox_id(container)
        if checkbox_id and checkbox_id not in used_html_ids:
            used_html_ids.add(checkbox_id)
        else:
            _set_container_checkbox_id(container, allocate_checkbox_id())

    for image in list(doctree.findall(nodes.image)):
        if not _is_transform_candidate(app, image):
            continue

        checkbox_id = allocate_checkbox_id()

        uri = image.get("uri", "")
        alt_text = image.get("alt", "")
        custom_class = _image_classes(app, image)
        caption = _figure_child_text(image, nodes.caption)
        legend = _figure_child_text(image, nodes.legend)

        container = LightboxContainer()
        if image.get("align"):
            container["align"] = image["align"]
        container.source = image.source
        container.line = image.line

        trigger = LightboxTrigger()
        trigger["uri"] = uri
        trigger["alt"] = alt_text
        trigger["custom_class"] = custom_class
        trigger["checkbox_id"] = checkbox_id

        # Let Sphinx render the thumbnail's native image node. This preserves
        # built-in image/figure options such as width, height, scale, loading,
        # name, and their builder-specific behavior instead of duplicating a
        # changing subset in the extension's HTML visitor. The trigger's
        # accessible name carries the original alt text, so its child image is
        # deliberately decorative.
        thumbnail = image.deepcopy()
        thumbnail["alt"] = ""
        # Sphinx normally wraps resized images in a link to the source file.
        # The lightbox trigger already provides that enlargement behavior, so
        # suppress the nested link while preserving all sizing semantics.
        thumbnail["classes"] = ["lightbox-trigger", "no-scaled-link", *custom_class.split()]
        trigger += thumbnail

        overlay = LightboxOverlay()
        overlay["uri"] = uri
        overlay["alt"] = alt_text
        overlay["caption"] = caption
        overlay["legend"] = legend
        overlay["size_style"] = ""
        overlay["custom_class"] = custom_class
        overlay["checkbox_id"] = checkbox_id

        container += trigger
        container += overlay
        image.replace_self(container)

    assign_lightbox_gallery(app, doctree, docname)



class LightboxImageTransform(SphinxPostTransform):
    """Add HTML lightboxes after Sphinx has filtered ``only`` branches."""

    default_priority = 60
    formats = ("html",)

    def run(self, **_kwargs: Any) -> None:
        # Sphinx 8 introduced ``current_document`` and Sphinx 9 moved the
        # application reference from ``env.app`` to ``env._app``. Support the
        # complete declared Sphinx 7-9 range without invoking deprecated
        # compatibility properties on newer versions.
        app = getattr(self.env, "_app", None)
        if app is None:
            app = self.env.app
        current_document = getattr(self.env, "current_document", None)
        docname = current_document.docname if current_document is not None else self.env.docname
        transform_lightbox_images(
            app,
            self.document,
            docname,
        )


# ---------------------------------------------------------------------------
# HTML visitors
# ---------------------------------------------------------------------------


def visit_lightbox_container_html(self: Any, node: LightboxContainer) -> None:
    classes = ["lightbox-container"]
    if node.get("align"):
        classes.append(f"align-{node['align']}")
    class_attr = html_escape(" ".join(classes), quote=True)
    self.body.append(f'<div class="{class_attr}">\n')


def depart_lightbox_container_html(self: Any, node: LightboxContainer) -> None:
    self.body.append("</div>\n")


def visit_lightbox_trigger_html(self: Any, node: LightboxTrigger) -> None:
    checkbox_id = html_escape(node["checkbox_id"], quote=True)
    image_name = _accessible_image_name(node.get("alt", ""), node["uri"])
    # Translators: Accessible label for the control that opens an enlarged image.
    enlarge_label = html_escape(_("Enlarge image: {image}").format(image=image_name), quote=True)

    self.body.append(
        f'<label for="{checkbox_id}" class="lightbox-trigger-label">\n'
        f'  <span class="lightbox-trigger-control" role="button" tabindex="0" '
        f'data-lightbox-target="{checkbox_id}">\n'
        f'    <span class="lightbox-visually-hidden">{enlarge_label}</span>\n'
    )

    # Legacy directive nodes do not contain a native image child. Keep their
    # 0.5.x HTML output working without making that duplicate API prominent.
    has_native_thumbnail = next(node.findall(nodes.image), None) is not None
    if not has_native_thumbnail:
        image_uri = html_escape(_resolve_output_uri(self.builder, node["uri"]), quote=True)
        custom_class = html_escape(node.get("custom_class", ""), quote=True)
        thumbnail_width = html_escape(
            _sanitize_css_width(node.get("thumbnail_width", "100%")), quote=True
        )
        cls = f"lightbox-trigger {custom_class}".strip()
        self.body.append(
            f'    <img src="{image_uri}" alt="" class="{cls}" style="width: {thumbnail_width};">\n'
        )


def depart_lightbox_trigger_html(self: Any, node: LightboxTrigger) -> None:
    self.body.append("  </span>\n</label>\n")


def visit_lightbox_overlay_html(self: Any, node: LightboxOverlay) -> None:
    checkbox_id = html_escape(node["checkbox_id"], quote=True)
    image_uri = html_escape(_resolve_output_uri(self.builder, node["uri"]), quote=True)
    alt_text = html_escape(_accessible_image_name(node.get("alt", ""), node["uri"]), quote=True)
    caption = html_escape(node.get("caption", ""), quote=True)
    legend = html_escape(node.get("legend", ""), quote=True)
    raw_size_style = node.get("size_style", "")
    size_style = (
        html_escape(_sanitize_style_attr(raw_size_style), quote=True) if raw_size_style else ""
    )
    size_attr = f' style="{size_style}"' if size_style else ""
    custom_class = html_escape(node.get("custom_class", ""), quote=True)
    gallery_index = int(node.get("gallery_index", 0))
    gallery_count = int(node.get("gallery_count", 0))
    prev_target = html_escape(node.get("gallery_prev_target", ""), quote=True)
    next_target = html_escape(node.get("gallery_next_target", ""), quote=True)
    # Translators: Accessible label for the previous-image gallery button.
    prev_label_text = _("Previous image in gallery ({index} of {count})")
    prev_label = html_escape(
        prev_label_text.format(index=gallery_index, count=gallery_count),
        quote=True,
    )
    # Translators: Accessible label for the next-image gallery button.
    next_label_text = _("Next image in gallery ({index} of {count})")
    next_label = html_escape(
        next_label_text.format(index=gallery_index, count=gallery_count),
        quote=True,
    )
    # Translators: Accessible label for the control that closes the image dialog.
    close_label = html_escape(_("Close lightbox"), quote=True)

    cls = custom_class.strip() if custom_class else ""
    self.body.append(
        f'<input type="checkbox" id="{checkbox_id}" '
        f'class="lightbox-toggle" aria-hidden="true" tabindex="-1">\n'
        f'<div class="lightbox-overlay" role="dialog" aria-modal="true" '
        f'aria-label="{alt_text}">\n'
        f'  <label for="{checkbox_id}" class="lightbox-close-label">'
        f'<span class="lightbox-close" role="button" tabindex="0" '
        f'data-lightbox-target="{checkbox_id}">'
        f'<span aria-hidden="true">&times;</span>'
        f'<span class="lightbox-visually-hidden">{close_label}</span></span></label>\n'
    )
    if prev_target:
        self.body.append(
            '  <button type="button" '
            'class="lightbox-gallery-control lightbox-gallery-prev" '
            f'data-lightbox-target="{prev_target}" aria-label="{prev_label}">'
            "&lsaquo;</button>\n"
        )
    if next_target:
        self.body.append(
            '  <button type="button" '
            'class="lightbox-gallery-control lightbox-gallery-next" '
            f'data-lightbox-target="{next_target}" aria-label="{next_label}">'
            "&rsaquo;</button>\n"
        )
    self.body.append('  <div class="lightbox-content">\n')

    img_class = f' class="{cls}"' if cls else ""
    self.body.append(f'    <img src="{image_uri}" alt="{alt_text}"{img_class}{size_attr}>\n')

    if caption or legend:
        self.body.append('    <div class="lightbox-text">\n')
        if caption:
            self.body.append(f'      <p class="lightbox-caption">{caption}</p>\n')
        if legend:
            self.body.append(f'      <div class="lightbox-legend">{legend}</div>\n')
        self.body.append("    </div>\n")

    self.body.append(
        f'  </div>\n  <label for="{checkbox_id}" class="lightbox-backdrop-close"></label>\n</div>\n'
    )


def depart_lightbox_overlay_html(self: Any, node: LightboxOverlay) -> None:
    pass


# ---------------------------------------------------------------------------
# LaTeX visitors
# ---------------------------------------------------------------------------


def visit_lightbox_container_latex(self: Any, node: LightboxContainer) -> None:
    uri = node.get("uri")
    if hasattr(self.builder, "images") and uri in self.builder.images:
        image_file = self.builder.images[uri]
    else:
        image_file = os.path.basename(uri)

    latex_width = node.get("latex_width", "0.95")
    caption = node.get("caption", "")

    self.body.append("\n\\begin{figure}[htbp]\n\\centering\n")
    self.body.append(
        f"\\adjustbox{{max width={latex_width}\\linewidth}}{{\\includegraphics{{{image_file}}}}}\n"
    )
    if caption:
        escaped_caption = latex_escape(caption)
        self.body.append(f"\\caption{{{escaped_caption}}}\n")
    self.body.append("\\end{figure}\n")

    raise nodes.SkipNode


# ---------------------------------------------------------------------------
# Directive
# ---------------------------------------------------------------------------


class LightboxDirective(SphinxDirective):
    """Compatibility directive retained for documents authored with 0.5.x."""

    has_content = False
    required_arguments = 1
    optional_arguments = 0
    final_argument_whitespace = True
    option_spec = {
        "alt": directives.unchanged,
        "caption": directives.unchanged,
        "percentage": directives.positive_int_list,
        "class": directives.unchanged,
        "latex-width": directives.unchanged,
    }

    def run(self) -> list[nodes.Node]:
        env = self.env
        raw_path = self.arguments[0].strip()
        alt_text = self.options.get("alt", "")

        if _is_remote_or_data_uri(raw_path):
            return [nodes.image(uri=raw_path, alt=alt_text)]

        image_path = self._resolve_image_path(raw_path)
        if image_path is None:
            return []

        env.images.add_file(env.docname, image_path)
        _register_lightbox_image(env, env.docname, image_path)

        abs_fs_path = os.path.normpath(os.path.join(env.srcdir, image_path.replace("/", os.sep)))
        aspect_ratio = 1.0
        try:
            from sphinx.util.images import get_image_size

            width, height = get_image_size(abs_fs_path)
            if width and height:
                aspect_ratio = width / height
        except Exception as e:
            logger.warning(
                _(
                    "Could not calculate image dimensions for '{path}': {error}. "
                    "Falling back to 1:1 aspect ratio."
                ).format(path=raw_path, error=e),
                location=(env.docname, self.lineno),
                type="lightbox",
                subtype="image_dimensions",
            )

        caption = self.options.get("caption", "")
        percentages = self.options.get("percentage", [])
        custom_class = self.options.get("class", "")

        thumbnail_width = f"{percentages[0]}%" if percentages else "100%"
        lightbox_pct = percentages[1] if len(percentages) > 1 else 95
        latex_width = f"{lightbox_pct / 100:.2f}"

        # Optional override: :latex-width: decouples PDF sizing from HTML
        latex_width_override = self.options.get("latex-width")
        if latex_width_override is not None:
            try:
                val = float(latex_width_override)
                if not 0.0 < val <= 1.0:
                    raise ValueError
                latex_width = f"{val:.2f}"
            except ValueError:
                logger.warning(
                    _(
                        "Invalid :latex-width: value '{value}'. Expected a float between 0 "
                        "and 1 (e.g. 0.8). Falling back to percentage-based width."
                    ).format(value=latex_width_override),
                    location=(env.docname, self.lineno),
                    type="lightbox",
                    subtype="invalid_option",
                )

        safe_docname = _safe_html_id_part(env.docname)
        checkbox_id = f"lightbox-{safe_docname}-{env.new_serialno('lightbox')}"

        container = LightboxContainer()
        container["uri"] = image_path
        container["caption"] = caption
        container["latex_width"] = latex_width
        container.source, container.line = self.state_machine.get_source_and_line(self.lineno)

        trigger = LightboxTrigger()
        trigger["uri"] = image_path
        trigger["alt"] = alt_text
        trigger["thumbnail_width"] = thumbnail_width
        trigger["custom_class"] = custom_class
        trigger["checkbox_id"] = checkbox_id

        overlay = LightboxOverlay()
        overlay["uri"] = image_path
        overlay["alt"] = alt_text
        overlay["caption"] = caption
        overlay["size_style"] = (
            f"width: min({lightbox_pct}vw, calc({lightbox_pct}vh * {aspect_ratio:.4f}));"
            f"height: min({lightbox_pct}vh, calc({lightbox_pct}vw / {aspect_ratio:.4f}));"
        )
        overlay["custom_class"] = custom_class
        overlay["checkbox_id"] = checkbox_id

        container += trigger
        container += overlay

        container += _collector_for_image(image_path, alt_text)

        return [container]

    def _resolve_image_path(self, raw_path: str) -> str | None:
        env = self.env
        if raw_path.startswith("/"):
            rel_to_source = raw_path.lstrip("/")
        else:
            current_dir = posixpath.dirname(env.docname)
            rel_to_source = posixpath.normpath(posixpath.join(current_dir, raw_path))

        # Resolve symlinks as well as ``..`` components before checking the
        # boundary; otherwise a path inside srcdir can point to an outside file.
        abs_fs_path = os.path.realpath(
            os.path.abspath(os.path.join(env.srcdir, rel_to_source.replace("/", os.sep)))
        )
        safe_srcdir = os.path.realpath(os.path.abspath(env.srcdir))

        # Compare the common path to ensure the target is strictly inside srcdir
        try:
            if os.path.commonpath([safe_srcdir, abs_fs_path]) != safe_srcdir:
                raise ValueError
        except ValueError:
            logger.warning(
                _("Lightbox image path traverses outside source directory: {path}").format(
                    path=raw_path
                ),
                location=(env.docname, self.lineno),
                type="lightbox",
                subtype="path_traversal",
            )
            return None

        if not os.path.isfile(abs_fs_path):
            logger.warning(
                _("Lightbox image not found: {path}").format(path=abs_fs_path),
                location=(env.docname, self.lineno),
                type="lightbox",
                subtype="image_not_found",
            )
            return None

        return rel_to_source


# ---------------------------------------------------------------------------
# Setup
# ---------------------------------------------------------------------------



[docs]
def setup(app: Sphinx) -> dict[str, Any]:
    app.require_sphinx("7.0")
    app.add_message_catalog(_MESSAGE_CATALOG, _LOCALE_DIR)
    app.add_config_value("lightbox_all_images", False, "env", bool)
    app.add_config_value("lightbox_images", "explicit", "env", str)
    app.add_config_value("lightbox_figures", "all", "env", str)
    app.add_config_value("lightbox_default_class", "with-shadow", "env", str)
    app.add_config_value("lightbox_gallery", "document", "env", str)
    app.add_config_value("lightbox_gallery_wrap", False, "env", bool)
    app.add_node(
        LightboxContainer,
        html=(visit_lightbox_container_html, depart_lightbox_container_html),
        latex=(visit_lightbox_container_latex, skip_departure),
        epub=(visit_noop, skip_departure),
        text=(visit_noop, skip_departure),
        man=(visit_noop, skip_departure),
        texinfo=(visit_noop, skip_departure),
    )
    app.add_node(
        LightboxTrigger,
        html=(visit_lightbox_trigger_html, depart_lightbox_trigger_html),
        latex=(visit_noop, skip_departure),
        epub=(visit_noop, skip_departure),
        text=(visit_noop, skip_departure),
        man=(visit_noop, skip_departure),
        texinfo=(visit_noop, skip_departure),
    )
    app.add_node(
        LightboxOverlay,
        html=(visit_lightbox_overlay_html, depart_lightbox_overlay_html),
        latex=(visit_noop, skip_departure),
        epub=(visit_noop, skip_departure),
        text=(visit_noop, skip_departure),
        man=(visit_noop, skip_departure),
        texinfo=(visit_noop, skip_departure),
    )
    app.add_node(
        LightboxCollector,
        html=(_visit_skip, skip_departure),
        latex=(visit_noop, skip_departure),
        epub=(visit_noop, skip_departure),
        text=(visit_noop, skip_departure),
        man=(visit_noop, skip_departure),
        texinfo=(visit_noop, skip_departure),
    )

    app.add_directive("lightbox", LightboxDirective)
    app.connect("builder-inited", _builder_inited)
    app.connect("env-purge-doc", _purge_lightbox_images)
    app.connect("env-merge-info", _merge_lightbox_images)
    app.add_post_transform(LightboxImageTransform)
    app.connect("build-finished", _copy_missing_lightbox_images)
    app.add_css_file("lightbox.css")
    app.add_js_file("lightbox.js")
    app.add_latex_package("adjustbox")

    return {
        "version": __version__,
        "env_version": _LIGHTBOX_ENV_VERSION,
        "parallel_read_safe": True,
        "parallel_write_safe": True,
    }