检查网页文字颜色时,工具显示对比度为 4.50,我们很容易把它当作“已经达到 4.5:1”。但显示值通常经过了格式化,原始结果可能只有 4.499795。文字对比度应该怎样计算,为什么不能先四舍五入再判断,又怎样避免一张通过的配色表掩盖页面上的问题?

本文把这个问题拆成可复现的计算与实际页面的检查边界。规则依据来自 W3C 的 WCAG 2.2 及配套说明;数值来自我在本机执行的脚本;修复流程是基于这些结果提出的建议。实验没有测量人的阅读表现,也没有对谷米或其他网站做完整无障碍审计。

1. 先确定正在判断哪一种文字

WCAG 2.2 的成功准则 1.4.3 属于 AA 级别,一般文字与其背景的对比度至少为 4.5:1,大号文字至少为 3:1。这里讨论的是这项准则的颜色要求;标志、纯装饰等情况另有例外,不能把数字当成所有视觉元素统一适用的门槛。WCAG 2.2:Contrast (Minimum)

“大号”也不能只凭组件叫作标题来判断。规范定义涉及至少 18pt 的文字、至少 14pt 的粗体,或中日韩字体中产生等效大小的字号。具体页面需要核对实际字体与尺寸,不能看到 h1 就自动选择较低门槛,也不能把用户放大后的状态当成原始大号文字。WCAG 2.2:large-scale 定义

因此,计算器可以同时给出是否达到 4.5 和 3,却无法仅凭两种颜色决定页面应采用哪一档。下文的“通过”只表示数值达到指定阈值。网站宣称符合 AA,还涉及整页及其他成功准则;一个颜色判断不具备那种范围。W3C:符合性要求说明

2. 对比度比较的是相对亮度

本文的输入限于不透明的六位十六进制 sRGB 颜色,例如 #767676。先把每个八位通道除以 255,再按分段函数转换到线性值;随后按红、绿、蓝的权重求相对亮度。最后,用较亮一方的亮度加 0.05,除以较暗一方的亮度加 0.05。W3C 的 G18 技术文档给出了这套计算步骤。W3C:G18 的计算过程

先做线性转换的理由可以从数值看出来:sRGB 通道是编码值,不能直接当作线性亮度。脚本算得 #808080 的通道归一化值约为 0.501961,而相对亮度约为 0.215861。拿前一个量冒充后一个量,就已改变了要计算的东西,后面即使除法写对也无法补救。

下面是完整脚本中的计算函数。输入验证刻意拒绝透明色、简写和其他色彩空间,避免悄悄把不支持的输入算成一个貌似精确的数字:

import re

def parse_hex(color):
    if not isinstance(color, str) or re.fullmatch(r"#[0-9a-fA-F]{6}", color) is None:
        raise ValueError("expected one opaque sRGB color in #RRGGBB format")
    return tuple(int(color[index:index + 2], 16) for index in (1, 3, 5))

def linear_channel(channel):
    value = channel / 255
    return value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4

def luminance(color):
    red, green, blue = (linear_channel(channel) for channel in parse_hex(color))
    return 0.2126 * red + 0.7152 * green + 0.0722 * blue

def contrast_ratio(foreground, background):
    darker, lighter = sorted((luminance(foreground), luminance(background)))
    return (lighter + 0.05) / (darker + 0.05)

def meets_threshold(raw_ratio, threshold=4.5):
    return raw_ratio >= threshold

交换前景与背景不会改变这个公式的比值,相同颜色得到 1:1,黑白两端得到 21:1。这些是脚本实际检查的性质。不过,这个数值对称性不表示浅底深字与深底浅字在所有阅读场景里有相同体验;实验只验证了公式。

3. 八组实际配色,把结果放在同一张表里

我在 2026 年 10 月 5 日使用 macOS、Python 3.9.6 执行了完整实验脚本,只使用 Python 标准库。下载脚本后,在同一目录运行:

python3 text-contrast-lab.py --json text-contrast-results.json --svg text-contrast-samples.svg

实际结果 JSON保留原始浮点结果、两位小数的展示字符串,以及分别比较 4.5 和 3 后的布尔值。下表的背景全部为 #FFFFFF,比值为便于阅读显示六位小数,判断使用未格式化的计算结果。

前景颜色对比度展示值达到 4.5达到 3
#00000021.000000:1是是
#7676764.542225:1是是
#7777774.478089:1否是
#7079724.499795:1否是
#8888883.544886:1否是
#FF00003.998477:1否是
#00FF001.372190:1否否
#0000FF8.592471:1是是

白色背景上的六组文字色值、对比度与 4.5 阈值判断;接近的灰色仍可能分属两侧

图中英文 PASS 表示原始结果达到 4.5,FAIL 表示没有达到。图是颜色样例,不是字体渲染的测量;完整数值以上表与 JSON 为准。也可以单独下载样例 SVG。

灰色 #767676 与 #777777 的每个通道只差一个整数,判断却分处阈值两侧。规则必须有一个可检验的界限,因此边界附近会出现这种情况。它不证明前一种配色突然让所有人轻松阅读,也不证明后一种让所有人完全看不见。把最低要求理解成一道数值门槛,可以避免给它加上实验未支持的人体效果。

4. 展示 4.50,与原始值达到 4.5 是两件事

#707972 的实际比值为 4.49979546274635。格式化为两位小数后是字符串 "4.50";如果把这个字符串再转为数字后判断,就会得到通过。W3C 的配套说明明确要求比较计算值时不要舍入,举例说明 4.499:1 不达到 4.5:1。W3C:阈值与舍入说明

一种直接的实现是把计算、判断与展示分开:先用原始值决定是否通过,再把格式化字符串交给界面。报告同时保留颜色对、适用门槛和判断结果。若只有两位小数,界面可以显示“4.50,低于门槛”,或者在临界区展示更多小数;不要让读者只能根据短字符串反推判断。

浮点计算也不能成为随意放宽门槛的理由。给条件加上一个宽松容差,可能把确实小于 4.5 的颜色也算成通过。本案例与阈值的差约为 0.000205,远大于这个数量级上的双精度最后一位差别。脚本另外检查了恰好 4.5、4.499,以及浮点数中紧邻 4.5 的较小值,三者按原始比较分别通过、失败、失败。

对于设计选择,可以主动留出余量,而不必把配色调整到刚好贴着门槛。比如把内部目标提高到 5:1,是一种项目自己的设计建议,不是本文声称的 WCAG 强制要求。它可以减少换背景或修改样式后立即跌破门槛的机会,但也不能替代对新状态重新计算。

5. RGB 平均值为什么也会误导

纯红、纯绿、纯蓝的三个通道算术平均值都为 85,却在白底上得到约 4.00、1.37、8.59 的不同比值。平均值抹掉了各通道在相对亮度计算中的不同权重。只看色相差异、饱和度或三通道的平均,也不能得到上述公式的结论。

另一种错误是采用了加权,却跳过线性转换。前面的 #808080 若直接把 0.501961 当作亮度,与白底算出的比值约为 1.902309;正确过程得到约 3.949440。两个数差得很大,原因是输入量用错了。查计算器实现时,应检查整个转换过程,不能只搜索最后有没有出现 0.05。

实际运行除了八组配色断言,还通过了十五项边界检查:准确阈值及其下方、相同颜色、黑白极值、前后景互换、分段转换两支、全部 256 级灰度的单调性、小写输入,以及错误格式、透明色和 Display P3 输入的拒绝等。另有舍入误判、RGB 均值和直接使用编码值的反例。断言在正常 Python 模式下执行;不要用会关闭断言的 -O 来复现实验。

6. 颜色算对了,还要找对页面上的背景

纯色函数只知道你传入的两种颜色。若文字实际放在浅灰卡片上,却拿白色页面背景计算,就会检查错对象。开始修复时,应记录具体文字、它紧邻的背景、字体条件和所在状态,再决定使用哪一档门槛。所谓“这个品牌色已经通过”,应追问是在哪个背景上、用于什么文字。

最简单的反例就是白字:在黑底上比值为 21,换到白色卡片上就变成 1。脚本已经检查了这两个端点。即使文字色完全没改,容器背景变化也足以让原来的通过记录失效。因此,组件移动位置或调整层级时,也应重新确认它使用的背景组合。

透明度、渐变和图片会扩大输入问题。透明文字的最终颜色取决于底下的内容,祖先元素的透明度还可能影响整体;一个渐变背景也没有唯一的背景色。CSS Color 4:整体透明度 脚本明确不做这些合成与 CSS 解析。遇到它们,要在能正确处理实际页面的工具中核对,或为文字提供受控的纯色底板,再检查该底板上的颜色对。直接删掉八位颜色的透明通道后输入脚本,会把问题改成另一个问题。

还要检查状态与主题。正文颜色通过,并不能保证悬停链接、错误提示或深色模式中覆盖后的文字也通过。可以建立按用途命名的前景与背景组合,并为每个实际使用的组合记录结果;换主题时一起更新组合。若只把某个十六进制颜色列入“安全色”,复用到不同背景时仍会失去依据。

字形本身也需要看。W3C 的说明建议按样式或用户代理给出的前景背景评估该准则,同时提醒抗锯齿和细字形可能让名义上通过的文字显得更淡。W3C:字体渲染的注意事项 所以通过公式后,仍要用实际字号和字体检查阅读与布局。本文没有执行这些页面集成检查,不能把实验输出标成浏览器审计结果。

7. 给出可追查的判断,而不只给一个小数

开头的问题可以明确回答:4.50 可能只是展示结果;满足 4.5:1 必须由未舍入的计算值判断。计算时先处理 sRGB 编码,再求相对亮度与比值,随后根据文字条件选择门槛。最后确认输入对应实际页面,而不是设计稿里另外一块背景。

一个有用的检查记录至少应包含前景、背景、原始比值、展示值、门槛和判断,并注明是否只做了不透明纯色计算。这样,同事既能复现结果,也能知道仍需补查哪里。最低门槛提供了可检验的共同依据;清楚保存计算过程和适用范围,才让这个依据在页面修改后仍然有用。

若把检查接入自动构建,可以从项目实际采用的颜色对建立一份清单,让计算过程报告是哪一组、在哪个主题下失败。颜色名称、背景名称或门槛改动时,都应在评审里展示差异。这个流程只是实现建议,本文没有把脚本接入任何项目的持续集成;动态状态和真实字体仍要另行检查,不能因为清单全部通过就把后续工作省掉。