Clave el contraste perfecto entre texto claro y una imagen de fondo | Programar Plus

¿Alguna vez se ha encontrado con un sitio en el que el texto claro se coloca sobre una imagen de fondo clara? Si es así, sabrá lo difícil que es leerlo. Una forma popular de evitarlo es utilizar una superposición transparente. Pero esto lleva a una pregunta importante: ¿qué tan transparente debería ser esa superposición? No es que siempre estemos tratando con los mismos tamaños de fuente, pesos y colores y, por supuesto, diferentes imágenes darán como resultado diferentes contrastes.

Intentar eliminar el contraste de texto deficiente en las imágenes de fondo es muy parecido a jugar a Whac-a-Mole. En lugar de adivinar, podemos resolver este problema con HTML <canvas> y un poco de matemáticas.

Como esto:

Podríamos decir “¡Problema resuelto!” y simplemente termine este artículo aquí. Pero, ¿dónde está la diversión en eso? Lo que quiero mostrarle es cómo funciona esta herramienta para que tenga una nueva forma de manejar este problema tan común.

Este es el plan

Primero, seamos específicos sobre nuestros objetivos. Hemos dicho que queremos texto legible sobre una imagen de fondo, pero ¿qué significa “legible”? Para nuestros propósitos, usaremos la definición de WCAG de legibilidad de nivel AA, que dice que el texto y los colores de fondo necesitan suficiente contraste entre ellos para que un color sea 4.5 veces más claro que el otro.

Elija un color de texto, una imagen de fondo y un color de superposición como punto de partida. Dadas esas entradas, queremos encontrar el nivel de opacidad de la superposición que hace que el texto sea legible sin ocultar tanto la imagen que también sea difícil de ver. Para complicar un poco las cosas, usaremos una imagen con espacio tanto oscuro como claro y nos aseguraremos de que la superposición lo tenga en cuenta.

Nuestro resultado final será un valor que podamos aplicar al CSS opacity propiedad de la superposición que nos da la cantidad adecuada de transparencia que hace que el texto sea 4,5 veces más claro que el fondo.

Opacidad de superposición óptima: 0.521

Para encontrar la opacidad de superposición óptima, realizaremos cuatro pasos:

  1. Pondremos la imagen en HTML <canvas>, que nos permitirá leer los colores de cada píxel de la imagen.
  2. Encontraremos el píxel de la imagen que tenga menos contraste con el texto.
  3. A continuación, prepararemos una fórmula de mezcla de colores que podemos usar para probar diferentes niveles de opacidad sobre el color de ese píxel.
  4. Finalmente, ajustaremos la opacidad de nuestra superposición hasta que el contraste del texto alcance el objetivo de legibilidad. Y estas no serán solo conjeturas aleatorias, usaremos técnicas de búsqueda binaria para hacer que este proceso sea rápido.

¡Empecemos!

Paso 1: lee los colores de la imagen del lienzo

Canvas nos permite “leer” los colores contenidos en una imagen. Para hacer eso, necesitamos “dibujar” la imagen en un <canvas> elemento y luego use el contexto del lienzo (ctx) getImageData() método para producir una lista de los colores de la imagen.

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://developer.mozilla.org/en-US/docs/Web/HTML/CORS_enabled_image
  const imagePixelColors = ctx.getImageData(...destinationCanvasCoordinates);
  return imagePixelColors;
}

El getImageData() El método nos da una lista de números que representan los colores de cada píxel. Cada píxel está representado por cuatro números: rojo, verde, azul y opacidad (también llamado “alfa”). Sabiendo esto, podemos recorrer la lista de píxeles y encontrar cualquier información que necesitemos. Esto será útil en el siguiente paso.

Imagen de una rosa azul y violeta sobre un fondo rosa claro.  Una sección de la rosa se amplía para revelar los valores RGBA de un píxel específico.

Paso 2: encuentra el píxel con el menor contraste

Antes de hacer esto, necesitamos saber cómo calcular el contraste. Escribiremos una función llamada getContrast() que toma dos colores y escupe un número que representa el nivel de contraste entre los dos. Cuanto mayor sea el número, mejor será el contraste de legibilidad.

Cuando comencé a investigar colores para este proyecto, esperaba encontrar una fórmula simple. Resultó que había varios pasos.

Para calcular el contraste entre dos colores, necesitamos conocer sus niveles de luminancia, que es esencialmente el brillo (Stacie Arellano hace un análisis profundo de la luminancia que vale la pena revisar).

Gracias al W3C, conocemos la fórmula para calcular el contraste mediante luminancia:

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

Obtener la luminancia de un color significa que tenemos que convertir el color del valor RGB normal de 8 bits utilizado en la web (donde cada color es 0-255) a lo que se llama RGB lineal. La razón por la que necesitamos hacer esto es que el brillo no aumenta de manera uniforme a medida que cambian los colores. Necesitamos convertir nuestros colores a un formato en el que el brillo varíe uniformemente con los cambios de color. Eso nos permite calcular correctamente la luminancia. Nuevamente, el W3C es una ayuda aquí:

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

¡Pero espera hay mas! Para convertir RGB de 8 bits (0 a 255) a RGB lineal, debemos pasar por lo que se llama RGB estándar (también llamado sRGB), que está en una escala de 0 a 1.

Entonces el proceso es:

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

Y una vez que tenemos la luminancia de ambos colores que queremos comparar, podemos conectar los valores de luminancia para obtener el contraste entre sus respectivos colores.

// 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;
}

Ahora que podemos calcular el contraste, tendremos que mirar nuestra imagen del paso anterior y recorrer cada píxel, comparando el contraste entre el color de ese píxel y el color del texto de primer plano. A medida que recorremos los píxeles de la imagen, realizaremos un seguimiento del peor (mínimo) contraste hasta el momento y, cuando lleguemos al final del ciclo, sabremos cuál es el color de peor contraste en la imagen.

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;
}

Paso 3: Prepare una fórmula de mezcla de colores para probar los niveles de opacidad de la superposición

Ahora que conocemos el color de peor contraste en nuestra imagen, el siguiente paso es establecer qué tan transparente debe ser la superposición y ver cómo eso cambia el contraste con el texto.

Cuando implementé esto por primera vez, usé un lienzo separado para mezclar colores y leer los resultados. Sin embargo, gracias al artículo de Ana Tudor sobre la transparencia, ahora sé que hay una fórmula conveniente para calcular el color resultante de mezclar un color base con una superposición transparente.

Para cada canal de color (rojo, verde y azul), aplicaríamos esta fórmula para obtener el color mezclado:

mixedColor = baseColor + (overlayColor - baseColor) * overlayOpacity

Entonces, en el código, se vería así:

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;
}

Ahora que podemos mezclar colores, podemos probar el contraste cuando se aplica el valor de opacidad de superposición.

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

¡Con eso, tenemos todas las herramientas que necesitamos para encontrar la opacidad de superposición óptima!

Paso 4: encuentre la opacidad de superposición que alcance nuestro objetivo de contraste

Podemos probar la opacidad de una superposición y ver cómo afecta el contraste entre el texto y la imagen. Vamos a probar un montón de diferentes niveles de opacidad hasta que encontremos el contraste que da en nuestra marca donde el texto es 4.5 veces más claro que el fondo. Puede parecer una locura, pero no se preocupe; no vamos a adivinar al azar. Usaremos una búsqueda binaria, que es un proceso que nos permite reducir rápidamente el posible conjunto de respuestas hasta que obtengamos un resultado preciso.

Así es como funciona una búsqueda binaria:

  • Adivina en el medio.
  • Si la suposición es demasiado alta, eliminamos la mitad superior de las respuestas. ¿Demasiado baja? En su lugar, eliminamos la mitad inferior.
  • Adivina en el medio de esa nueva gama.
  • Repite este proceso hasta que obtengamos un valor.

Resulta que tengo una herramienta para mostrar cómo funciona esto:

En este caso, estamos tratando de adivinar un valor de opacidad entre 0 y 1. Entonces, adivinaremos en el medio, probaremos si el contraste resultante es demasiado alto o demasiado bajo, eliminaremos la mitad de las opciones y volveremos a adivinar. Si limitamos la búsqueda binaria a ocho suposiciones, obtendremos una respuesta precisa en un instante.

Antes de comenzar a buscar, necesitaremos una forma de verificar si una superposición es necesaria en primer lugar. ¡No tiene sentido optimizar una superposición que ni siquiera necesitamos!

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

Ahora podemos usar nuestra búsqueda binaria para buscar la opacidad de superposición óptima:

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;
}

Con nuestro experimento completo, ahora sabemos exactamente qué tan transparente debe ser nuestra superposición para mantener nuestro texto legible sin ocultar demasiado la imagen de fondo.

¡Lo hicimos!

Mejoras y limitaciones

Los métodos que hemos cubierto solo funcionan si el color del texto y el color de la superposición tienen suficiente contraste para empezar. Por ejemplo, si tuviera que elegir un color de texto que sea el mismo que su superposición, no habrá una solución óptima a menos que la imagen no necesite una superposición en absoluto.

Además, incluso si el contraste es matemáticamente aceptable, eso no siempre garantiza que se verá genial. Esto es especialmente cierto para el texto oscuro con una superposición clara y una imagen de fondo ocupada. Varias partes de la imagen pueden distraer la atención del texto, lo que dificulta la lectura incluso cuando el contraste es numéricamente fino. Es por eso que la recomendación popular es usar texto claro sobre un fondo oscuro.

Tampoco hemos tenido en cuenta dónde se encuentran los píxeles o cuántos hay de cada color. Un inconveniente de esto es que un píxel en la esquina posiblemente podría ejercer demasiada influencia en el resultado. El beneficio, sin embargo, es que no tenemos que preocuparnos por cómo se distribuyen los colores de la imagen o dónde está el texto porque, siempre que hayamos manejado dónde está la menor cantidad de contraste, estamos seguros en cualquier otro lugar.

Aprendí algunas cosas en el camino

Hay algunas cosas con las que salí después de este experimento y me gustaría compartirlas con ustedes:

  • ¡Ser específico acerca de una meta realmente ayuda! Comenzamos con un objetivo vago de querer texto legible en una imagen, y terminamos con un nivel de contraste específico por el que podíamos esforzarnos.
  • Es muy importante tener claros los términos. Por ejemplo, el RGB estándar no fue lo que esperaba. Aprendí que lo que pensaba que era RGB “regular” (0 a 255) se llama formalmente RGB de 8 bits. Además, pensé que la “L” en las ecuaciones que investigué significaba “luminosidad”, pero en realidad significa “luminancia”, que no debe confundirse con “luminosidad”. Aclarar los términos ayuda a cómo codificamos, así como a cómo discutimos el resultado final.
  • Complejo no significa irresoluble. Los problemas que suenan difíciles se pueden dividir en partes más pequeñas y manejables.
  • Cuando caminas por el sendero, ves los atajos. Para el caso común de texto blanco sobre una superposición transparente negra, nunca necesitará una opacidad superior a 0,54 para lograr una legibilidad de nivel AA de WCAG.

En resumen…

Ahora tiene una forma de hacer que su texto sea legible en una imagen de fondo sin sacrificar demasiado la imagen. Si ha llegado hasta aquí, espero haberle podido dar una idea general de cómo funciona todo.

Inicialmente comencé este proyecto porque vi (e hice) demasiados banners de sitios web donde el texto era difícil de leer contra una imagen de fondo o la imagen de fondo estaba demasiado oscurecida por la superposición. Quería hacer algo al respecto y quería darles a los demás una forma de hacer lo mismo. Escribí este artículo con la esperanza de que entendieras mejor la legibilidad en la web. Espero que también hayas aprendido algunos trucos ingeniosos sobre el lienzo.

Si ha hecho algo interesante con legibilidad o lienzo, ¡me encantaría escucharlo en los comentarios!

(Visited 5 times, 1 visits today)