diff --git a/plugins/img_hygiene.py b/plugins/img_hygiene.py
index 5d97614c..247ef710 100644
--- a/plugins/img_hygiene.py
+++ b/plugins/img_hygiene.py
@@ -10,25 +10,58 @@
* ``loading="lazy"`` - defer offscreen images until they are near the viewport
* ``decoding="async"`` - let the browser decode the image off the main thread
+* ``width`` / ``height`` - the image's real pixel dimensions, read from the
+ source file on disk, so the browser can reserve layout space before the
+ bytes arrive and avoid cumulative layout shift (CLS).
Attributes are only added when absent, so author-specified values (e.g. an
-eager hero image with ``loading="eager"``) are preserved. The transform is a
-conservative regex over the content HTML - it never touches ``src``/``href``
-values, so Pelican's ``{static}``/``{filename}`` link resolution (which runs
-lazily on ``.content`` access) is unaffected.
-
-Width/height injection is intentionally NOT performed: it would require
-resolving each ``{static}`` path back to a local file and reading its
-dimensions with PIL, which is not straightforward given intra-site link
-markers are unresolved at ``content_object_init`` time. Lazy-loading plus
-async decoding are the high-value, zero-risk wins.
+eager hero image with ``loading="eager"``, or an ``
`` that already carries
+its own ``width``/``height``) are preserved.
+
+Why width/height is safe here
+-----------------------------
+``content/static/custom.css`` styles content images with::
+
+ main article p img { aspect-ratio: auto; height: auto; max-width: 100%; }
+
+``height: auto`` (an author-stylesheet rule) beats the HTML ``height``
+presentational attribute, so the injected dimensions never distort the image:
+the browser derives the intrinsic aspect ratio from ``width``/``height`` and
+scales by width. ``aspect-ratio: auto`` explicitly tells the browser to honour
+those attributes for space reservation, which is exactly what kills CLS.
+
+The dimensions are emitted as plain HTML attributes (``width="800"``), never a
+``style=""`` attribute, so the strict-CSP audit (scripts/csp_audit.py) stays
+green.
+
+Resolving src -> file on disk
+-----------------------------
+``width``/``height`` require reading the source image, so the plugin maps each
+``
`` back to a file under the content tree. At
+``content_object_init`` the ``src`` is still an intra-site marker
+(``{static}/images/foo.webp``); after Pelican resolves links it is a site URL
+(``/images/foo.webp`` or ``https://rivassec.com/images/foo.webp``). Both forms
+carry the same site-relative path, so ``_resolve_src_to_file`` strips any
+marker, scheme, or host and maps the ``/images/...`` (or any ``STATIC_PATHS``
+directory) tail onto ``/images/...`` on disk. Remote hosts and
+``data:``/``blob:`` URIs are skipped - there is no local file to measure.
+
+The transform is a conservative regex over the content HTML; it never rewrites
+``src``/``href`` values, so Pelican's ``{static}``/``{filename}`` link
+resolution (which runs lazily on ``.content`` access) is unaffected.
"""
+import os
import re
import logging
from pelican import signals, contents
+try: # Pillow is a build dependency; degrade gracefully if it is ever absent.
+ from PIL import Image
+except Exception: # pragma: no cover - only hit if Pillow is missing
+ Image = None
+
logger = logging.getLogger(__name__)
# Match a single
start tag. [^>]* is the standard practical bound;
@@ -37,22 +70,126 @@
_IMG_TAG_RE = re.compile(r"
]*>", re.IGNORECASE)
_HAS_LOADING_RE = re.compile(r"\bloading\s*=", re.IGNORECASE)
_HAS_DECODING_RE = re.compile(r"\bdecoding\s*=", re.IGNORECASE)
+_HAS_WIDTH_RE = re.compile(r"\bwidth\s*=", re.IGNORECASE)
+_HAS_HEIGHT_RE = re.compile(r"\bheight\s*=", re.IGNORECASE)
+_SRC_RE = re.compile(r"""\bsrc\s*=\s*(?P["'])(?P.*?)(?P=q)""", re.IGNORECASE)
+# Intra-site link markers Pelican leaves in _content before .content resolves.
+_MARKER_RE = re.compile(r"^\{(?:static|filename|attach)\}", re.IGNORECASE)
+
+# filepath -> (width, height) | None. Process-lifetime cache so an image
+# referenced from several posts is only opened once per build.
+_DIM_CACHE = {}
+
+
+def _dimensions(filepath):
+ """Return (width, height) for a local image file, or None on any failure."""
+ if filepath in _DIM_CACHE:
+ return _DIM_CACHE[filepath]
+ result = None
+ if Image is not None:
+ try:
+ with Image.open(filepath) as im:
+ result = im.size # (width, height)
+ except Exception as exc: # noqa: BLE001 - never let image reads break the build
+ # Broad on purpose: Pillow raises DecompressionBombError (subclasses
+ # Exception, not OSError) on very large images; dimension injection
+ # is best-effort and must degrade to "no dims", never fail the build.
+ logger.debug("img_hygiene: cannot read dimensions of %s: %s", filepath, exc)
+ _DIM_CACHE[filepath] = result
+ return result
+
+
+def _resolve_src_to_file(src, settings):
+ """Map an
(marker or resolved URL) to a source file on disk.
+
+ Returns an absolute/relative filesystem path if the src points at a local
+ static asset that exists, else None (remote, data URI, or not found).
+ """
+ src = src.strip()
+ if not src:
+ return None
+
+ # Strip a leading intra-site marker ({static}/{filename}/{attach}); the
+ # remainder is a site-relative path such as /images/foo.webp.
+ marker = _MARKER_RE.match(src)
+ if marker:
+ path = src[marker.end():]
+ else:
+ lower = src.lower()
+ if lower.startswith(("data:", "blob:")):
+ return None
+ if "://" in src:
+ # Absolute URL. Only our own site can be mapped to disk; anything
+ # else is a remote image with no local file to measure.
+ scheme, _, rest = src.partition("://")
+ if scheme.lower() not in ("http", "https"):
+ return None
+ host, _, tail = rest.partition("/")
+ siteurl = (settings.get("SITEURL") or "") if settings else ""
+ local_hosts = {"rivassec.com", "www.rivassec.com"}
+ if "://" in siteurl:
+ local_hosts.add(siteurl.split("://", 1)[1].split("/", 1)[0])
+ if host not in local_hosts:
+ return None
+ path = "/" + tail
+ else:
+ # Site-relative or document-relative path (e.g. /images/x.webp or
+ # ../images/x.webp).
+ path = src
+
+ # Drop any query string or fragment.
+ path = path.split("?", 1)[0].split("#", 1)[0]
+
+ content_root = settings.get("PATH", "content") if settings else "content"
+ static_dirs = settings.get("STATIC_PATHS", ["images"]) if settings else ["images"]
+
+ # Locate the static directory segment (e.g. "images/") within the path and
+ # rebuild the path relative to the content root. Handles /images/x.webp,
+ # ../images/x.webp, {static}/images/x.webp alike.
+ for static_dir in static_dirs:
+ needle = static_dir.strip("/") + "/"
+ idx = path.rfind("/" + needle)
+ if idx != -1:
+ rel = path[idx + 1:]
+ elif path.startswith(needle):
+ rel = path
+ else:
+ continue
+ candidate = os.path.join(content_root, rel)
+ if os.path.isfile(candidate):
+ return candidate
+ return None
+
+
+def _make_augmenter(settings):
+ def _augment_img(match):
+ tag = match.group(0)
+ additions = ""
+ if not _HAS_LOADING_RE.search(tag):
+ additions += ' loading="lazy"'
+ if not _HAS_DECODING_RE.search(tag):
+ additions += ' decoding="async"'
+
+ # Only inject dimensions when the tag has neither width nor height, so
+ # author-specified sizing is always respected.
+ if not _HAS_WIDTH_RE.search(tag) and not _HAS_HEIGHT_RE.search(tag):
+ src_match = _SRC_RE.search(tag)
+ if src_match:
+ filepath = _resolve_src_to_file(src_match.group("val"), settings)
+ if filepath:
+ dims = _dimensions(filepath)
+ if dims:
+ additions += ' width="%d" height="%d"' % (dims[0], dims[1])
+ if not additions:
+ return tag
+ # Insert new attributes just before the tag close, preserving whether
+ # the original tag was self-closing ("/>") or a plain start tag (">").
+ if tag.endswith("/>"):
+ return tag[:-2].rstrip() + additions + " />"
+ return tag[:-1] + additions + ">"
-def _augment_img(match):
- tag = match.group(0)
- additions = ""
- if not _HAS_LOADING_RE.search(tag):
- additions += ' loading="lazy"'
- if not _HAS_DECODING_RE.search(tag):
- additions += ' decoding="async"'
- if not additions:
- return tag
- # Insert new attributes just before the tag close, preserving whether the
- # original tag was self-closing ("/>") or a plain HTML5 start tag (">").
- if tag.endswith("/>"):
- return tag[:-2].rstrip() + additions + " />"
- return tag[:-1] + additions + ">"
+ return _augment_img
def add_image_hygiene(content):
@@ -62,7 +199,8 @@ def add_image_hygiene(content):
html = getattr(content, "_content", None)
if not html or "![]()