Der Edge rendert auf manchen Laptops PDFs komisch, wenn Hardwarebeschleunigung an ist, sprach der Kunde zu mir und blickte hoffnungsvoll. Und es stimmte – oft (nicht immer), wenn Edge auf den gerade frisch in seiner Firma ausgerollten Laptops (nicht den alten Laptops) ein PDF in der Inhouse-Webapp zu rendern versuchte, waren einzelne Buchstaben entweder ganz unsichtbar oder es fehlte die Hälfte der relevanten Pixel. Sobald die Hardwarebeschleunigung in den Browser-Optionen abgeschaltet war, sah alles wunderbar aus. Das Problem trat ausschließlich auf den neuen Laptops auf, ausschließlich bei aktivierter Hardwarebeschleunigung und ausschließlich im Edge, bei nicht allen, aber vielen PDFs.

Anders als die initiale Beschreibung des Bugs vermuten lässt, war es gar nicht so schwer, für das Problem einen passenden Workaround zu bauen: einfach die modernsten DOM-APIs mit den übelsten JavaScript-Worst-Practices der Jungsteinzeit kombinieren und schon ist perfektes PDF-Rendering gesichert!

Ursachensuche

Etwas wie der beschriebene Rendering-Bug konnte eigentlich nicht an irgendwelchem JavaScript aus der Feder meines Kunden liegen, da das überwiegend aus pragmatischem Glue Code für diverse Libraries besteht. Also war an der Zeit, die Dependencies zu durchkämmen, was die eigentliche Ursache des Problems schnell zutage förderte. Das PDF-Rendering in der Webapp übernimmt PDF.js (als eine Dependency einer Dependency einer anderen Dependency) und der Bugtracker von PDF.js ist nicht arm an Meldungen des genau gleichen Problems (Beispiel 1, Beispiel 2). Die eigentliche Ursache für den Bug ist aber nicht PDF.js selbst, sondern ein Glitch in der Rendering-Engine des Browsers.

Das Ergebnis der Ursachenforschung ist also: um das Problem wirklich zu reparieren, müssten die C++-Ninjas im Chrome/Blink-Team auf den Plan gerufen werden. Da das, selbst wenn es zeitnah in einem Bugfix münden sollte, vermutlich eine lange Rollout-Phase nach sich ziehen würde, brauchten wir zur Behandlung des akuten Problems einen JavaScript-basierten Workaround. Am einfachsten wäre es, per JS die Hardwarebeschleunigung zu deaktivieren, sollte sich das als machbar herausstellen. Und siehe da: das geht tatsächlich!

Pro und Contra von Canvas-Hardwarebeschleunigung

Im Kontext von Webbrowsern bedeutet Hardwarebeschleunigung, den Hauptprozessor zu entlasten, indem Rendering-Aufgaben an den dezidierten Grafik-Chip (sofern vorhanden) des Rechners übergeben werden. Der Grafik-Chip ist auf Renderei spezialisiert und deshalb darin viel flotter als die generalistische CPU, wovon sowohl CSS als auch Canvas-Elemente profitieren können. Anders als bei CSS ist allerdings Rendering nicht die einzige Aufgabe, die ein Canvas-Element wahrnehmen kann.

Das Canvas-Element unterstützt diverse APIs für Operationen rund um Grafik. Von diesen APIs haben allerdings längst nicht alle etwas mit dem Anzeigen irgendwelcher Pixel zu tun! Wenn wir beispielsweise in JavaScript-Zugriff auf die rohen Pixeldaten von Bilden brauchen, ist das Canvas-Element das Mittel der Wahl:

function loadImage(src) {
  return new Promise((resolve) => {
    const img = new Image();
    img.src = src;
    img.onload = () => resolve(img);
  });
}

// JPEG laden
const img = await loadImage("image.jpg");

// Nicht in DOM eingehängtes Canvas-Element erzeugen
const canvas = document.createElement("canvas");
canvas.width = 300;
canvas.height = 300;

// 2D-API
const ctx = canvas.getContext("2d");

// Bild auf (unsichtbare) Canvas zeichnen
ctx.drawImage(img, 0, 0);

// Pixeldaten abrufen
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);

// RGB-Werte invertieren
let i = 0;
while (i < imgData.data.length) {
  imgData.data[i] = 255 - imgData.data[i++]; // r
  imgData.data[i] = 255 - imgData.data[i++]; // g
  imgData.data[i] = 255 - imgData.data[i++]; // b
  i++; // a
}

// Invertierte Pixeldaten auf Canvas zeichnen
ctx.putImageData(imgData, 0, 0);

// Export als Data-URL, Anzeige als Image-Element
canvas.toBlob(async (blob) => {
  document.body.append(await loadImage(URL.createObjectURL(blob)));
});

Wichtig hier: das Canvas-Element ist zu keinem Zeitpunkt sichtbar, da es auch nicht angezeigt werden muss! Wir nutzen seine 2D-APIs (v.a. getImageData()), um die Pixeldaten des geladenen JPEG-Bildes in die Finger zu bekommen, die wir daraufhin invertieren und exportieren. Das Canvas-Element rendert nie etwas sichtbar auf den Bildschirm.

Für diesen Anwendungsfall ist Hardwarebeschleunigung tatsächlich eher Fluch als Segen. Die Benutzung von getImageData() und toBlob() führt dazu, dass bei aktivierter Hardwarebeschleunigung Daten häufig zwischen CPU und GPU hin- und hergeschoben werden müssen, was (je nach Workload) den Vorteil der flotteren GPU zunichtemacht. Damit das nicht sein muss, gibt es mit der Option willReadFrequently ein Opt-Out aus der Hardwarebeschleunigung für 2D-Canvas:

// 2D-API mit Optimierung für Readbacks
const ctx = canvas.getContext("2d", {
  willReadFrequently: true,
});

Der relevante normative Teil der HTML-Spezifikation nennt den Effekt von willReadFrequently nicht direkt ein „ein Opt-Out aus der Hardwarebeschleunigung“, sondern definiert den Effekt als:

When a CanvasRenderingContext2D object's willReadFrequently is true, the user agent may optimize the canvas for readback operations.

Die direkt auf den obigen Satz folgende nicht-normative Anmerkung sowie auch die MDN-Doku zu getContext() nennen das Kind aber beim Namen: willReadFrequently aktiviert Software-Rendering, damit Readbacks schneller werden … und vielleicht auch, damit lästige Rendering-Bugs mit PDF.js verschwinden?

Canvas-Hardwarebeschleunigung per JavaScript selektiv deaktivieren

Es ist nicht die Aufgabe von PDF.js, Rendering-Bugs in Chrome und Chrome-Derivaten zu reparieren. Deshalb ist es gut und richtig, dass in der Library alle Aufrufe von getContext() ohne willReadFrequently daherkommen - der Job von PDF.js ist das zügige Rendern von PDFs und Hardwarebeschleunigung hilft dabei. Aus der Sicht meines Kunden (speziell aus der Sicht der für die Webapp zuständigen Abteilung) ist das freilich anders: Ihr Job ist es, fehlerfrei PDFs in Edge anzuzeigen - und zwar jetzt, nicht erst, wenn Browserbugs gefixt und ausgerollt sind.

Statt PDF.js zu patchen und damit zukünftige Updates der Library zu verunmöglichen, habe ich folgende, dezent haarsträubende Lösung vorgeschlagen:

(function() {
  "use strict";
  const originalGetContext = HTMLCanvasElement.prototype.getContext;
  function patchedGetContext(contextType, contextAttributes = {}) {
    return originalGetContext.call(
      this,
      contextType,
      { ...contextAttributes, willReadFrequently: true }
    );
  }
  HTMLCanvasElement.prototype.getContext = patchedGetContext;
})();

Dieser Code (ummantelt mit einer legacy-konformen IIFE nach Art der Vorväter) überschreibt kurzerhand die getContext()-Methode aller Canvas-Elemente mit einer Variante, die willReadFrequently immer auf true setzt.

Eigentlich versteht sich von selbst, dass man nicht auf diese Weise Prototypen von irgendwelchen Objekten patchen sollte:

Aber keine Regel ohne Ausnahme! Ich würde behaupten, dass der obige Code für das akut bestehende PDF-Render-Problem der am wenigsten schlechte Workaround ist, denn:

  • das betroffene Modul der betroffenen Webapp lebt aus einer Reihe von Gründen in einem Iframe. Dieser bildet eine eigene kleine Sandbox, weswegen nicht alle Canvas-Elemente im kompletten Projekt betroffen sind. Das Team mit dem PDF-Problem verändert nur jene Canvas-Elemente, die es auch verantwortet (wenn auch von diesen Canvas-Elementen tatsächlich alle).
  • selbst wenn alle Canvas-Elemente im Projekt betroffen wären, ist aktuell das schlimmste, was passieren könnte, nachlassende Performance beim Rendering. Langsam, korrekt gerenderte Webapps sind aber besser als schnell falsch gerenderte Webapps, also ist das ein akzeptabler Tradeoff! Zwar könnten zukünftige Änderungen an Standards und Browsern zu Problemen führen, aber ...
  • ...der Code ist für leichtes Löschen optimiert. Er befindet sich in einer Extra-Datei mit einem erklärenden Kommentar inkl. Datumsangabe und Verweis auf den Browser-Bug, der die eigentliche Ursache des Problems ist. Wer auch immer über den Code stolpert, sei es aus Zufall oder auf der Suche nach einem zukünftig entstehenden Problem, kann sofort entscheiden, ob das Modul gelöscht werden kann oder unter welchen Voraussetzungen nach einem neuen Workaround gesucht werden muss.

Für die gegebenen Umstände, unter denen ein akutes Problem schnell und ökonomisch aus der Welt zu schaffen war, halte ich den Code für die perfekt passende Lösung.

Fazit und Ausblick

Nach dem Ausrollen des gezeigten Workarounds ist das PDF-Anzeige-Proble meines Kunden behoben und die Webapp funktioniert, ohne dass die Browser der Nutzer speziell konfiguriert werden müssen. Eine echte Lösung des Problems bestünde in Änderungen an der Rendering-Engine der Browser oder ein Update aller im Unternehmen verwendeten PDFs – beides ist, wenn überhaupt möglich, nicht der Job des Webapp-Teams. Also bleiben nur unglamouröse Workarounds wie das Verändern von PDF.js oder des Canvas-Prototypen. Bei letzterem besteht zumindest eine gute Chance, dass zwischen Einbau und Löschung des Workarounds keine weiteren Probleme auftauchen. Und für den Fall dass doch, sind die Gründe für das Vorhandensein des Workarounds, seine etwas obskure Funktionsweise und die Bedingungen für seine Löschung (inkl. Verweis auf die relevanten Issues in den Bugtrackern der Browser) in Kommentaren lang und breit dokumentiert.

Für mich persönlich war es sehr schön, mal wieder das eingestaubte Wissen rund um Prototypen, this und Function.prototype.call zur Anwendung bringen zu können. In einer Welt, in der katastrophale Browser-Inkonsistenzen selten geworden sind und in der JS-OOP über Klassen extrem einfach geworden ist, kommt man kaum noch dazu, handgeknotete Prototyp-Ketten und Funktionsverbiegungen zu jonglieren. Was zwar alles in allem ein echter und unbestreitbarer Fortschritt ist, aber irgendwo auch ein klein wenig langweilt.