Skip to content

Perf: inject width/height on inline images to prevent CLS - #74

Merged
rivassec merged 2 commits into
mainfrom
inline-image-dimensions
Sep 12, 2026
Merged

Perf: inject width/height on inline images to prevent CLS#74
rivassec merged 2 commits into
mainfrom
inline-image-dimensions

Conversation

@rivassec

Copy link
Copy Markdown
Owner

What

Extends the existing plugins/img_hygiene.py plugin to inject real pixel width/height attributes on inline content images, eliminating cumulative layout shift (CLS). No new plugin, no pelicanconf.py change (img_hygiene is already registered).

Why (CLS rationale)

Inline body images had loading/decoding but no dimensions, so the browser could not reserve layout space until the image bytes arrived, shifting the text below every image. Supplying intrinsic width/height lets the browser compute the aspect ratio up front and reserve the box, which is what removes the shift. content/static/custom.css already declares main article p img { aspect-ratio: auto; height: auto; max-width: 100% }, so it was primed to honor these attributes and had no effect until they existed.

Signal / approach

Keeps the existing content_object_init signal. Rather than depending on link resolution timing, the plugin maps each <img src> back to its source file on disk and reads the dimensions with Pillow. The resolver is form-agnostic: it strips a leading {static}/{filename}/{attach} marker (the form present at content_object_init), or a scheme+host for a resolved absolute URL, or handles a document-relative ./images/... path, then maps the /images/... tail (any STATIC_PATHS dir) onto <PATH>/images/.... Because it resolves against the file on disk, it works identically whether the src is still a marker or already a resolved URL.

Verified working in both a normal local build (pelican content -o output -s pelicanconf.py, relative srcs like ./images/x.webp) and the publish build (-s publishconf.py, absolute https://rivassec.com/... srcs) - not deploy-only.

Guardrails:

  • Emits plain HTML attributes (width="800" height="450"), never style="", so scripts/csp_audit.py stays green.
  • Skips remote hosts and data:/blob: URIs (no local file to measure).
  • Skips imgs that already carry width or height (author sizing preserved).
  • Preserves existing loading/decoding behavior; degrades gracefully if Pillow is ever absent.
  • Template images (header logo, author-bio avatar which already ships width="72" height="72", cover/OG meta) are untouched - they are not in article _content.

Injected dimensions (the 3 posts with inline images)

Post Image Dimensions
leap-second-chaos-2012 leap_second_monotonic_vs_wall_clock.webp 2000x800
teensy-efi-bruteforce-hours-late teensy-efi-bruteforce-rig.webp 580x337
venezuela-twitter-proxy-osint who-is-chavezcandanga-com.webp 892x738
venezuela-twitter-proxy-osint chavezcandanga-web.webp 854x972

Spot-checked against PIL.Image.open(...).size - injected attrs match the real files.

Verification

  • scripts/csp_audit.py output - OK (no inline styles introduced)
  • scripts/check_image_alt.py, check_link_graph.py, check_em_dashes.py - all pass
  • python3 -m unittest discover tests - 38 tests OK

rivassec added 2 commits September 11, 2026 20:12
Extend img_hygiene to add real pixel width/height attributes to inline
content images, so the browser reserves layout space before image bytes
arrive and cumulative layout shift (CLS) is eliminated.

The plugin maps each <img src> back to its source file under the content
tree (handling {static} markers, relative ./images paths, and absolute
site URLs alike), reads the dimensions with Pillow, and emits them as
plain width/height HTML attributes. Remote hosts and data:/blob: URIs are
skipped; imgs that already carry width or height are left untouched, as
are existing loading/decoding attributes.

Dimensions are plain attributes, never a style="" attribute, so the
strict-CSP audit stays green. custom.css sets height:auto on content
images, so injected dimensions drive aspect-ratio reservation without
distorting the rendered image.
Pillow's DecompressionBombError subclasses Exception, not OSError, so a
future oversized inline image would break the build instead of degrading
to no-dimensions. Dimension injection is best-effort; never fatal.
(Critic follow-up on the CLS PR.)
@rivassec
rivassec merged commit 6674d77 into main Sep 12, 2026
18 checks passed
@rivassec
rivassec deleted the inline-image-dimensions branch September 12, 2026 03:20
rivassec added a commit that referenced this pull request Sep 12, 2026
…ks in prod (#76)

PR #74's width/height injection needs Pillow, but it was only a dev dep;
deploy installs requirements.txt with --no-deps so 'from PIL import Image'
failed on the runner -> dims silently skipped -> #74 inert in production.
Pillow has no required runtime deps, so --no-deps stays safe. Compiled
with --no-emit-index-url to keep private index URLs out of the lockfile.

Co-authored-by: rivassec <rivassec@rivassec.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant