Ein PDF-Renderer, der nichts zeichnet, hat meist überhaupt keinen Fehler in seinem Zeichencode. In der HotPDF Component für Delphi und C++Builder ließen vier separate Defekte Seiten leer rendern, während jede Log-Zeile sauber blieb: Namensoperanden mit führendem Slash, eine umgekehrte cm-Verkettung und ein Token-Index, der null las. Keiner davon warf eine Exception. Keiner davon loggte etwas. Der Content-Stream wurde korrekt tokenisiert, der Operator-Dispatcher erkannte jeden Operator, das Bild-XObject wurde zu einer gültigen Bitmap dekodiert, und dann kam die Seite leer heraus. Diese Kombination – eine Pipeline, die auf jeder Stufe Erfolg meldet und nichts Sichtbares erzeugt – ist die Signatur eines Lookups oder Index, der still danebengreift statt zu scheitern. Dies ist eine Nachbetrachtung einer solchen Familie, und der Testdisziplin, die sie 38 Releases lang überleben ließ
Warum zeichnet ein PDF-Renderer überhaupt nichts?
Weil ein fehlgeschlagener Ressourcen-Lookup in einem PDF-Renderer von einer leeren Seite nicht zu unterscheiden ist. Namensoperanden im Content-Stream und Schlüssel im Ressourcen-Dictionary sind zwei verschiedene String-Räume, und HotPDF verglich über sie hinweg, ohne zu normalisieren. Der Tokenizer liest /Im0 und behält den Solidus, weil das der Token ist; das geladene /Resources /XObject-Dictionary speichert den Schlüssel als Im0, weil der Parser den Trenner entfernt, wenn er Dictionary-Schlüssel baut. Jedes FindValue gegen einen Operandennamen lieferte daher -1. Der Wirkungsradius war weiter als nur Bilder. ISO 32000-1 §8.9 behandelt Do, §8.4 behandelt gs und dessen /ExtGState-Lookup, §8.6 behandelt cs und CS, und §8.7.4.3 behandelt sh. Alle fünf Operatoren schlüsselten ihr Ressourcen-Sub-Dictionary über den rohen Operanden, sodass alle fünf danebengriffen. Benannte Farbräume fielen auf DeviceGray zurück, was 1 scn in weiße Farbe auf weißer Seite verwandelt. Bild-XObjects wurden überhaupt nie gemalt – der Bitmap-Bildpfad hatte in der Praxis seit dem Tag seiner Einführung nie funktioniert. Der Fix ist ein Helfer auf Unit-Ebene, angewandt auf jeden operandengeschlüsselten Lookup, was der einzige Weg ist, die Konvention davon abzuhalten, erneut abzudriften
// Page content stream, the ordinary image-placement idiom:
// q
// /GS0 gs
// 200 0 0 120 60 400 cm
// /Im0 Do
// Q
// The operand token is '/Im0'. The resource dictionary key is 'Im0'.
function HPDFStripNameSlash(const N: AnsiString): AnsiString;
begin
Result := N;
if (Result <> '') and (Result[1] = '/') then
Delete(Result, 1, 1);
end;
// Every resource lookup keyed by an operand name goes through the helper.
Name := HPDFStripNameSlash(Name);
XObjIdx := FPageResources.FindValue('XObject');
if XObjIdx < 0 then
Exit;
// The /XObject sub-dictionary may itself be an indirect reference.
XObjDict := FAccess.ResolveDictionary(FAccess.Context,
FPageResources.GetIndexedItem(XObjIdx));
if XObjDict = nil then
Exit;
Ein zweiter, verwandter Fehltreffer saß eine Ebene tiefer. Der Renderer hatte typisierte Resolver nur für Streams und Dictionaries, sodass eine indirekte Referenz, die auf ein Top-Level-Array-Objekt zeigte – das gängige /CS0 5 0 R mit [/Separation ...] am anderen Ende – über beide zu nil auflöste und auf den unaufgelösten Link zurückfiel. Einen generischen Objekt-Resolver hinzuzufügen behob benannte Farbräume und Funktionsarrays in einem Zug. Wer Shading-Dictionaries verdrahtet, dem gilt dieselbe Auflösungsdisziplin für den Pfad axialer und radialer Shadings, wo der /Function-Eintrag sehr häufig indirekt ist
Der cm-Operator und eine rückwärts geschriebene Verkettung
Der zweite Defekt platzierte Bilder um rund einhunderttausend Pixel neben der Seite, was exakt so aussieht wie gar nicht gezeichnet. ISO 32000-1 §8.3.4 definiert PDF-Transformationen mit Zeilenvektoren, und der cm-Operator verkettet seine Operandenmatrix M mit der aktuellen Transformationsmatrix als M × CTM – M wirkt zuerst, die bestehende CTM danach. HotPDF setzt Matrizen über HPDFMatMul(A, B) zusammen, was B vor A anwendet. Der korrekte Aufruf übergibt daher die alte CTM als A. Der ausgelieferte Code übergab die Operandenmatrix als A, was CTM × M ergab
Umgekehrte Reihenfolge ist harmlos für ein einzelnes cm und katastrophal für das übliche zweistufige Idiom. Platziert man ein Bild mit 1 0 0 1 x y cm gefolgt von w 0 0 h 0 0 cm, skaliert die korrekte Kaskade das Einheitsquadrat um (w, h) und verschiebt es dann um (x, y). Unter der umgekehrten Kaskade geht die Verschiebung zuerst ein, und die Skalierung multipliziert sie, sodass ein Bild, nominell bei (60, 400) auf 200 mal 120 skaliert, bei (12000, 48000) landet. Der Clip-Test am Anfang des Blits weist es zurück, der Blit wird übersprungen, und nichts irgendwo meldet ein Problem
// HPDFMatMul(A, B) applies B first, then A.
// ISO 32000-1 cm semantics: new CTM = M x CTM, so M must be B.
// Wrong, and shipped for 38 versions:
GS.CTM := HPDFMatMul(HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
NumAt(3), NumAt(2), NumAt(1)), GS.CTM);
// Correct:
GS.CTM := HPDFMatMul(GS.CTM, HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
NumAt(3), NumAt(2), NumAt(1)));
Was diesen Fall lehrreich macht: Dieselbe Quelldatei enthielt bereits die richtige Reihenfolge. Der /Matrix-Eintrag eines Form-XObject hatte dieselbe umgekehrte Zusammensetzung, aber der Type-3-Glyphenpfad und der eingebettete Glyphen-Konturpfad hatten beide von Anfang an die richtige, weil Glyphenplatzierung sichtbar zum Ursprung kollabiert, wenn man sie invertiert, und jemand war bereits gezwungen gewesen, das zu reparieren. Zwei Konventionen koexistierten drei Dutzend Releases lang in einer Unit, jede in ihrer eigenen Funktion korrekt, und kein Reviewer bemerkte es, weil keine Aufrufstelle für sich betrachtet falsch aussah
Was passiert, wenn ein Token-Index um eins danebenliegt?
Man erhält zwölf Operatoren, die syntaktisch behandelt und semantisch tot sind. Der Operanden-Accessor im Renderer ist NumAt(Back), der Tokens[OpIndex - Back] liest, und OpIndex ist der Index des Operator-Tokens selbst. Ein Operator mit einem Operanden findet seine Zahl also bei Back 1. Zwölf davon waren als NumAt(0) geschrieben, was das Operator-Token liest, die ctOperandNumber-Artprüfung nicht besteht und den Nullwert-Default zurückgibt. Die Liste umfasst Tc, Tw, Tz, TL, Ts und Tr aus den Textzustandsoperatoren von ISO 32000-1 §9.3, plus w, J, j, M, ri und i aus den Grafikzustandsoperatoren von §8.4.3. Zeichen- und Wortabstand wurden zu No-Ops, horizontale Skalierung griff nie, der Zeilenabstand blieb bei null, sodass T* nie eine Zeile weiterrückte, der Text-Rise tat nichts, der Rendermodus war immer Füllen, und jeder Strich in jedem Dokument kam als 1-Pixel-Haarlinie heraus, unabhängig von der deklarierten Linienbreite. Operatoren mit mehreren Operanden wie m, rg und Tm verwendeten NumAt(1..6) und waren allesamt korrekt, sodass ein Reviewer, der die Funktion überflog, eine Wand plausibler Index-Arithmetik sah, in die zwölf falsche Einträge eingebettet waren
function NumAt(Back: Integer): Double;
begin
Result := 0;
if (OpIndex - Back >= 0)
and (Tokens[OpIndex - Back].Kind = ctOperandNumber) then
Result := Tokens[OpIndex - Back].NumValue;
end;
// OpIndex addresses the operator token, so a lone operand sits at back 1.
else if Op = 'Tc' then GS.Text.CharSpace := NumAt(1) // previously NumAt(0)
else if Op = 'TL' then GS.Text.Leading := NumAt(1) // previously NumAt(0)
else if Op = 'Tr' then GS.Text.RenderMode := Round(NumAt(1))
else if Op = 'w' then GS.LineWidth := NumAt(1) // previously NumAt(0)
Warum blieb die Testsuite 38 Versionen lang grün?
Weil die Assertions zu schwach waren, um eine gerenderte Seite von einer teilweise gerenderten zu unterscheiden. Die Rendering-Smokes prüften Dinge wie: die Ausgabe-Bitmap ist nicht vollständig schwarz, oder die Seite ist nicht leer, oder der Bild-Digest ist ungleich null. Jede davon gilt, wenn Text rendert und Bilder nicht. Text zeichnete einwandfrei, sodass der Frame-Buffer nie einheitlich war, der Digest nie null war, und die Suite Erfolg meldete, während die gesamte Bildpipeline in der Praxis toter Code war. Schwache Assertions sind bei Grafik geradezu verführerisch, weil starke brüchig wirken. Niemand will einen Test, der bricht, wenn eine Anti-Aliasing-Kante um ein Pixel verrutscht, sodass der natürliche Rückzug darin besteht, etwas zu prüfen, das keine vernünftige Änderung verletzen könnte – und dieser Rückzug landet bei Prädikaten, die auch keine unvernünftige Änderung verletzen kann. Ein Test für einen Separation-Farbraum prüfte, ob die Ausgabe von Schwarz unterscheidbar war; Grau auf Weiß bestand ihn, ebenso Weiß auf Weiß. Der Test maß nicht, ob die richtige Farbe gemalt wurde. Er maß, ob überhaupt irgendetwas auf der Leinwand geschehen war
Wie schreibt man eine Rendering-Assertion, die tatsächlich fehlschlägt?
Pixel der erwarteten Farbe in der erwarteten Menge zählen, und Position und Größe aus der Zählung herausfallen lassen. Die Ersatzdisziplin ist ein handgebautes minimales PDF, ein visueller Fakt pro Datei, und eine Assertion darüber, wie viele Pixel innerhalb einer Toleranz um ein bestimmtes RGB-Tripel liegen. Ein 200 mal 120 großes Bild aus reinem Rot, platziert an einem bekannten Offset, muss rund 24000 rote Pixel erzeugen. Greift der Ressourcen-Lookup daneben, ist die Zählung 0. Ist die cm-Kaskade umgekehrt, ist die Zählung 0. Rendert das Bild im falschen Farbraum, ist die Zählung 0. Eine Zahl fängt alle drei, und die Toleranzbandbreite absorbiert das Anti-Aliasing-Rauschen, das Menschen ursprünglich vor exakten Vergleichen zurückschrecken ließ
function CountPixelsNear(Bmp: TBitmap; R, G, B, Tol: Integer): Integer;
var
X, Y: Integer;
C: TColor;
begin
Result := 0;
for Y := 0 to Bmp.Height - 1 do
for X := 0 to Bmp.Width - 1 do
begin
C := Bmp.Canvas.Pixels[X, Y];
if (Abs(GetRValue(C) - R) <= Tol)
and (Abs(GetGValue(C) - G) <= Tol)
and (Abs(GetBValue(C) - B) <= Tol) then
Inc(Result);
end;
end;
// A 200x120 red image placed at 60,400 must paint about 24000 red pixels.
Check(CountPixelsNear(Bmp, 255, 0, 0, 12) > 20000,
'image XObject was never drawn');
Vier Smokes wurden auf diese Weise neu geschrieben – eine Type-4-Farbtonwerttransformation, eine Bild-Do-Platzierung, ein Fall optionaler Sichtbarkeit von Inhalt und ein Tr-Strichmodus – und zusammen deckten sie die gesamte Familie auf. Das ist die eigentliche Lehre, und sie verallgemeinert über diese Codebasis hinaus: In einer Rendering-Pipeline muss die Assertion die Farbe benennen. Alles Weichere ist eine Prüfung, dass der Renderer gelaufen ist, keine Prüfung, dass er gezeichnet hat. Wer sein eigenes Page-to-Bitmap-Testgeschirr baut, findet im Durchgang zur Seitenrasterung den natürlichen Ort, um einen Pixel-Zähl-Helfer an die erste Regression anzuschrauben
Ehrliche Grenzen
Zwei Grenzen sind es wert, offen benannt zu werden. Text-Clipping-Rendermodi 4 bis 7 werden als ihr Basis-Füll- oder Strichmodus gezeichnet, weil der Renderer keine akkumulierten Clip-Pfade aus Glyphenkonturen modelliert; Dokumente, die auf textgeformtem Clipping beruhen, rendern den Text statt des darunterliegenden, geclippten Artworks. Und die hier beschriebene Pixel-Zähl-Disziplin ist eine Smoke-Test-Technik, keine Konformitätssuite – sie beweist, dass ein bestimmter visueller Fakt den Frame-Buffer erreicht hat, was eine viel niedrigere Messlatte ist, als zu beweisen, dass die Ausgabe einem Referenz-Rasterisierer entspricht. Es ist jedoch genau die Messlatte, die diese vier Bugs drei Jahre lang an Releases nicht überwanden
Der hier besprochene Renderer ist Teil der Standard-HotPDF Component für Delphi und C++Builder; die Produktseite führt die vollständige API-Referenz zum Seiten-Rendering, einschließlich der Bitmap-Cache- und Hintergrund-Prefetch-Einstiegspunkte