Perfekter Kontrast zwischen hellem Text und Hintergrundbild

Avatar of Yaphi Berhanu
Yaphi Berhanu am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Sind Sie schon einmal auf eine Website gestoßen, auf der heller Text auf einem hellen Hintergrundbild platziert war? Wenn ja, wissen Sie, wie schwierig das zu lesen ist. Eine beliebte Methode, dies zu vermeiden, ist die Verwendung einer transparenten Überlagerung. Dies wirft jedoch eine wichtige Frage auf: Wie transparent *sollte* diese Überlagerung sein? Es ist ja nicht so, dass wir immer mit den gleichen Schriftgrößen, -stärken und -farben zu tun haben, und natürlich führen unterschiedliche Bilder zu unterschiedlichen Kontrasten.

Der Versuch, schlechten Textkontrast auf Hintergrundbildern auszumerzen, ist wie das Spiel "Whac-a-Mole". Anstatt zu raten, können wir dieses Problem mit HTML <canvas> und ein wenig Mathematik lösen.

So wie das

Wir könnten sagen: "Problem gelöst!" und diesen Artikel hier beenden. Aber wo bleibt da der Spaß? Was ich Ihnen zeigen möchte, ist, *wie* dieses Werkzeug funktioniert, damit Sie eine neue Methode haben, um dieses allzu häufige Problem zu bewältigen.

Hier ist der Plan

Zuerst wollen wir unsere Ziele konkretisieren. Wir haben gesagt, dass wir lesbaren Text auf einem Hintergrundbild wünschen, aber was bedeutet "lesbar" überhaupt? Für unsere Zwecke verwenden wir die WCAG-Definition von Lesbarkeit auf AA-Niveau, die besagt, dass Text- und Hintergrundfarben genügend Kontrast zueinander haben müssen, damit eine Farbe 4,5-mal heller ist als die andere.

Wählen wir als Ausgangspunkt eine Textfarbe, ein Hintergrundbild und eine Überlagerungsfarbe. Mit diesen Eingaben wollen wir den Überlagerungs-Deckkraftgrad finden, der den Text lesbar macht, ohne das Bild so stark zu verdecken, dass auch dieses schwer zu erkennen ist. Um die Sache etwas zu verkomplizieren, verwenden wir ein Bild mit dunklen und hellen Bereichen und stellen sicher, dass die Überlagerung dies berücksichtigt.

Unser Endergebnis wird ein Wert sein, den wir auf die CSS-opacity-Eigenschaft der Überlagerung anwenden können, um die richtige Transparenz zu erzielen, die den Text 4,5-mal heller als den Hintergrund macht.

Optimale Überlagerungsdeckkraft: 0.521

Um die optimale Überlagerungsdeckkraft zu finden, gehen wir vier Schritte durch

  1. Wir legen das Bild in ein HTML-<canvas>-Element, was uns ermöglicht, die Farben jedes Pixels im Bild auszulesen.
  2. Wir finden das Pixel im Bild, das den geringsten Kontrast zum Text aufweist.
  3. Als Nächstes bereiten wir eine Formel zur Farbmischung vor, mit der wir verschiedene Deckkraftgrade über diesem Pixel testen können.
  4. Schließlich passen wir die Deckkraft unserer Überlagerung an, bis der Textkontrast das Lesbarkeitsziel erreicht. Und dies werden keine zufälligen Schätzungen sein – wir werden binäre Suchtechniken verwenden, um diesen Prozess zu beschleunigen.

Legen wir los!

Schritt 1: Bildfarben vom Canvas lesen

Canvas ermöglicht es uns, die Farben eines Bildes zu "lesen". Dazu müssen wir das Bild auf ein <canvas>-Element "zeichnen" und dann die getImageData()-Methode des Canvas-Kontexts (ctx) verwenden, um eine Liste der Bildfarben zu erstellen.

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

Die getImageData()-Methode liefert uns eine Liste von Zahlen, die die Farben jedes Pixels darstellen. Jedes Pixel wird durch vier Zahlen repräsentiert: Rot, Grün, Blau und Deckkraft (auch "Alpha" genannt). Wenn wir das wissen, können wir die Liste der Pixel durchgehen und alle benötigten Informationen finden. Dies wird im nächsten Schritt nützlich sein.

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.

Schritt 2: Das Pixel mit dem geringsten Kontrast finden

Bevor wir dies tun, müssen wir wissen, wie man den Kontrast berechnet. Wir schreiben eine Funktion namens getContrast(), die zwei Farben entgegennimmt und eine Zahl zurückgibt, die das Kontrastniveau zwischen den beiden darstellt. Je höher die Zahl, desto besser ist der Kontrast für die Lesbarkeit.

Als ich mit der Recherche zu Farben für dieses Projekt begann, erwartete ich, eine einfache Formel zu finden. Es stellte sich heraus, dass mehrere Schritte erforderlich waren.

Um den Kontrast zwischen zwei Farben zu berechnen, müssen wir ihre Luminanzwerte kennen, was im Wesentlichen die Helligkeit ist (Stacie Arellano bietet eine ausführliche Auseinandersetzung mit Luminanz, die es wert ist, angeschaut zu werden).

Dank des W3C kennen wir die Formel zur Berechnung des Kontrasts mithilfe der Luminanz

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

Um die Luminanz einer Farbe zu erhalten, müssen wir die Farbe vom üblichen 8-Bit-RGB-Wert, der im Web verwendet wird (bei dem jede Farbe 0-255 ist), in das sogenannte *lineare* RGB umwandeln. Der Grund, warum wir dies tun müssen, ist, dass die Helligkeit nicht gleichmäßig zunimmt, wenn sich Farben ändern. Wir müssen unsere Farben in ein Format umwandeln, in dem die Helligkeit mit Farbänderungen gleichmäßig variiert. Das ermöglicht uns, die Luminanz korrekt zu berechnen. Auch hier ist das W3C eine Hilfe.

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

Aber warten Sie, es gibt noch mehr! Um 8-Bit-RGB (0 bis 255) in lineares RGB umzuwandeln, müssen wir durch das sogenannte Standard-RGB (auch sRGB genannt) gehen, das auf einer Skala von 0 bis 1 liegt.

Der Prozess läuft also wie folgt ab: 

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

Und sobald wir die Luminanz beider zu vergleichenden Farben haben, können wir die Luminanzwerte einsetzen, um den Kontrast zwischen ihren jeweiligen Farben zu erhalten.

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

Jetzt, wo wir den Kontrast berechnen können, müssen wir das Bild aus dem vorherigen Schritt betrachten und jedes Pixel durchlaufen, wobei wir den Kontrast zwischen der Farbe dieses Pixels und der Vordergrundtextfarbe vergleichen. Während wir durch die Pixel des Bildes iterieren, verfolgen wir den schlechtesten (niedrigsten) bisherigen Kontrast. Wenn wir das Ende der Schleife erreichen, kennen wir die Farbe mit dem schlechtesten Kontrast im Bild.

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

Schritt 3: Eine Formel zur Farbmischung vorbereiten, um Überlagerungsdeckkraftgrade zu testen

Nachdem wir nun die Farbe mit dem schlechtesten Kontrast in unserem Bild kennen, besteht der nächste Schritt darin, festzulegen, wie transparent die Überlagerung sein soll und wie sich dies auf den Kontrast zum Text auswirkt.

Als ich dies zum ersten Mal implementierte, benutzte ich einen separaten Canvas, um Farben zu mischen und die Ergebnisse auszulesen. Dank Ana Tadors Artikel über Transparenz weiß ich jedoch jetzt, dass es eine praktische Formel gibt, um die resultierende Farbe zu berechnen, wenn eine Basisfarbe mit einer transparenten Überlagerung gemischt wird.

Für jeden Farbkanal (Rot, Grün und Blau würden wir diese Formel anwenden, um die gemischte Farbe zu erhalten

mixedColor = baseColor + (overlayColor - baseColor) * overlayOpacity

In Code würde das also so aussehen

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

Nun, da wir Farben mischen können, können wir den Kontrast testen, wenn der Überlagerungsdeckkraftwert angewendet wird.

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

Damit haben wir alle Werkzeuge, die wir brauchen, um die optimale Überlagerungsdeckkraft zu finden!

Schritt 4: Die Überlagerungsdeckkraft finden, die unser Kontrastziel erreicht

Wir können die Deckkraft einer Überlagerung testen und sehen, wie sich dies auf den Kontrast zwischen Text und Bild auswirkt. Wir werden eine Reihe verschiedener Deckkraftgrade ausprobieren, bis wir den Kontrast finden, der unser Ziel erreicht, bei dem der Text 4,5-mal heller als der Hintergrund ist. Das mag verrückt klingen, aber keine Sorge; wir werden nicht zufällig raten. Wir verwenden eine binäre Suche, ein Prozess, der es uns ermöglicht, den möglichen Antwortbereich schnell einzugrenzen, bis wir ein präzises Ergebnis erhalten.

So funktioniert eine binäre Suche

  • In der Mitte raten.
  • Wenn die Schätzung zu hoch ist, eliminieren wir die obere Hälfte der Antworten. Zu niedrig? Dann eliminieren wir stattdessen die untere Hälfte.
  • In der Mitte des neuen Bereichs raten.
  • Wiederholen Sie diesen Vorgang, bis Sie einen Wert erhalten.

Ich habe gerade zufällig ein Werkzeug, um zu zeigen, wie das funktioniert

In diesem Fall versuchen wir, einen Deckkraftwert zwischen 0 und 1 zu erraten. Wir raten also in der Mitte, prüfen, ob der resultierende Kontrast zu hoch oder zu niedrig ist, eliminieren die Hälfte der Optionen und raten erneut. Wenn wir die binäre Suche auf acht Versuche beschränken, erhalten wir im Handumdrehen eine präzise Antwort.

Bevor wir mit der Suche beginnen, benötigen wir eine Möglichkeit zu prüfen, ob eine Überlagerung überhaupt notwendig ist. Es macht keinen Sinn, eine Überlagerung zu optimieren, die wir nicht einmal brauchen!

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

Jetzt können wir unsere binäre Suche verwenden, um nach der optimalen Überlagerungsdeckkraft zu suchen

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

Nachdem unser Experiment abgeschlossen ist, wissen wir nun genau, wie transparent unsere Überlagerung sein muss, um unseren Text lesbar zu halten, ohne das Hintergrundbild zu sehr zu verdecken.

Wir haben es geschafft!

Verbesserungen und Einschränkungen

Die von uns behandelten Methoden funktionieren nur, wenn die Textfarbe und die Überlagerungsfarbe von vornherein genügend Kontrast aufweisen. Wenn Sie beispielsweise eine Textfarbe wählen, die mit Ihrer Überlagerung identisch ist, gibt es keine optimale Lösung, es sei denn, das Bild benötigt überhaupt keine Überlagerung.

Darüber hinaus garantiert die Tatsache, dass der Kontrast mathematisch akzeptabel ist, nicht immer, dass er gut aussieht. Dies gilt insbesondere für dunklen Text mit einer hellen Überlagerung und einem unruhigen Hintergrundbild. Verschiedene Teile des Bildes können vom Text ablenken und ihn auch dann schwer lesbar machen, wenn der Kontrast numerisch in Ordnung ist. Deshalb ist die populäre Empfehlung, hellen Text auf dunklem Hintergrund zu verwenden.

Wir haben auch nicht berücksichtigt, wo sich die Pixel befinden oder wie viele es von jeder Farbe gibt. Ein Nachteil davon ist, dass ein Pixel in der Ecke möglicherweise zu viel Einfluss auf das Ergebnis hat. Der Vorteil ist jedoch, dass wir uns keine Gedanken darüber machen müssen, wie die Farben des Bildes verteilt sind oder wo sich der Text befindet, denn solange wir den Bereich mit dem geringsten Kontrast behandelt haben, sind wir überall anders sicher.

Ich habe unterwegs ein paar Dinge gelernt

Es gibt einige Dinge, die ich nach diesem Experiment mitgenommen habe, und ich möchte sie mit Ihnen teilen

  • Konkret werden bei einem Ziel hilft wirklich! Wir begannen mit dem vagen Ziel, lesbaren Text auf einem Bild zu haben, und endeten mit einem spezifischen Kontrastniveau, das wir anstreben konnten.
  • Es ist so wichtig, bei den Begriffen klar zu sein. Zum Beispiel war Standard-RGB nicht das, was ich erwartet hatte. Ich lernte, dass das, was ich für "normales" RGB (0 bis 255) hielt, formell als 8-Bit-RGB bezeichnet wird. Außerdem dachte ich, das "L" in den von mir recherchierten Gleichungen stünde für "Lightness" (Helligkeit), aber es bedeutet tatsächlich "Luminance" (Leuchtdichte), was nicht mit "Luminosity" (Leuchtkraft) verwechselt werden darf. Die Klärung von Begriffen hilft sowohl bei der Codierung als auch bei der Diskussion des Endergebnisses.
  • Komplexität bedeutet nicht Unlösbarkeit. Schwierig klingende Probleme können in kleinere, besser handhabbare Teile zerlegt werden.
  • Wenn man den Weg geht, entdeckt man die Abkürzungen. Für den gängigen Fall von weißem Text auf einer schwarzen transparenten Überlagerung benötigen Sie niemals eine Deckkraft über 0,54, um die WCAG AA-Lesbarkeit zu erreichen.

Zusammenfassend…

Sie haben nun eine Möglichkeit, Ihren Text auf einem Hintergrundbild lesbar zu machen, ohne zu viel vom Bild zu opfern. Wenn Sie bis hierher gekommen sind, hoffe ich, Ihnen eine allgemeine Vorstellung davon gegeben zu haben, wie alles funktioniert.

Ursprünglich habe ich dieses Projekt begonnen, weil ich zu viele Website-Banner sah (und erstellte), bei denen der Text schwer gegen ein Hintergrundbild zu lesen war oder das Hintergrundbild durch die Überlagerung übermäßig verdeckt wurde. Ich wollte etwas dagegen tun und anderen eine Möglichkeit geben, dasselbe zu tun. Ich habe diesen Artikel geschrieben in der Hoffnung, dass Sie ein besseres Verständnis für Lesbarkeit im Web gewinnen. Ich hoffe, Sie haben auch ein paar coole Canvas-Tricks gelernt.

Wenn Sie etwas Interessantes mit Lesbarkeit oder Canvas gemacht haben, würde ich es gerne in den Kommentaren hören!