Technischer Artikel

Indexed-PDF-Paletten in Delphi: Basisfarbräume und Zyklen

Eine /Indexed-Lookup-Tabelle speichert Komponenten ihres Basis-ColorSpaces, keine RGB-Tripel. Eine DeviceGray-Basis packt ein Byte pro Eintrag, DeviceCMYK vier, Lab drei, die eine Range-Skalierung brauchen, und ICCBased das, was das Profil-/N deklariert. HotPDF normalisiert alle beim Parsen in die öffentliche RGB-Palette, die THPDFColorSpace bereitstellt

Dieser Satz ist die ganze Bugklasse. Fast jeder Paletten-Renderer startet mit der Annahme eines Drei-Byte-Stride, weil die überwältigende Mehrheit realer /Indexed-Spaces auf /DeviceRGB sitzt und die Annahme jahrelang trägt. Dann kommt eine gescannte Rechnung mit [/Indexed /DeviceCMYK 15 <...>] an, der Reader läuft die Lookup-Zeichenkette drei Bytes auf einmal durch, und jeder Index über null landet mitten in der Farbe eines anderen. Die Ausgabe ist nicht leer und crasht nicht. Es ist plausibel aussehender Müll – und das ist weit schlimmer, denn gegen Farben, die nur leicht daneben aussehen, meldet niemand einen Bug

Warum kommt eine Indexed-Palette falsch heraus?

Der Stride ist falsch, weil der Stride keine Konstante ist. ISO 32000-1 §8.6.6.3 definiert die Lookup-Tabelle als m × (hival + 1) Bytes, wobei m die Anzahl der Komponenten im Basisspace ist – und über RGB als Basis sagt die Stelle nichts. Derselbe Abschnitt verbietet nur zwei Basen rundweg: /Indexed selbst und /Pattern. Alles andere ist erlaubt, einschließlich aller vier CIE-basierten Familien von §8.6.5.2 bis §8.6.5.5

Diagramm einer Indexed-PDF-Lookup-Tabelle in HotPDF, bei der der Eintrags-Stride vom Basis-ColorSpace kommt: Ein 8-Byte-DeviceCMYK-Lookup, von einem falschen 3-Byte-RGB-Lauf geschnitten, erzeugt Müll-Einträge, die korrekte 4-Byte-Gruppierung liefert zwei in RGB-Tripel normalisierte CMYK-Einträge, und eine Tabelle listet Komponenten pro Eintrag für DeviceGray, DeviceRGB, DeviceCMYK, Lab und ICCBased
HotPDF löst zuerst den Basis-ColorSpace auf und fragt ihn nach der Komponentenzahl, daher begeht ein CMYK-Lookup vier Bytes pro Eintrag, bevor er nach RGB normalisiert wird

Die eigentliche Arbeit ist also nicht das Lesen von Bytes, sondern zuerst die Basis aufzulösen und sie zu fragen, wie breit ein Eintrag ist. ResolveIndexedPalette in HotPDF macht genau das: Es löst die Basis in einen THPDFColorSpace auf, nimmt ComponentCount aus dem aufgelösten Record und lehnt das Array rundweg ab, wenn die Basis als csfIndexed, csfPattern oder csfUnsupported zurückkam. Erst dann bemisst es das Bytebudget als PaletteCount * Count, was beim legalen Maximum von hival 255 und einer Vier-Komponenten-Basis bei 1024 Bytes endet. Zwei Details dieser Bemessung sind die Stellen, an denen eine feindliche Datei Sie sondiert. HotPDF klemmt hival vor der Verwendung auf 0..255, gemäß der Grenze aus §8.6.6.3, sodass ein Lookup-Array, das hival 100000 behauptet, nicht zu einer Gigabyte-Allokation wird; und es stutzt das Budget auf das, was die Lookup-Daten tatsächlich enthalten, und hört dann beim ersten Eintrag auf, dessen Bytes über das Ende hinauslaufen, die Palette zu füllen. Eine kurze Tabelle ergibt eine kurze Palette, kein Read über den Puffer hinaus

Vier verschiedene Basisspaces in eine RGB-Tabelle normalisieren

HotPDF konvertiert Paletten beim Parsen zu RGB statt beim Zeichnen, und diese Entscheidung treibt den Rest des Designs. Das öffentliche Feld THPDFColorSpace.Palette ist ein TBytes aus RGB-Tripeln mit der Länge 3 * PaletteCount, und diese Form hat es lange, bevor CIE-basierte Basen unterstützt wurden. Das Record-Layout umzuschreiben, um native Basiskomponenten zu tragen, hätte jeden Konsumenten des Felds gebrochen. Die Konvertierung beim Parsen hält das Layout intakt und schickt nebenbei Gray-, CMYK-, Lab-, CalGray-, CalRGB- und ICCBased-Einträge durch denselben HPDFResolveColor-Pfad, den der Renderer schon für direkte Color-Operatoren nutzt

Beim Skalierungsschritt hören die Basisspaces auf, austauschbar zu sein. Ein Lookup-Byte ist immer 0..255, aber was dieses Byte bedeutet, hängt vom Komponentenbereich ab, den die Basis deklariert. Device- und kalibrierte Komponenten bilden auf 0..1 ab. Lab nicht: Die L*-Komponente läuft von 0..100, während a* und b* über das vom Space deklarierte /Range laufen. HotPDF holt diese Grenzen aus den Feldern AMin, AMax, BMin und BMax von THPDFCIEParams, die der Lab-Parser aus dem /Range-Eintrag des Dictionaries füllt und bei fehlendem Eintrag auf −108..101 setzt – also keinen symmetrischen Bereich aus dem Gedächtnis hartkodieren. ICCBased-Basen bringen eine dritte Wahrheitsquelle: Die Komponentenzahl kommt aus dem Profil-Stream-/N, das als ICCN an die Oberfläche kommt, und ein ICCBased-Stream kann selbst ein /Range-Array tragen, das HotPDF über HPDFReadNumericArrayUpTo in dieselbe komponentenweise Grenztabelle liest. Eine Drei-Komponenten-ICC-Palette und eine Drei-Komponenten-Lab-Palette begehen die Lookup-Zeichenkette daher mit identischen Strides und völlig anderer Arithmetik – genau das Ergebnis, das ein fester RGB-Stride nicht produzieren kann

var
  CS: THPDFColorSpace;
  Entry: Integer;
  R, G, B: Byte;
begin
  // ColorSpaceObj ist der /ColorSpace-Eintrag aus einem Image-XObject oder
  // einem Resource-Dictionary; er kann ein Name, ein Array oder eine indirekte Referenz sein
  CS := HPDFResolveColorSpace(ColorSpaceObj);
  if CS.Family <> csfIndexed then
    Exit;

  // Palette ist bereits RGB, egal was der Basisspace war
  for Entry := 0 to CS.PaletteCount - 1 do
  begin
    if (Entry * 3 + 2) >= Length(CS.Palette) then
      Break;                       // kurze Lookup-Tabelle, sauber stoppen
    R := CS.Palette[Entry * 3];
    G := CS.Palette[Entry * 3 + 1];
    B := CS.Palette[Entry * 3 + 2];
    UseColor(Entry, R, G, B);
  end;
end;

Wenn die Lookup-Tabelle in einem Stream lebt

ISO 32000-1 §8.6.6.3 erlaubt für die Lookup-Tabelle einen String oder einen Stream, und die Stream-Form ist die, die beißt. Ein Stream kann /FlateDecode tragen, er kann ein indirektes Objekt sein, das mit unzusammenhängenden Teilen der Datei geteilt wird, und sein TStream kann in dem Moment irgendwo positioniert sein, in dem Ihr Parser ihn erreicht. Den komprimierten Fall erledigt HotPDF, indem es an den Decoded-Bytes-Callback delegiert, der über HPDFSetColorStreamReader registriert ist – denselben Callback, den die Type-0-Sampled-Function-Engine nutzt

Ist kein Reader registriert, ist der Fallback bewusst eng statt Best-Effort. HotPDF prüft das Stream-Dictionary auf /Filter und lehnt die Palette komplett ab, wenn einer vorhanden ist, statt komprimierte Bytes in das Lookup zu füttern und eine selbstsichere falsche Tabelle zu erzeugen. Ohne Filter liest es rohe Bytes, gedeckelt auf das aus hival und der Basiskomponentenzahl berechnete Bytebudget. Der ganze Stream-Zweig sitzt in einem try..finally, das auf jedem Ausgangspfad – auch bei den Ablehnungen – die ursprüngliche Stream.Position wiederherstellt, denn das Objekt wird geteilt, und der nächste Konsument wird Ihnen einen verschobenen Cursor nicht danken. LookupStreamUsesDecodedReaderAndRestoresPosition in der Regression-Suite nagelt beide Hälften dieses Verhaltens fest

Entscheidungsdiagramm für eine als PDF-Stream gespeicherte HotPDF-Indexed-Lookup-Tabelle: Ein registrierter HPDFSetColorStreamReader-Callback dekodiert gefilterte Bytes, ein nicht registrierter Stream mit /Filter-Eintrag wird rundweg abgelehnt, und ein filterfreier Stream wird roh bis zum Bytebudget gelesen, während try..finally Stream.Position auf jedem Ausgang wiederherstellt
Einen gefilterten Stream abzulehnen ist besser, als komprimierte Bytes in das Lookup zu füttern, und die Wiederherstellung der ursprünglichen Stream-Position hält das geteilte Objekt für den nächsten Konsumenten sicher
// Beide Callbacks registrieren, bevor ein ColorSpace aufgelöst wird, sonst
// werden indirekte Basen und komprimierte Lookup-Streams schlicht abgelehnt
HPDFSetColorSpaceResolver(MyResolveIndirectObject);
HPDFSetColorStreamReader(MyDecodedStreamReader);
try
  CS := HPDFResolveColorSpace(ColorSpaceObj);
  if (CS.Family = csfIndexed) and (Length(CS.Palette) = 0) then
    LogWarning('lookup table unreadable: filtered stream or no reader');
finally
  HPDFSetColorStreamReader(nil);
  HPDFSetColorSpaceResolver(nil);
end;

Was hält einen Indexed-ColorSpace davon ab, endlos zu rekursieren?

/Indexed als direkte Basis abzulehnen ist notwendig und bei weitem nicht hinreichend. Der interessante Angriff geht quer. Nehmen Sie ein gültiges [/Indexed /Separation ...], und erinnern Sie sich: §8.6.6.4 erlaubt einer Separation, einen Alternate-Space zu benennen, und §8.6.6.5 erlaubt DeviceN dasselbe. Machen Sie diesen Alternate zu einer indirekten Referenz zurück auf das ursprüngliche Indexed-Array. Nichts in der Kette verletzt die Direkt-Basis-Regel an einem einzelnen Hop: Die Basis ist eine Separation, der Separation-Alternate ist ein Array, und das Array ist nur zufällig das, von dem Sie ausgegangen sind. Lösen Sie naiv auf, und der Parser läuft die Schleife, bis der Stack aufgibt. Die Tint-Transform-Mechanik, auf die diese Spaces angewiesen sind, ist ein eigenes Thema, behandelt in dem Artikel zum Rendern von Separation- und DeviceN-Sonderfarben; hier zählen sie nur als Glied im Zyklus

HotPDF schließt das am einzigen Einstiegspunkt, durch den ohnehin jeder Pfad läuft. HPDFResolveColorSpace verpackt den internen Resolver in einen Tiefenzähler, der durch HPDFColorSpaceMaxDepth begrenzt ist – 64. Wer diese Grenze überschreitet, setzt im Guard ein Flag, und auf das Flag kommt es an: Beim Unwind verwirft jede Verschachtelungsebene, die es gesetzt sah, ihren eigenen teilaufgelösten Record und gibt csfUnsupported zurück, sodass der ganze Graph abgelehnt statt zur Hälfte behalten wird, und beide Zähler setzen sich zurück, wenn der äußerste Aufruf zurückkehrt. Diese Zähler sind als threadvar deklariert, nicht als Unit-Globals, und das ist keine beiläufige Sauberkeit: GResolveObj und GReadStream leben aus demselben Grund in Thread-lokalem Speicher, denn Seiten rendern parallel. Ein geteiltes Global würde erlauben, dass ein Thread tief in einer verschachtelten DeviceN-Kette für einen anderen Thread die Grenze auslöst, der gerade ein völlig gewöhnliches [/Indexed /DeviceRGB 255 <...>] parst – ein nichtdeterministischer, lastabhängiger Fehler, die am wenigsten debuggbare Sorte überhaupt. 64 ist komfortabler Spielraum für legitimen Inhalt, der selten tiefer als vier oder fünf Ebenen verschachtelt, also behandeln Sie csfUnsupported aus einem strukturell gültig aussehenden Array als protokollierenswert: In der Praxis bedeutet es einen Zyklus oder eine Tiefe, die kein realer Produzent emittiert. IndirectAlternateCycleIsUnsupported, parametrisiert über Separation und DeviceN, und ExcessivePatternDepthIsUnsupported decken beide Fälle ab

Zyklen-Diagramm und Guard für HotPDF-ColorSpaces: Ein Indexed-Array mit einer Separation-Basis, deren Alternate eine indirekte Referenz zurück auf dasselbe Array ist, aufgelöst von HPDFResolveColorSpace mit einem durch HPDFColorSpaceMaxDepth bei 64 gedeckelten Tiefenzähler, wonach jede Unwind-Ebene ihren Record verwirft und csfUnsupported zurückgibt
Nichts in einem einzelnen Hop bricht die Direkt-Basis-Regel, daher begrenzt HotPDF die Auflösungstiefe auf 64 Ebenen und lehnt den ganzen Graph ab, statt die Hälfte zu behalten
function TryResolveImageSpace(Obj: THPDFObject;
  out CS: THPDFColorSpace): Boolean;
begin
  CS := HPDFResolveColorSpace(Obj);
  // csfUnsupported deckt hier eine wirklich unbekannte Familie, einen Zyklus
  // durch einen indirekten Alternate und Tiefe jenseits von 64 Ebenen ab
  Result := CS.Family <> csfUnsupported;
  if not Result then
    CS := HPDFDefaultColorSpace;   // DeviceGray, der PDF-Initialspace
end;

Eine API-Grenze, die sich für Ihre Aufrufer zu dokumentieren lohnt

HotPDF wendet die zum Parse-Zeitpunkt geltende ICC-Konvertierungskonfiguration an, wenn es eine Indexed-Palette baut, und spätere Aufrufe von HPDFSetColorSpaceRenderingIntent regenerieren eine bereits aufgelöste Palette nicht. Diese Funktion begeht die ICCBased- und Alternate-Transforms des Records, den Sie ihr geben, und baut diese Pipelines neu; die flachgedrückten RGB-Bytes in Palette sind ein Parse-Zeit-Artefakt und bleiben, wie sie waren. Das ist eine Folge der frühen Normalisierung, kein Versehen, und es lohnt sich, es in die eigenen API-Dokumente zu schreiben, statt es einen Kunden entdecken zu lassen. Wenn Ihre Anwendung einem Benutzer erlaubt, den Rendering-Intent mitten in der Sitzung zu wechseln, lösen Sie die ColorSpaces nach dem Wechsel neu auf; wenn sie den Intent einmal beim Pipeline-Setup setzt – der überwältigend übliche Fall –, gibt es nichts zu tun. Dieselbe Reihenfolge gilt für die über HPDFSetICCColorPipeline installierten Proofing-Profile: Konfigurieren Sie sie, bevor Sie mit dem Auflösen beginnen, nicht danach

Der spiegelbildliche Bug auf der Schreibseite

Alles oben dreht sich ums Lesen. Der symmetrische Defekt auf der Schreibseite ist leichter auszuliefern und viel leichter zu übersehen. Wenn HotPDF aus einer Windows-8-Bit-Bitmap ein /Indexed-Array baut, ruft es GetPaletteEntries, und diese API füllt nur so viele Einträge, wie die Palette tatsächlich hält, und gibt diese Zahl zurück. Der ursprüngliche Code prüfte das Ergebnis auf = 0 und iterierte dann fest 256 Einträge über ein Stack-Array. Bei einer 16-Farben-Bitmap schrieb das 240 Einträge uninitialisierten Stack-Speichers direkt in die Lookup-Zeichenkette eines PDF, das Sie dann einem Kunden geben – mit hartkodiertem hival 255, sodass jeder Reader den Rest pflichtbewusst als echte Farbdaten behandelte. Es ist ein Korrektheitsbug und ein Informationsoffenlegungsbug in denselben Kleidern. Der Fix ist unglamourös und vollständig: das Array vor dem Aufruf nullen, dann die zurückgegebene Zahl sowohl für die Schleifengrenze als auch für hival ehren. Wenn Sie irgendwo in Ihrem eigenen Code /Indexed-Bilder erzeugen, gehen Sie jetzt hival nachsehen

Zwei benachbarte Bausteine runden das Bild ab: Indexed-Paletten und Type-0-Sampled-Funktionen teilen sich den Decoded-Stream-Reader und dieselbe Klemmen-Disziplin, besprochen in dem Beitrag zu Type-0-Funktions-Farblookup-Tabellen, und die Filterregeln für Lookup-Streams sind dieselben, die Bilddaten in Extrahieren geladener Bilder über Decode-Filter regeln. Der hier beschriebene ColorSpace-Resolver wird als Teil der HotPDF Delphi Component für Delphi und C++Builder ausgeliefert, mit der vollständigen THPDFColorSpace-Referenz auf der Produktseite