浅色文本与背景图片的完美对比度

Avatar of Yaphi Berhanu
Yaphi Berhanu 发布

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

您是否曾经遇到过网站上浅色文本放置在浅色背景图片上的情况?如果您遇到过,就会知道这有多难阅读。避免这种情况的一种常用方法是使用 透明叠加层。但这引发了一个重要的问题:叠加层的透明度究竟应该有多高?我们并不总是处理相同的字体大小、粗细和颜色,而且不同的图片会导致不同的对比度。

尝试消除背景图片上糟糕的文本对比度就像玩打地鼠游戏一样。与其猜测,我们可以使用 HTML <canvas> 和一些数学运算来解决这个问题。

像这样

我们可以说“问题解决了!”,然后就此结束本文。但那样就没有意思了。我想向您展示的是这个工具是如何工作的,这样您就有了处理这种过于常见问题的全新方法。

计划

首先,让我们明确我们的目标。我们说过想要在背景图片上显示可读的文本,但“可读”究竟意味着什么?就我们的目的而言,我们将使用 WCAG 对 AA 级别可读性的定义,该定义指出文本和背景颜色之间需要有足够的对比度,使得一种颜色比另一种颜色亮 4.5 倍。

让我们选择一种文本颜色、一张背景图片和一种叠加层颜色作为起点。给定这些输入,我们想要找到使文本可读的叠加层不透明度级别,同时又不至于过度隐藏图片使其难以查看。为了使问题稍微复杂化一些,我们将使用一张包含深色和浅色区域的图片,并确保叠加层考虑到了这一点。

我们的最终结果将是一个值,我们可以将其应用于叠加层的 CSS opacity 属性,该值将为我们提供合适的透明度,使文本比背景亮 4.5 倍。

最佳叠加层不透明度:0.521

为了找到最佳叠加层不透明度,我们将分四个步骤进行

  1. 我们将把图片放在 HTML <canvas> 中,这将使我们能够读取图片中每个像素的颜色。
  2. 我们将找到图片中与文本对比度最小的像素。
  3. 接下来,我们将准备一个颜色混合公式,我们可以使用它来测试不同不透明度级别对该像素颜色的影响。
  4. 最后,我们将调整叠加层的不透明度,直到文本对比度达到可读性目标。这些不会是随机猜测——我们将使用二分搜索技术来加快此过程。

让我们开始吧!

步骤 1:从画布读取图片颜色

画布使我们能够“读取”图片中包含的颜色。为此,我们需要将图片“绘制”到 <canvas> 元素上,然后使用画布上下文 (ctx) 的 getImageData() 方法生成图片颜色的列表。

function getImagePixelColorsUsingCanvas(image, canvas) {
  // The canvas's context (often abbreviated as ctx) is an object
  // that contains a bunch of functions to control your canvas
  const ctx = canvas.getContext('2d');


  // The width can be anything, so I picked 500 because it's large
  // enough to catch details but small enough to keep the
  // calculations quick.
  canvas.width = 500;


  // Make sure the canvas matches proportions of our image
  canvas.height = (image.height / image.width) * canvas.width;


  // Grab the image and canvas measurements so we can use them in the next step
  const sourceImageCoordinates = [0, 0, image.width, image.height];
  const destinationCanvasCoordinates = [0, 0, canvas.width, canvas.height];


  // Canvas's drawImage() works by mapping our image's measurements onto
  // the canvas where we want to draw it
  ctx.drawImage(
    image,
    ...sourceImageCoordinates,
    ...destinationCanvasCoordinates
  );


  // Remember that getImageData only works for same-origin or 
  // cross-origin-enabled images.
  // https://mdn.org.cn/en-US/docs/Web/HTML/CORS_enabled_image
  const imagePixelColors = ctx.getImageData(...destinationCanvasCoordinates);
  return imagePixelColors;
}

getImageData() 方法为我们提供了一个表示每个像素颜色的数字列表。每个像素由四个数字表示:红色、绿色、蓝色和不透明度(也称为“alpha”)。了解这一点后,我们就可以遍历像素列表并找到我们需要的信息。这在下一步中将非常有用。

Image of a blue and purple rose on a light pink background. A section of the rose is magnified to reveal the RGBA values of a specific pixel.

步骤 2:找到对比度最小的像素

在执行此操作之前,我们需要知道如何计算对比度。我们将编写一个名为 getContrast() 的函数,该函数接收两种颜色并输出一个表示这两种颜色之间对比度级别的数字。数字越高,对比度对可读性的影响就越好。

当我开始为这个项目研究颜色时,我以为会找到一个简单的公式。结果发现有多个步骤。

要计算两种颜色之间的对比度,我们需要知道它们的亮度级别,这本质上就是亮度(Stacie Arellano 对亮度进行了深入的 探讨,值得一读)。

感谢 W3C,我们知道 计算使用亮度计算对比度的公式

const contrast = (lighterColorLuminance + 0.05) / (darkerColorLuminance + 0.05);

获取颜色的亮度意味着我们必须将颜色从网络上使用的常规 8 位 RGB 值(其中每种颜色为 0-255)转换为所谓的线性 RGB。我们需要执行此操作的原因是,亮度不会随着颜色的变化而均匀增加。我们需要将颜色转换为亮度随颜色变化而均匀变化的格式。这样我们就可以正确地计算亮度。同样,W3C 在这里提供了帮助

const luminance = (0.2126 * getLinearRGB(r) + 0.7152 * getLinearRGB(g) + 0.0722 * getLinearRGB(b));

但是,等等,还有更多!为了将 8 位 RGB(0 到 255)转换为线性 RGB,我们需要经历所谓的标准 RGB(也称为 sRGB),其范围为 0 到 1。

所以流程如下:

8-bit RGB → standard RGB  → linear RGB → luminance

一旦我们获得了想要比较的两种颜色的亮度,我们就可以将亮度值代入公式以获得它们各自颜色之间的对比度。

// getContrast is the only function we need to interact with directly.
// The rest of the functions are intermediate helper steps.
function getContrast(color1, color2) {
  const color1_luminance = getLuminance(color1);
  const color2_luminance = getLuminance(color2);
  const lighterColorLuminance = Math.max(color1_luminance, color2_luminance);
  const darkerColorLuminance = Math.min(color1_luminance, color2_luminance);
  const contrast = (lighterColorLuminance + 0.05) / (darkerColorLuminance + 0.05);
  return contrast;
}


function getLuminance({r,g,b}) {
  return (0.2126 * getLinearRGB(r) + 0.7152 * getLinearRGB(g) + 0.0722 * getLinearRGB(b));
}
function getLinearRGB(primaryColor_8bit) {
  // First convert from 8-bit rbg (0-255) to standard RGB (0-1)
  const primaryColor_sRGB = convert_8bit_RGB_to_standard_RGB(primaryColor_8bit);


  // Then convert from sRGB to linear RGB so we can use it to calculate luminance
  const primaryColor_RGB_linear = convert_standard_RGB_to_linear_RGB(primaryColor_sRGB);
  return primaryColor_RGB_linear;
}
function convert_8bit_RGB_to_standard_RGB(primaryColor_8bit) {
  return primaryColor_8bit / 255;
}
function convert_standard_RGB_to_linear_RGB(primaryColor_sRGB) {
  const primaryColor_linear = primaryColor_sRGB < 0.03928 ?
    primaryColor_sRGB/12.92 :
    Math.pow((primaryColor_sRGB + 0.055) / 1.055, 2.4);
  return primaryColor_linear;
}

现在我们可以计算对比度了,我们需要查看上一步中的图片并遍历每个像素,比较该像素的颜色与前景文本颜色的对比度。在遍历图片像素时,我们将跟踪到目前为止最差(最低)的对比度,当我们到达循环末尾时,我们将知道图片中对比度最差的颜色。

function getWorstContrastColorInImage(textColor, imagePixelColors) {
  let worstContrastColorInImage;
  let worstContrast = Infinity; // This guarantees we won't start too low
  for (let i = 0; i < imagePixelColors.data.length; i += 4) {
    let pixelColor = {
      r: imagePixelColors.data[i],
      g: imagePixelColors.data[i + 1],
      b: imagePixelColors.data[i + 2],
    };
    let contrast = getContrast(textColor, pixelColor);
    if(contrast < worstContrast) {
      worstContrast = contrast;
      worstContrastColorInImage = pixelColor;
    }
  }
  return worstContrastColorInImage;
}

步骤 3:准备颜色混合公式以测试叠加层不透明度级别

现在我们知道了图片中对比度最差的颜色,下一步是确定叠加层的透明度应该如何,以及这将如何改变与文本的对比度。

当我第一次实现这一点时,我使用了单独的画布来混合颜色并读取结果。但是,感谢 Ana Tudor 关于透明度的 文章,我现在知道有一个方便的公式可以计算将基础颜色与透明叠加层混合后的结果颜色。

对于每个颜色通道(红色、绿色和蓝色),我们将应用此公式来获取混合后的颜色

mixedColor = baseColor + (overlayColor - baseColor) * overlayOpacity

因此,在代码中,这将如下所示

function mixColors(baseColor, overlayColor, overlayOpacity) {
  const mixedColor = {
    r: baseColor.r + (overlayColor.r - baseColor.r) * overlayOpacity,
    g: baseColor.g + (overlayColor.g - baseColor.g) * overlayOpacity,
    b: baseColor.b + (overlayColor.b - baseColor.b) * overlayOpacity,
  }
  return mixedColor;
}

现在我们可以混合颜色了,我们可以测试应用叠加层不透明度值时的对比度。

function getTextContrastWithImagePlusOverlay({textColor, overlayColor, imagePixelColor, overlayOpacity}) {
  const colorOfImagePixelPlusOverlay = mixColors(imagePixelColor, overlayColor, overlayOpacity);
  const contrast = getContrast(textColor, colorOfImagePixelPlusOverlay);
  return contrast;
}

有了这些,我们就具备了找到最佳叠加层不透明度所需的所有工具!

步骤 4:找到达到对比度目标的叠加层不透明度

我们可以测试叠加层的不透明度,并查看这将如何影响文本和图片之间的对比度。我们将尝试许多不同的不透明度级别,直到找到达到目标对比度的值,即文本比背景亮 4.5 倍。这听起来可能很疯狂,但别担心;我们不会随机猜测。我们将使用二分搜索,这是一个允许我们快速缩小可能答案集的过程,直到获得精确的结果。

以下是二分搜索的工作原理

  • 在中间进行猜测。
  • 如果猜测过高,我们将消除答案的上半部分。过低?我们将消除下半部分。
  • 在新范围内中间进行猜测。
  • 重复此过程,直到我们获得一个值。

我碰巧有一个工具可以展示其工作原理

在本例中,我们试图猜测一个介于 0 和 1 之间的不透明度值。因此,我们将进行中间猜测,测试结果对比度是过高还是过低,消除一半的选项,然后再次猜测。如果我们将二分搜索限制为八次猜测,我们就可以立即获得精确的答案。

在开始搜索之前,我们需要一种方法来检查是否首先需要叠加层。我们没有必要优化我们根本不需要的叠加层!

function isOverlayNecessary(textColor, worstContrastColorInImage, desiredContrast) {
  const contrastWithoutOverlay = getContrast(textColor, worstContrastColorInImage);
  return contrastWithoutOverlay < desiredContrast;
}

现在我们可以使用二分搜索来查找最佳叠加层不透明度

function findOptimalOverlayOpacity(textColor, overlayColor, worstContrastColorInImage, desiredContrast) {
  // If the contrast is already fine, we don't need the overlay,
  // so we can skip the rest.
  const isOverlayNecessary = isOverlayNecessary(textColor, worstContrastColorInImage, desiredContrast);
  if (!isOverlayNecessary) {
    return 0;
  }


  const opacityGuessRange = {
    lowerBound: 0,
    midpoint: 0.5,
    upperBound: 1,
  };
  let numberOfGuesses = 0;
  const maxGuesses = 8;


  // If there's no solution, the opacity guesses will approach 1,
  // so we can hold onto this as an upper limit to check for the no-solution case.
  const opacityLimit = 0.99;


  // This loop repeatedly narrows down our guesses until we get a result
  while (numberOfGuesses < maxGuesses) {
    numberOfGuesses++;


    const currentGuess = opacityGuessRange.midpoint;
    const contrastOfGuess = getTextContrastWithImagePlusOverlay({
      textColor,
      overlayColor,
      imagePixelColor: worstContrastColorInImage,
      overlayOpacity: currentGuess,
    });


    const isGuessTooLow = contrastOfGuess < desiredContrast;
    const isGuessTooHigh = contrastOfGuess > desiredContrast;
    if (isGuessTooLow) {
      opacityGuessRange.lowerBound = currentGuess;
    }
    else if (isGuessTooHigh) {
      opacityGuessRange.upperBound = currentGuess;
    }


    const newMidpoint = ((opacityGuessRange.upperBound - opacityGuessRange.lowerBound) / 2) + opacityGuessRange.lowerBound;
    opacityGuessRange.midpoint = newMidpoint;
  }


  const optimalOpacity = opacityGuessRange.midpoint;
  const hasNoSolution = optimalOpacity > opacityLimit;


  if (hasNoSolution) {
    console.log('No solution'); // Handle the no-solution case however you'd like
    return opacityLimit;
  }
  return optimalOpacity;
}

我们的实验完成后,我们现在确切地知道叠加层的透明度需要达到多少,才能保持文本可读性,同时又不至于过度隐藏背景图片。

我们做到了!

改进和局限性

我们之前介绍的方法只有在文本颜色和叠加层颜色本身对比度足够大的情况下才能有效。例如,如果你选择的文本颜色与你的叠加层颜色相同,那么除非图像根本不需要叠加层,否则就不会有最佳的解决方案。

此外,即使对比度在数学上是可以接受的,但这也不一定能保证它看起来很棒。对于带有浅色叠加层和繁忙背景图像的深色文本尤其如此。图像的各个部分可能会分散对文本的注意力,即使对比度在数值上很好,也可能难以阅读。这就是为什么普遍建议在深色背景上使用浅色文本。

我们也没有考虑像素的位置或每种颜色的像素数量。这样做的一个缺点是,角落中的像素可能会对结果产生过大的影响。然而,好处是,我们不必担心图像颜色的分布或文本的位置,因为只要我们处理了对比度最小的位置,其他所有位置都是安全的。

我在过程中学到了一些东西

在这个实验之后,我有一些收获,我想与大家分享。

  • 明确目标真的很有帮助!我们一开始的目标很模糊,只是希望在图像上获得可读的文本,最后我们得到了一个可以努力达成的特定对比度级别。
  • 明确术语非常重要。例如,标准RGB并非我预期的样子。我了解到,我所认为的“常规”RGB(0到255)正式称为8位RGB。此外,我以为我研究的方程式中的“L”代表“亮度”,但它实际上代表“亮度”(luminance),不要与“光亮度”(luminosity)混淆。澄清术语有助于我们编写代码以及讨论最终结果。
  • 复杂并不意味着无法解决。听起来很困难的问题可以分解成更小、更容易管理的部分。
  • 当你走过这条路时,你就会发现捷径。对于黑色透明叠加层上的白色文本的常见情况,你永远不需要超过0.54的不透明度就能达到WCAG AA级别的可读性。

总结一下…

你现在有了一种方法可以使你的文本在背景图像上可读,而不会牺牲太多图像。如果你已经读到这里了,我希望我已经能够让你对它的工作原理有一个大致的了解。

我最初开始这个项目是因为我看到(并且制作了)太多网站横幅,这些横幅上的文字在背景图像上很难阅读,或者背景图像被叠加层过度遮挡。我想做点什么,我想给其他人提供一种方法来做同样的事情。我写这篇文章是为了希望你能够更好地理解网络上的可读性。我希望你也能学到一些很酷的画布技巧。

如果你在可读性或画布方面做了一些有趣的事情,我很乐意在评论中听到你的分享!