再來聊聊「依據背景色切換黑字或白字,確保文字明顯容易閱讀」這檔事。

過去我都用 Github Copilot 教我的186 魔術數字公式complementary = (r * 0.299 + g * 0.587 + b * 0.114) > 186 ? '#000000' : '#ffffff',但上回在 20 種差異鮮明色彩組合用這招決定文字顏色,讀者 lke 提醒,我為 #42d4f4 配了白字,看起來不太明顯。對照原文網頁範例,同一顏色配了黑字,看起來的確比白字明顯許多。這讓我開始懷疑,莫非 186 公式有缺陷?

剛好在 FB 留言看到李奎翰大大分享的重要情資:關於文字顏色對比,W3C 的 WCAG 網站內容無障礙指南其實已有明確標準 - G18: Ensuring that a contrast ratio of at least 4.5:1 exists between text (and images of text) and background behind the text,而文字與背景色對比度有兩個認證等級:(貫徹無障礙網站的難度不亞於下油鍋,這裡淺嚐就好,恕不再深入)

  • AA - 文字與背景對比值需大於 4.5:1
  • AAA - 文字與背景對比值需大於 7:1

我找到檢查對比值是否符合 WCAG 標準的網站,確認 #42d4f4 應該配黑字才是正解,配白字只對比只有 1.75:1,死當!!

G18 標準有提供完整的 RGB 值換算相對亮度(Relative Luminance)及對比值的公式,我將公式轉成 JavaScript 函式:

const calcLuminance = (hex) => {
    const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
    const colors = [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)];
    const [r, g, b] = colors.map(c => {
        const r = c / 255.0;
        if (r <= 0.03928) {
            return r / 12.92;
        } else {
            return Math.pow((r + 0.055) / 1.055, 2.4);
        }
    });
    return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
const calcContrastRatio = (hex1, hex2) => {
    let L1 = calcLuminance(hex1);
    let L2 = calcLuminance(hex2);
    if (L1 < L2) {
        [L1, L2] = [L2, L1];
    }
    return (L1 + 0.05) / (L2 + 0.05);
}

成功算出 1.7565 跟 11.9553,與檢測網站的計算結果一致。

再來便是在已知背景色時,決定該用 #000000 還是 #fffff。

依 G18 文件,若有兩種顏色,其相對亮度分別為 L1 及 L2,則二者之對比值可表示為:

(L1 + 0.05) / (L2 + 0.05)

黑字的使用時機應為「黑字與背景色的對比值」大於「白字與背景色的對比值」,黑色與白色的相對亮度分別為 0、1,若背景色之相對亮度為 L,代入上方公式可得:

(L + 0.05) / (0 + 0.05) > (1 + 0.05) / (L + 0.05)

化簡以上公式,會得到魔術數字 0.179:

  (L + 0.05) / 0.05 > 1.05 / (L + 0.05)
→ (L + 0.05)^2 / 0.05 > 1.05 
→ (L + 0.05)^2 > 1.05 * 0.05  
→ L + 0.05 > sqrt(1.05 * 0.05)
→ L > sqrt(1.05 * 0.05) - 0.05
→ L > 0.179  

寫成黑白字判斷函式:

const getTextColorWcag = (hex) =>
    calcLuminance(hex) > 0.179 ? '#000000' : '#ffffff';

最後,比對一下 186 版及 G18 版文字配色效果:線上展示

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8" />
    <script src="https://unpkg.com/vue@3"></script>
    <style>
        .palette {
            display: flex; margin-bottom: 12px;
            flex-direction: row; flex-wrap: wrap;
            width: 900px; font-size: 10pt;
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
        }

        .palette>div {
            position: relative; width: 72px; height: 150px;
            margin: 2px; padding: 6px;
        }

        .palette .chk {
            background-color: white; text-align: center;
            margin-top: 8px; margin-bottom: 8px;
        }

        .palette .chk span {
            color: red; text-decoration: line-through;
            margin-right: 4px;
        }

        .palette .chk span.pass {
            text-decoration: none; color: green; 
            font-weight: bolder;
        }
    </style>
</head>

<body>
    <div id="app">
        <div class="palette">
            <div v-for="(color,idx) in colors" :style="{ backgroundColor: color.bg }">
                <div v-for="fg in color.fgColors" :style="{ color: fg.fg }">
                    <div>{{ color.bg }}</div>
                    <div>{{ fg.ratio.toFixed(2) }}</div>
                    <div class="chk">
                        <span :class="{'pass':fg.aa}">AA</span>
                        <span :class="{'pass':fg.aaa}">AAA</span>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <script>
        const pool = ['#e6194B', '#3cb44b', '#ffe119', '#4363d8', '#f58231', '#911eb4', '#42d4f4', '#f032e6', '#bfef45', '#fabed4', '#469990', '#dcbeff', '#9A6324', '#fffac8', '#800000', '#aaffc3', '#808000', '#ffd8b1', '#000075', '#a9a9a9', '#ffffff', '#000000'];

        const getTextColor186 = (hex) => {
            const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
            return (parseInt(m[1], 16) * 0.299 + parseInt(m[2], 16) * 0.587 + parseInt(m[3], 16) * 0.114) > 186 ? '#000000' : '#ffffff';
        }

        const calcLuminance = (hex) => {
            const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
            const colors = [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)];
            const [r, g, b] = colors.map(c => {
                const r = c / 255.0;
                if (r <= 0.03928) {
                    return r / 12.92;
                } else {
                    return Math.pow((r + 0.055) / 1.055, 2.4);
                }
            });
            return 0.2126 * r + 0.7152 * g + 0.0722 * b;
        }
        const calcContrastRatio = (hex1, hex2) => {
            let L1 = calcLuminance(hex1);
            let L2 = calcLuminance(hex2);
            if (L1 < L2) {
                [L1, L2] = [L2, L1];
            }
            return (L1 + 0.05) / (L2 + 0.05);
        }
        const getTextColorWcag = (hex) =>
            calcLuminance(hex) > 0.179 ? '#000000' : '#ffffff';

        const colors = pool.map(hex =>
        ({
            bg: hex,
            fgColors:
                [getTextColor186(hex), getTextColorWcag(hex)].map(fg => {
                    const ratio = calcContrastRatio(fg, hex);
                    return {
                        fg, ratio, aa: ratio >= 4.5, aaa: ratio >= 7
                    };
                })
        }));

        const app = Vue.createApp({
            data() {
                return {
                    colors: colors
                }
            }
        });
        var vm = app.mount('#app');
    </script>

</body>

</html>

每個背景色有兩組結果,上方為 186 公式計算結果(對比值、是否符合 AA、AAA 標準),下方為 G18 公式計算結果,抓出 186 公式為草綠(Green)、橘色(Orange)、青色(Cyan)、品紅(Magenta)、青色(Teal)、橄欖(Olive)、灰(Gray)配了白色,對比值不符合 AA 標準。

而 G18 公式計算結果全部符合 AA,但有七種未達 AAA 標準,實務應用應避免以提高網頁可讀性。

Knowledge of WCAG G18 guildeline to decide correct text color for different background color.


Comments

# by Mathew Chan

有用 先收藏

# by Ike

可惜有些顏色體感還是白字比較好,例如:#e6194B (為什麼 B 是大寫???)、#469990

# by icecain

在我看起來,G18那排,有幾組配色對比反而不如186那組明顯 上1(#e6194B),上8(#f032e6),下1(#469990),下7(#808000) 現在主流瀏覽器有支援css的lch標色法 https://lea.verou.me/blog/2020/04/lch-colors-in-css-what-why-and-how/ 如果先決定好字要用黑或白,固定L(亮度)、C(彩度),改變H(色相) 是不是比較容易得到幾組視覺重量相近的色票?

# by Jeffrey

to icecain,我也有同感,有些 186 配白字的組合雖然對比值低但視覺上比黑字更清晰順眼,但這是我個人的感受,在色弱或視力障礙使用者眼中未必如此,我相信 WCAG 標準背後有科學研究支持,應該會比個人主觀感受更值得參考。

# by

當初為了產出給深度學習使用的訓練資料,使用隨機產生的背景顏色跟互補色的文字,有些結果對比很不明顯,當時不知道有這些公式可以用來計算對比,今天終於學到了

Post a comment