文字を読みやすく！　コントラスト比を計算して文字色を自動選択

# コントラスト比

文字の読みやすさは、文字色と背景色のコントラスト比 (contrast ratio) に大きく左右されます。

<font color="#eee">**コントラスト比が小さいと、下の文のように読みにくくなってしまいます。**</font>
<font color="#222">**コントラスト比が小さいと、上の文のように読みにくくなってしまいます。**</font>

コントラスト比は、ざっくり言うと色の明るさの比です。
コントラスト比が小さい状態は、明るい背景に明るい文字を置いたり、暗い背景に暗い文字を置いたりしている状態であり、文字は読みにくいです。
文字を読みやすくするため、コントラスト比はある程度大きくあるべきです（隠したい要素でない限り）。

コントラスト比は 1 以上 21 以下の値であり、大きいほど明暗の差がはっきりしています。
だいたいの基準として、文字を読むには 4.5 以上はあったほうがよいです。
4.5 未満だと Chrome の開発者ツールに怒られます。

![Chrome の開発者ツールの表示。コントラスト比が 3.23 であるので警告が出ている。](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643544/fe0663c3-621a-5fe7-ac5a-ad044e9d89f3.png)

コントラスト比の具体的な計算方法は [Web Content Accessibility Guidelines (WCAG) 2.2](https://www.w3.org/TR/WCAG22/) に掲載されています。

> ***contrast ratio***
> (L1 + 0.05) / (L2 + 0.05), where
> - L1 is the [relative luminance](https://www.w3.org/TR/WCAG22/#dfn-relative-luminance) of the lighter of the colors, and
> - L2 is the [relative luminance](https://www.w3.org/TR/WCAG22/#dfn-relative-luminance) of the darker of the colors.
>
> *https://www.w3.org/TR/WCAG22/#dfn-contrast-ratio*

ここに出てくる相対輝度 (relative luminance) は、最も暗い黒を 0 、最も明るい白を 1 に正規化した、色空間内での相対的な明るさのことのようです。
ある色をリニアRGBで表したときの各要素を0から1に正規化した値をそれぞれ R, G, B とするとき、その相対輝度 L は
L = 0.2126 * R + 0.7152 * G + 0.0722 * B
で求まります。

[Siv3D](https://siv3d.github.io/ja-jp/) では、相対輝度、コントラスト比を得る関数は次のように書けます。

```cpp
[[nodiscard]]
double GetRelativeLuminance(const ColorF &color) noexcept
{
	const auto [r, g, b, a] = color.removeSRGBCurve();
	return (0.2126 * r + 0.7152 * g + 0.0722 * b);
}

[[nodiscard]]
double GetContrastRatio(const ColorF& color1, const ColorF& color2) noexcept
{
	const auto l1 = GetRelativeLuminance(color1);
	const auto l2 = GetRelativeLuminance(color2);
	return ((Max(l1, l2) + 0.05) / (Min(l1, l2) + 0.05));
}
```

# 背景色に合わせた文字色の自動選択

文字のある画面でユーザが背景色を変更できるとき、文字色もそれに合わせて読みやすい色に変更する必要があります。
ライトテーマとダークテーマを用意するぐらいであればそれぞれに適切な文字色を設定しておけばよいですが、何十通りにも増えると面倒だし、完全に自由に設定できる場合は不可能です。

そこで、コントラスト比を用いて文字色を選択します。
じつは、文字色を `#000000` と `#FFFFFF` の2色から選ぶだけでも、背景色とのコントラスト比が 4.5 を下回らないようにできます。

<details>
<summary>おまけ：コントラスト比が 4.5 を下回らないようにできることの証明</summary>

相対輝度が $L$ である色を考える。
その色と `#000000` 、`#FFFFFF` のコントラスト比をそれぞれ $C_\text{Black},C_\text{White}$ とすると、それらはそれぞれ次のようになる。

```math
\begin{align*}
C_\text{Black} &= \frac{L+0.05}{0.05}, & C_\text{White} &= \frac{1.05}{L+0.05}
\end{align*}
```

ここで、コントラスト比が大きいほうを背景色として選択するようにする。
このとき、コントラスト比 $C$ は $C=\max(C_\text{Black},C_\text{White})$ である。

$C$ が最小値 $C_\text{min}$ をとるとき、 $C_\text{Black}=C_\text{White}$ が成り立つから、

```math
\begin{align*}
C_\text{min} &= \sqrt{C_\text{Black}C_\text{White}}
= \sqrt{\frac{1.05}{0.05}}
= \sqrt{21}
\end{align*}
```

$C_\text{min} = \sqrt{21} > \sqrt{20.25} = 4.5$ である。（証明終わり）


</details>

```cpp
# include <Siv3D.hpp> // Siv3D v0.6.15

// `color` の相対輝度を返します。
// https://www.w3.org/TR/WCAG22/#dfn-relative-luminance
[[nodiscard]]
double GetRelativeLuminance(const ColorF &color) noexcept
{
	const auto [r, g, b, a] = color.removeSRGBCurve();
	return (0.2126 * r + 0.7152 * g + 0.0722 * b);
}

// `color1` と `color2` のコントラスト比を返します。
// https://www.w3.org/TR/WCAG22/#dfn-contrast-ratio
[[nodiscard]]
double GetContrastRatio(const ColorF& color1, const ColorF& color2) noexcept
{
	const auto l1 = GetRelativeLuminance(color1);
	const auto l2 = GetRelativeLuminance(color2);
	return ((Max(l1, l2) + 0.05) / (Min(l1, l2) + 0.05));
}

void Main()
{
	const Font fontLight{ FontMethod::MSDF, 48, Typeface::Light };
	const Font fontBold{ FontMethod::MSDF, 48, Typeface::Bold };

	HSV backgroundColor{ 210, 0.2, 1.0 };
	Scene::SetBackground(backgroundColor);
	
	while (System::Update())
	{
		SimpleGUI::Headline(U"Background", Vec2{ 20, 20 }, 160);
		if (SimpleGUI::ColorPicker(backgroundColor, Vec2{ 20, 60 }))
		{
			Scene::SetBackground(backgroundColor);
		}

		// コントラスト比を比較して白か黒か選択する
		const auto contrastBlack = GetContrastRatio(backgroundColor, Palette::Black);
		const auto contrastWhite = GetContrastRatio(backgroundColor, Palette::White);
		const auto foregroundColor = ((contrastBlack < contrastWhite) ? Palette::White : Palette::Black);

		fontBold(U"Hello, Siv3D!").drawAt(96, Scene::CenterF(), foregroundColor);

		const auto contrast = Max(contrastBlack, contrastWhite);
		const auto luminanceForeground = GetRelativeLuminance(foregroundColor);
		const auto luminanceBackground = GetRelativeLuminance(backgroundColor);
		const auto debugText =
			U"Contrast Ratio: {:.3f}\n"
			U"Luminance (fore): {}\n"
			U"Luminance (back): {:.3f}\n"_fmt(contrast, luminanceForeground, luminanceBackground);
		fontLight(debugText).drawAt(24, Scene::CenterF().movedBy(0, 150), foregroundColor);
	}
}
```

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643544/e335f5e0-3975-711c-ecfd-40d76e461d7d.png">
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643544/6c4e0414-8617-9941-96be-ee5e6378ad12.png">

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643544/8e065b4b-2e96-aded-7351-520fb845963e.png">
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643544/813ed639-9efb-3cec-cd33-5dbe1f967eae.png">

背景色によって文字色が自動的に切り替わり、常に見やすい状態を維持します。

# 読みやすくする別の方法：枠をつける

元も子もないですが、文字にコントラスト比の大きい枠を付ければ、文字色を変えずに読みやすさを保てます。

```cpp
# include <Siv3D.hpp> // Siv3D v0.6.15

void Main()
{
	const Font fontBold{ FontMethod::MSDF, 48, Typeface::Bold };
	fontBold.setBufferThickness(3);

	HSV backgroundColor{ 210, 0.2, 1.0 };
	Scene::SetBackground(backgroundColor);
	
	while (System::Update())
	{
		SimpleGUI::Headline(U"Background", Vec2{ 20, 20 }, 160);
		if (SimpleGUI::ColorPicker(backgroundColor, Vec2{ 20, 60 }))
		{
			Scene::SetBackground(backgroundColor);
		}

		fontBold(U"Hello, Siv3D!").drawAt(TextStyle::Outline(0.2, Palette::Black), 96, Scene::CenterF(), Palette::White);
	}
}
```

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643544/f7aec1e2-9cdc-0324-e4c8-f455e4f81cd4.png">
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643544/c61717f3-b020-6ee4-b4e0-f7c18c60ee35.png">

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643544/b9f1cb32-2237-c896-1f5c-b49a36a9323c.png">
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643544/4abe856d-4853-e66f-a01a-48b1df7c3c1e.png">

こっちの方が簡単でいいですね。

# 参考サイト

https://zenn.dev/yend724/articles/20240202-85u5hfjvgp5irtjd

https://www.w3.org/TR/WCAG22/