diff --git a/.github/workflows/jekyll.yml b/.github/workflows/jekyll.yml
index b03a797..609601d 100644
--- a/.github/workflows/jekyll.yml
+++ b/.github/workflows/jekyll.yml
@@ -57,6 +57,7 @@ jobs:
python3 tests/sync-cloudflare-security-headers.test.py
bundle exec ruby tests/research-tag-collision.test.rb
bundle exec ruby tests/asset-fingerprints.test.rb
+ bundle exec ruby tests/responsive-images.test.rb
- name: Build site
run: bash scripts/build.sh --skip-deps
diff --git a/_plugins/responsive_images.rb b/_plugins/responsive_images.rb
index 08cc4bd..5d7a57b 100644
--- a/_plugins/responsive_images.rb
+++ b/_plugins/responsive_images.rb
@@ -54,10 +54,38 @@ def local_asset_path(value)
def apply_dimensions(img, info)
return unless info
- return if img['width'] && img['height']
- img['width'] = info['width'].to_s
- img['height'] = info['height'].to_s
+ existing_width = img['width']
+ existing_height = img['height']
+ return if existing_width && existing_height
+
+ natural_w = info['width'].to_f
+ natural_h = info['height'].to_f
+ return if natural_w <= 0 || natural_h <= 0
+
+ # Author set neither: emit both intrinsic dims for CLS.
+ if existing_width.nil? && existing_height.nil?
+ img['width'] = info['width'].to_s
+ img['height'] = info['height'].to_s
+ return
+ end
+
+ # Numeric width without height: keep width, scale height.
+ if existing_width && existing_height.nil?
+ if existing_width.match?(/\A\d+(\.\d+)?\z/)
+ scaled = (existing_width.to_f * natural_h / natural_w).round
+ img['height'] = scaled.to_s if scaled.positive?
+ end
+ # Non-numeric width (e.g. 75%): leave alone; never invent height.
+ return
+ end
+
+ # Numeric height without width: keep height, scale width.
+ return unless existing_height && existing_width.nil?
+ return unless existing_height.match?(/\A\d+(\.\d+)?\z/)
+
+ scaled = (existing_height.to_f * natural_w / natural_h).round
+ img['width'] = scaled.to_s if scaled.positive?
end
def apply_loading(img)
diff --git a/assets/css/research-v2.css b/assets/css/research-v2.css
index 7954d48..b9d1f2b 100644
--- a/assets/css/research-v2.css
+++ b/assets/css/research-v2.css
@@ -623,6 +623,7 @@
/* Static images in the paper list sit at full width, paper-style */
.research-content img:not([src*="img.shields.io"]) {
max-width: 100%;
+ height: auto;
border-radius: var(--r-sm);
margin: var(--s-3) 0;
}
diff --git a/tests/responsive-images.test.rb b/tests/responsive-images.test.rb
new file mode 100644
index 0000000..5017bef
--- /dev/null
+++ b/tests/responsive-images.test.rb
@@ -0,0 +1,69 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require 'jekyll'
+require 'nokogiri'
+require_relative '../_plugins/responsive_images'
+
+def assert(condition, message)
+ raise message unless condition
+end
+
+def img_node(html)
+ Nokogiri::HTML.fragment(html).at_css('img')
+end
+
+INFO = { 'width' => 2391, 'height' => 2090 }.freeze
+
+# Neither attr: set both intrinsic dims (CLS / first-paint intent of #147).
+bare = img_node('
')
+CoMPhyResponsiveImages.apply_dimensions(bare, INFO)
+assert(bare['width'] == '2391', 'bare img should get natural width')
+assert(bare['height'] == '2090', 'bare img should get natural height')
+
+# Author percentage width, no height: never overwrite width or invent height.
+pct = img_node(
+ '
'
+)
+CoMPhyResponsiveImages.apply_dimensions(pct, INFO)
+assert(pct['width'] == '75%', 'percentage width must be preserved')
+assert(pct['height'].nil?, 'percentage width must not get a pixel height')
+
+# Numeric width without height: keep width, scale height.
+num = img_node(
+ '
'
+)
+CoMPhyResponsiveImages.apply_dimensions(num, INFO)
+assert(num['width'] == '800', 'numeric author width must be preserved')
+expected_h = ((800.0 * 2090) / 2391).round
+assert(
+ num['height'] == expected_h.to_s,
+ "expected proportional height #{expected_h}, got #{num['height']}"
+)
+
+# Both already set: leave untouched.
+both = img_node(
+ '
'
+)
+CoMPhyResponsiveImages.apply_dimensions(both, INFO)
+assert(both['width'] == '400', 'existing width must not change')
+assert(both['height'] == '300', 'existing height must not change')
+
+# Numeric height without width: keep height, scale width.
+h_only = img_node(
+ '
'
+)
+CoMPhyResponsiveImages.apply_dimensions(h_only, INFO)
+assert(h_only['height'] == '1045', 'numeric author height must be preserved')
+expected_w = ((1045.0 * 2391) / 2090).round
+assert(
+ h_only['width'] == expected_w.to_s,
+ "expected proportional width #{expected_w}, got #{h_only['width']}"
+)
+
+# Nil info is a no-op.
+noop = img_node('
')
+CoMPhyResponsiveImages.apply_dimensions(noop, nil)
+assert(noop['width'].nil? && noop['height'].nil?, 'nil info must no-op')
+
+puts 'responsive-images.test.rb: all assertions passed'