視認しにくいテキストを探す | 最終回 puppeteer を使って任意の web サイト上のテキストに対してコントラストレートを計算する

前回までは色差をいい感じに計算して、じゃあどれくらい近いんですか!を感覚で設定していました。
視認しにくいテキストを探す | その 2 puppeteer を使って任意の web サイト上のテキストに対して色差を計算する | ごみばこいん Blog

でもそういうのって誰かが研究した成果だったり指針が一定あるはずで、それがアクセシビリティという形でまとまっているのです。

Web Content Accessibility Guidelines (WCAG) 2.0

コントラストレート

ところで、いま Chrome の DevTools を見ると、文字色のカラーピッカーにコントラストレートという情報が増えていることに気づきました。

このコントラストレートは、アクセシビリティの話の中でも出てきます。

Web Content Accessibility Guidelines (WCAG) 2.0 - 1.4.3 Contrast (Minimum)

WebAIM: WebAIM's WCAG 2 Checklist

おおざっぱにいうと、背景と文字とのコントラスト比が 3:1 とか 4.5:1 を超えないと見にくいテキストですよね、という判断のようです。

というわけで、今回はコントラストレートを使って、見にくいテキストを探してみます。

Chrome 上の実装

ところで Chrome ではコントラストレートの計算はどのように実装されているのでしょうか。
透明度もいい感じになっているのでしょうか。
その謎を知るためにソースコードへ飛び立ちます。

Code Search

ソースの検索が提供されているので、ここで、画面上にでている文言をさがして追いかけていきます。
検索してみるとそれっぽいソースが出てきました。

https://cs.chromium.org/chromium/src/third_party/blink/renderer/devtools/front_end/color_picker/ContrastDetails.js?q=contrast+ratio+lang:%5Ejavascript$&sq=package:chromium&dr=CSs&l=5

ここからコントラストレート AA の判定を追ってみます。

https://cs.chromium.org/chromium/src/third_party/blink/renderer/devtools/front_end/color_picker/ContrastDetails.js?sq=package:chromium&dr=CSs&g=0&l=121

const aa = this._contrastInfo.contrastRatioThreshold('aa');
this._passesAA = this._contrastInfo.contrastRatio() >= aa;

this._contrastInfo.contrastRatioThreshold('aa') が AA のコントラストレート基準値を持ってきているようです。
this._contrastInfo.contrastRatio() は指定された要素のコントラストレートを計算していそうです。

まずは this._contrastInfo.contrastRatioThreshold から追ってみます。

https://cs.chromium.org/chromium/src/third_party/blink/renderer/devtools/front_end/color_picker/ContrastInfo.js?sq=package:chromium&dr=CSs&g=0&l=147

contrastRatioThreshold(level) {
  if (!this._contrastRatioThresholds)
    return null;
  return this._contrastRatioThresholds[level];
}

this._contrastRatioThresholds という配列から値を取っているようです。
level は'aa'が入ってきます。

this._contrastRatioThresholds が設定されるところを見てみます。

https://cs.chromium.org/chromium/src/third_party/blink/renderer/devtools/front_end/color_picker/ContrastInfo.js?sq=package:chromium&dr=CSs&g=0&l=32

/**
 * @param {?SDK.CSSModel.ContrastInfo} contrastInfo
 */
update(contrastInfo) {
  ...

  if (contrastInfo.computedFontSize && contrastInfo.computedFontWeight && contrastInfo.computedBodyFontSize) {
    this._isNull = false;
    const isLargeFont = ColorPicker.ContrastInfo.computeIsLargeFont(
        contrastInfo.computedFontSize, contrastInfo.computedFontWeight, contrastInfo.computedBodyFontSize);

    this._contrastRatioThresholds =
        ColorPicker.ContrastInfo._ContrastThresholds[(isLargeFont ? 'largeFont' : 'normalFont')];
  }

  ...
}

... で一部省略していますが、キモは残しています。

この update というメソッドは外から定期的に呼ばれるようです。どこから呼ばれるのかちょっとわからず。。察するに要素が更新されたり、値が更新されるときに呼ばれるものだと思います。

肝心の this._contrastRatioThresholds は ColorPicker.ContrastInfo._ContrastThresholds から取得して、
そのために isLargeFont の判定をしています。

isLargeFont の判定に必要な、引数で渡ってくる contrastInfo はSDK.CSSModel.ContrastInfo という型です。

そこを追ってみます。

https://cs.chromium.org/chromium/src/third_party/blink/renderer/devtools/front_end/sdk/CSSModel.js?sq=package:chromium&dr=CSs&g=0&l=644

/** @typedef {{backgroundColors: ?Array<string>, computedFontSize: string, computedFontWeights: string, computedBodyFontSize: string}} */
SDK.CSSModel.ContrastInfo;

んー、ちょっとよく分からないですね。
devtools の js サイドではなく、もっとネイティブ側でもっている値なのでしょうか。

ある要素に対するすべての背景色と、フォントサイズ、フォントの太さ、本文フォントサイズが入っているオブジェクトのようです。

続いて isLargeFont の計算をしている箇所です。

https://cs.chromium.org/chromium/src/third_party/blink/renderer/devtools/front_end/color_picker/ContrastInfo.js?sq=package:chromium&dr=CSs&g=0&l=159

static computeIsLargeFont(fontSize, fontWeight, bodyFontSize) {
  const boldWeights = ['bold', 'bolder', '600', '700', '800', '900'];

  const fontSizePx = parseFloat(fontSize.replace('px', ''));
  const isBold = (boldWeights.indexOf(fontWeight) !== -1);

  const fontSizePt = fontSizePx * 72 / 96;
  if (isBold)
    return fontSizePt >= 14;
  else
    return fontSizePt >= 18;
}

先程の SDK.CSSModelContrastInfo から渡されるフォントサイズと太さの情報を使って、大きいフォント判定をします。
これはアクセシビリティのサイトに書いてあった通りのものです。

Web Content Accessibility Guidelines (WCAG) 2.0 - large scale (text)

そして ColorPicker.ContrastInfo._ContrastThresholds です。

https://cs.chromium.org/chromium/src/third_party/blink/renderer/devtools/front_end/color_picker/ContrastInfo.js?sq=package:chromium&dr=CSs&g=0&l=178

ColorPicker.ContrastInfo._ContrastThresholds = {
  largeFont: {aa: 3.0, aaa: 4.5},
  normalFont: {aa: 4.5, aaa: 7.0}
};

ラージフォントか通常かによって aa と aaa の基準値が設定されています。
こちらもアクセシビリティのサイトに書いてあったものです。

> The visual presentation of text and images of text has a contrast ratio of at least 4.5:1 except for the following: (Level AA)
> Large Text: Large-scale text and images of large-scale text have a contrast ratio of at least 3:1;

 

ここまでスレッショルドの取得は追いかけ終わりました。

次に this._contrastInfo.contrastRatio() を追います。

https://cs.chromium.org/chromium/src/third_party/blink/renderer/devtools/front_end/color_picker/ContrastInfo.js?sq=package:chromium&dr=CSs&g=0&l=81

contrastRatio() {
  return this._contrastRatio;
}

メソッドは単に getter になっているだけですね。

https://cs.chromium.org/chromium/src/third_party/blink/renderer/devtools/front_end/color_picker/ContrastInfo.js?sq=package:chromium&dr=CSs&g=0&l=140

this._contrastRatio = Common.Color.calculateContrastRatio(this._fgColor.rgba(), this._bgColor.rgba());

計算された値が設定されていそうです。
_fgColor と_bgColor は要素の color と background-color に当たるものでしょうか。

https://cs.chromium.org/chromium/src/third_party/blink/renderer/devtools/front_end/color_picker/ContrastInfo.js?sq=package:chromium&dr=CSs&g=0&l=12

/** @type {?Common.Color} */
this._fgColor = null;

/** @type {?Common.Color} */
this._bgColor = null;

コントラストレート計算の処理を追ってみます。

https://cs.chromium.org/chromium/src/third_party/blink/renderer/devtools/front_end/common/Color.js?dr=CSs&q=Common.Color&sq=package:chromium&g=0&l=364

/**
 * Calculate the contrast ratio between a foreground and a background color.
 * Returns the ratio to 1, for example for two two colors with a contrast ratio of 21:1, this function will return 21.
 * See http://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
 * @param {!Array<number>} fgRGBA
 * @param {!Array<number>} bgRGBA
 * @return {number}
 */
static calculateContrastRatio(fgRGBA, bgRGBA) {
  Common.Color.blendColors(fgRGBA, bgRGBA, Common.Color.calculateContrastRatio._blendedFg);

  const fgLuminance = Common.Color.luminance(Common.Color.calculateContrastRatio._blendedFg);
  const bgLuminance = Common.Color.luminance(bgRGBA);
  const contrastRatio = (Math.max(fgLuminance, bgLuminance) + 0.05) / (Math.min(fgLuminance, bgLuminance) + 0.05);

  for (let i = 0; i < Common.Color.calculateContrastRatio._blendedFg.length; i++)
    Common.Color.calculateContrastRatio._blendedFg[i] = 0;

  return contrastRatio;
}

重たいロジックが出てきましたが、見るとリンクが付いています。
それを実装しているだけのようです。

Web Content Accessibility Guidelines (WCAG) 2.0 - contrast ratio

というところで、ここから先は追わなくてもいいかなと思ったので止めておきます。
ロジックのざっくりとした理解では、背景色と文字色をブレンドして、明るさを計算して、いい感じの係数を合わせたら出来上がり!という具合でしょうか。

 

ここまでで AA の判定をすることができます。
大きくやっていることは 2 つで、コントラストレートのしきい値を計算することと、実際のコントラストレートを計算すること、です。

実装してみる

該当のソースは devtool の中にいるので puppeteer からウマいこと利用する手立てはないと思います。
ので、前回までと同様に、開いたページに対してスクリプトを実行する形でやってみます。

前回もお世話になった chroma を使うと2つの色に対してコントラストレートを計算することができます。

chroma.js api docs! - chroma.contrast(color1 color2)

でもってソースはこんな感じで try puppeteer してみます。
(前回とほぼ同じソース)

// ブラウザの起動
const browser = await puppeteer.launch();
const page = await browser.newPage();
page.setViewport({
    width: 1600,
    height: 900,
})

// デバッグ用に console.log を nodejs 側に渡す
page.on('console', msg => console.log(msg.text()));

// サイトにアクセスする
await page.goto('https://gomiba.co.in/test/color_difference.html');

// 色差を計算するために chroma をページ内で読み込む
await page.addScriptTag({
    url: 'https://cdnjs.cloudflare.com/ajax/libs/chroma-js/1.3.6/chroma.min.js',
});

// 色差が一定以上のものを探す
await page.evaluate(() => {
    // 全要素を探索していく
    document.querySelectorAll('*').forEach((element) => {
        // テキストノード、または SVG を子に持っている要素を探す
        const foundChildNode = Array.prototype.filter.call(element.childNodes, (e) => {
            let status = false;
            status = status || (e.nodeType === Node.TEXT_NODE && e.textContent.trim().length > 0);
            status = status || e.nodeName.toLowerCase() === 'svg';
            return status;
        });
        if (foundChildNode.length === 0) {
            return;
        }

        // 計算されたスタイルから色を取得
        const elementStyle = window.getComputedStyle(element);
        const fontColor = elementStyle.color;
        const backgroundColor = elementStyle.backgroundColor;

        // コントラストレートを計算する
        const contrastRatio = chroma.contrast(fontColor, backgroundColor);

        // フォントサイズからコントラストレートのしきい値を決める
        const fontSizePx = parseInt(elementStyle.fontSize);
        const fontSizePt = fontSizePx * 72 / 96;
        const isLargeFont = fontSizePt >= 18;
        const contrastRatioThreshold =
              isLargeFont ? 3.0 : 4.5
      
        // しきい値を超えたもの = 見やすいものを色付けする
        console.log(element.nodeName + " : " + contrastRatio)
        if (contrastRatio > contrastRatioThreshold) {
          element.style.cssText = element.style.cssText + 'border: 5px dashed red !important;';
        }
    });
});


// スクリーンショットを撮ってみる
await page.screenshot({path: 'example.png'});

// ブラウザを終了する
await browser.close();

isLargeFont のところで太字判定をサボっていたりしますが、とりあえずのところは。
前回の記事では見にくい!を出していましたが、わからんので逆にして、見やすいものだけをマークするようにしました。

これを試すとこんな感じになります。

が、これがどうなのかわからない…、ので、前回の結果と見比べられるようにしました。

要素の左側に点線があるのが今回、要素の右側に点線があるのが前回の検出した見やすいものです。

こうしてみると、前回のときにチョット見やすいとはいいにくいなあというものも今回のでいい感じに検出されているような気がします。
とはいえ、前回の方法でもしきい値がかなりガバガバなので、もうちょっと詰めていくと、良い結果がでるんじゃないかなあと。

 

とりあえず、コントラストレートを計算してあげるのがアクセシビリティの確認方法として挙げられているので
手動でいい感じに検出するなら今回の方法でやるほうがよさそうです。

その他

lighthouse を使うとアクセシビリティ以外にも様々な観点について自動チェックできておすすめです。

GoogleChrome/lighthouse: Auditing performance metrics and best practices for Progressive Web Apps

今回はとりあえず色の様子だけシュッと見たかったので、単品で調査する方法がないかなあと取り組んでみたものです。

前後の記事

Next:
Prev: