ブラウザごとに挙動が異なるimg要素のsrcset属性およびsizes属性と、Retinaディスプレーの問題

自分の写真をまとめるためのページを作っていて気がついたのだが、img要素におけるsrcsetsizes属性の挙動はブラウザによって異なっている。Safariの実装が最も遅れていて、Firefoxが一番仕様に忠実に実装されている。Chromeは近いうちに仕様通りの動作になるようだ。

さらに、Retina(HiDPI)ディスプレーでsizes属性を使うと、ブラウザが少し予想外の挙動をすることもあるので、ついでにメモを残す。

この記事の内容は以下のバージョンのブラウザに基づく。

Safariはsizes属性内でcalc()しか使用できない

Safari(Webkit)にはバグがあって、sizes属性内でサイズを指定する部分(source-size-value)がcalc()にしか対応していない。仕様的にはmath functionに対応している必要があるので、min()max()も使えないといけないのだが、現在の実装はcalc()決め打ちになっている

Webkitのバグトラッカーには該当バグのチケットが存在するものの、2018年から放置されている。

min()が使えないと「左右幅(100vw)、もしくは上下幅(100vh)の小さい方を基準に画像を選択する」といったことが出来ないので不便である。

Chrome(blink)はsizes_math_function_parser.ccに実装があって、calc()はもちろんのこと、min()max()clamp()などに対応している。Firefoxでも問題なく全ての関数を使用することができる。

<!-- Safariでもcalc()は使える -->
<img sizes="(max-width: 1280px) calc(100vw - 2rem)" ...>

<!-- Safariだとmin()を使った以下の例は動作しない -->
<img sizes="(max-width: 1280px) min(100vw, calc(100vh * 1.5))" ...>

ChromeやSafariはsizes属性のメディアクエリーがandにしか対応していない

Chrome(blink)やSafari(WebKit)のsizes属性のパーサーは、Media Queries Level 4を完全には実装していない。

sizes属性のパース方法を見ると、クエリー部分は正確にはメディアクエリー(<media-query>)ではなくて、Media Queries Level 4の<media-condition>として定義されている。

<media-condition>の定義をみると()のネストやorの使用ができるはずなのだが、ChromeやSafariではまだ対応しておらず、Media Query Level 3時代から存在している、andのみ使うことができる。

<!-- ChromeとSafariが唯一対応しているのはこの構文 -->
<img sizes="(min-width: 100px) and (max-width: 1000px) 200px" ...>

<!-- ChromeとSafariはorが使えないので以下の条件はエラーになる -->
<img sizes="(min-width: 100px) or (max-width: 1000px) 200px" ...>

<!-- ChromeとSafariは()のネストが出来ないのでエラーになる -->
<img sizes="((min-width: 100px) and (max-width: 1000px)) 200px" ...>

Firefoxはこの部分の実装では先行しており、()のネストもorも問題なく使用できる。Chromeもすでに実装自体は存在しており、バージョン104からはor()のネストに対応する。バージョン104はすでにDev Channelにリリースされているので、正式版でも遠くない未来に使用できるようになると思われる。

macOSではRetina(HiDPI)ディスプレイにおけるCSS Pixel Ratioが2で固定されている

MacBookなどでは、内蔵ディスプレイ上でウェブブラウザを使用すると、ディスプレイの設定をどのように変えても、CSS Pixel Ratioが常に2になる。これにより、ブラウザがsrcset内の画像から予想以上に大きな画像を選択することがある。

例えば、MacBook Pro (16-inch, 2019)のハードウェア的な画面解像度は3072 x 1920となっている。Pixel Ratioが2ということは、CSSにおける仮想的なピクセル数は、ハードウェアのピクセル数を2で割って、1536 x 960となる。

しかし、OSデフォルトの解像度設定は1792 x 1120となっており、1536 x 960より一回り大きい。さらに、最大解像度を選ぶと2048 x 1280にまでなる。1792と3072の割合はおおよそ1.7、2048と3072だと1.5で、これらの値がそれぞれの解像度の(計算上の)Pixel Ratioになる。しかし、ChromeもSafariもFirefoxも、OSの解像度設定に関わらず、Pixel Ratioは常に2に設定されている。

これによって問題になるのが、srcset属性内の画像選択である。

sizes属性内で、例えば100vwが選択されたとする(sizes="100vw")。OSのディスプレイ設定が1536 x 960になっていて、ブラウザのウィンドウが最大化されている場合(つまり100vw = 1536px)、ブラウザは1536pxをPixel Ratioに基づいて2倍して、3072wに一番近い画像を探そうとする。ディスプレイの物理的なピクセル数も3072pxなので、妥当な処理に思われる。

一方で、OSのディスプレイ設定が2048 x 1280になっている場合、ブラウザは同様に100vw = 2048pxを2倍して4096wの画像を探しに行くことになる。ディスプレイの物理的なピクセル数を超えた画像を探そうとするので、ある意味では予想以上に巨大な画像を選択する可能性がある。もしCSS Pixel Ratioが可変で、解像度設定が2048 x 1280の場合は1.5に設定されるのであれば、2048px * 1.5から3072wが導かれるので、こちらのほうが効率的に思えなくもない。

この挙動がバグかというとそんなことはなくて、どうやらmacOS内部では、常に解像度の2倍で描写を行っているらしい。つまり、OSの解像度が2048 x 1280に設定されている場合、内部的には4096 x 2560の描写領域が存在しており、一度この解像度で描かれたものが、物理的な解像度である3072 x 1920にダウンスケールされているようだ。実際に、画面のスクリーンショットを撮ると、設定解像度の2倍の大きさの画像が生成される。内部的には4096pxの幅があるので、上の例で言えば、3072wよりも4096wの画像を選択するのは、理屈の上では妥当だと言える。