Een /Indexed-opzoektabel slaat componenten van zijn base-kleurruimte op, geen RGB-triples. Een DeviceGray-base pakt één byte per entry, DeviceCMYK vier, Lab drie die rangeschaling nodig hebben, en ICCBased wat het profiel-/N ook declareert. HotPDF normaliseert ze allemaal bij het parsen tot het publieke RGB-palet dat THPDFColorSpace blootstelt
Die zin is de hele bugklasse. Bijna elke paletrenderer begint met de aanname van een stride van drie bytes, want de overgrote meerderheid van echte /Indexed-ruimten zit op /DeviceRGB en de aanname houdt jaren stand. Dan arriveert een gescande factuur met [/Indexed /DeviceCMYK 15 <...>], loopt de reader de opzoekstring drie bytes per keer door, en elk index boven nul landt midden in andermans kleur. De output is niet leeg en crasht niet. Het is aannemelijk ogende garbage, wat veel erger is, want niemand meldt een bug tegen kleuren die er alleen maar een beetje naast zitten
Waarom komt een Indexed-palet verkeerd uit?
De stride is fout omdat de stride geen constante is. ISO 32000-1 §8.6.6.3 definieert de opzoektabel als m × (hival + 1) bytes, waar m het aantal componenten in de base-ruimte is, en het zegt niets over die base die RGB zou zijn. Dezelfde sectie verbiedt maar twee bases botweg: /Indexed zelf en /Pattern. Al het andere is eerlijk spel, inclusief alle vier CIE-gebaseerde families uit §8.6.5.2 tot en met §8.6.5.5
Het echte werk is dus niet bytes lezen, het is eerst de base oplossen en hem vragen hoe breed een entry is. ResolveIndexedPalette in HotPDF doet precies dat: hij lost de base op tot een THPDFColorSpace, neemt ComponentCount uit het opgeloste record en wijst de array botweg af als de base terugkwam als csfIndexed, csfPattern of csfUnsupported. Pas daarna dimensionneert hij het bytebudget als PaletteCount * Count, dat voor het legale maximum van hival 255 en een vier-componentige base op 1024 bytes uitkomt. Twee details in die dimensionering zijn waar een vijandig bestand je zal testen. HotPDF clamt hival in tot 0..255 voordat hij hem gebruikt, conform de limiet van §8.6.6.3, dus een lookup-array die hival 100000 claimt wordt geen gigabyte-toewijzing; en hij kort het budget in tot wat de lookup-data werkelijk bevat, waarna hij stopt met het vullen van het palet bij de eerste entry waarvan de bytes over het einde heen lopen. Een korte tabel levert een kort palet op, geen read voorbij de buffer
Vier verschillende base-ruimten tot één RGB-tabel normaliseren
HotPDF converteert paletten tijdens het parsen naar RGB in plaats van bij het tekenen, en dat besluit drijft de rest van het ontwerp. Het publieke veld THPDFColorSpace.Palette is een TBytes van RGB-triples met lengte 3 * PaletteCount, en het heeft die vorm al lang voordat CIE-gebaseerde bases werden ondersteund. De recordlayout herschrijven om native base-componenten te dragen zou elke consumer van het veld hebben gebroken. Converteren bij het parsen houdt de layout intact en stuurt als bonus Gray-, CMYK-, Lab-, CalGray-, CalRGB- en ICCBased-entries door hetzelfde HPDFResolveColor-pad dat de renderer al voor directe kleuroperators gebruikt
De schaalstap is waar de base-ruimten ophouden inwisselbaar te zijn. Een lookup-byte is altijd 0..255, maar wat die byte betekent hangt af van het componentbereik dat de base declareert. Device- en gecalibreerde componenten mappen op 0..1. Lab niet: de L*-component loopt 0..100, terwijl a* en b* over het /Range lopen dat de ruimte declareert. HotPDF trekt die grenzen uit de velden AMin, AMax, BMin en BMax van THPDFCIEParams, die de Lab-parser uit de /Range-entry van het dictionary vult en op −108..101 default als de entry ontbreekt, dus hardcode geen symmetrisch bereik uit je hoofd. ICCBased-bases voegen een derde bron van waarheid toe: het componentenaantal komt uit de profielstream-/N, zichtbaar als ICCN, en een ICCBased-stream kan zelf een /Range-array dragen die HotPDF via HPDFReadNumericArrayUpTo in dezelfde per-component-grenzentabel leest. Een drie-componentig ICC-palet en een drie-componentig Lab-palet lopen de opzoekstring dus met identieke strides en totaal verschillende rekenkunde, precies de uitkomst die een vaste RGB-stride niet kan produceren
var
CS: THPDFColorSpace;
Entry: Integer;
R, G, B: Byte;
begin
// ColorSpaceObj is de /ColorSpace-entry uit een image-XObject of een
// resourcedictionary; het kan een name, een array of een indirecte ref zijn
CS := HPDFResolveColorSpace(ColorSpaceObj);
if CS.Family <> csfIndexed then
Exit;
// Palette is al RGB ongeacht wat de base-ruimte was
for Entry := 0 to CS.PaletteCount - 1 do
begin
if (Entry * 3 + 2) >= Length(CS.Palette) then
Break; // korte opzoektabel, netjes 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;
Wanneer de opzoektabel in een stream woont
ISO 32000-1 §8.6.6.3 staat toe dat de opzoektabel een string of een stream is, en de streamvorm is de vorm die bijt. Een stream kan /FlateDecode dragen, kan een indirect object zijn dat met ongerelateerde delen van het bestand wordt gedeeld, en zijn TStream kan op elk moment dat je parser hem bereikt overal gepositioneerd staan. HotPDF handelt het gecomprimeerde geval af door te delegeren naar de decoded-bytes-callback die via HPDFSetColorStreamReader is geregistreerd, dezelfde callback die de Type 0-sampled-function-engine gebruikt
Wanneer er geen reader is geregistreerd, is de fallback bewust smal in plaats van best-effort. HotPDF checkt het stream-dictionary op /Filter en weigert het palet volledig als er één aanwezig is, in plaats van gecomprimeerde bytes de lookup in te voeren en een zelfverzekerd foute tabel te produceren. Zonder filter leest hij ruwe bytes, begrensd op het bytebudget dat uit hival en het base-componentenaantal is berekend. De hele streamtak zit in een try..finally dat op elk uitgangspad, inclusief de weigeringen, de originele Stream.Position herstelt, want het object wordt gedeeld en de volgende consumer zal je niet danken voor een verplaatste cursor. LookupStreamUsesDecodedReaderAndRestoresPosition in de regressiesuite pind beide helften van dat gedrag vast
// Registreer beide callbacks vóór het oplossen van een kleurruimte, anders
// worden indirecte bases en gecomprimeerde lookup-streams simpelweg geweigerd
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;
Wat stopt een Indexed-kleurruimte die eindeloos recursieert?
/Indexed als directe base afwijzen is noodzakelijk en bij lange na niet voldoende. De interessante aanval gaat zijwaarts. Neem een valide [/Indexed /Separation ...] en bedenk dat §8.6.6.4 een Separation een alternate-ruimte laat noemen en §8.6.6.5 DeviceN hetzelfde laat doen. Maak die alternate een indirecte referentie die terugwijst naar de oorspronkelijke Indexed-array. Niets in de keten schendt bij een enkele hop de directe-base-regel: de base is een Separation, de Separation-alternate is een array, en de array blijkt toevallig degene waar je mee begon. Los hem naïef op en de parser loopt de lus tot de stack het begeeft. De tint-transform-machinery waar die ruimten op leunen is een eigen onderwerp, behandeld in het artikel over het renderen van Separation- en DeviceN-spotkleuren; hier doet ze er alleen toe als schakel in de cyclus
HotPDF sluit dit af op het enkele ingangspunt waar elk pad al doorheen loopt. HPDFResolveColorSpace wikkelt de interne resolver in een diepteteller die door HPDFColorSpaceMaxDepth wordt begrensd, en dat is 64. Overschrijd die grens en de guard zet een flag, en het is die flag die er toe doet: bij het unwinden gooit elk nestniveau dat hem gezet zag zijn eigen half opgeloste record weg en geeft csfUnsupported terug, zodat de hele graaf wordt afgewezen in plaats van half behouden, en beide tellers resetten wanneer de buitenste aanroep terugkeert. Die tellers zijn als threadvar gedeclareerd, niet als unit-globals, en dat is geen toevallige netheid: GResolveObj en GReadStream wonnen om dezelfde reden in thread-local storage, want pagina's renderen parallel. Een gedeelde global zou één thread diep in een geneste DeviceN-keten de limiet voor een andere thread laten trippen die een doodnormale [/Indexed /DeviceRGB 255 <...>] parseert, met een niet-deterministisch, load-afhankelijk falen als resultaat — de minst debuggare soort die er is. Vierenzestig is comfortabele headroom voor legitieme content, die zelden dieper dan vier of vijf niveaus nest, dus behandel csfUnsupported uit een structureel valide ogende array als logwaardig: in de praktijk betekent het een cyclus of een diepte die geen echte producent emitteert. IndirectAlternateCycleIsUnsupported, geparameteriseerd over Separation en DeviceN, en ExcessivePatternDepthIsUnsupported dekken beide gevallen
function TryResolveImageSpace(Obj: THPDFObject;
out CS: THPDFColorSpace): Boolean;
begin
CS := HPDFResolveColorSpace(Obj);
// csfUnsupported dekt hier een werkelijk onbekende familie, een cyclus
// door een indirecte alternate en diepte voorbij 64 niveaus
Result := CS.Family <> csfUnsupported;
if not Result then
CS := HPDFDefaultColorSpace; // DeviceGray, de initiële PDF-ruimte
end;
Eén API-grens die het waard is voor je callers te documenteren
HotPDF past de ICC-conversieconfiguratie die van kracht is bij het parsen toe wanneer hij een Indexed-palet bouwt, en latere aanroepen van HPDFSetColorSpaceRenderingIntent regenereren een al opgelost palet niet. Die functie loopt door de ICCBased- en alternate-transforms van het record dat je hem geeft en herbouwt die pipelines; de afgeplatte RGB-bytes in Palette zijn een parse-time-artefact en blijven zoals ze waren. Dit is een consequentie van vroeg normaliseren, geen over het hoofd zien, en het is de moeite waard om in je eigen API-docs te schrijven in plaats van een klant het te laten ontdekken. Laat je applicatie een gebruiker mid-session van rendering intent wisselen, los de kleurruimten dan na de wissel opnieuw op; zet je intent één keer bij de pipeline-opbouw, wat het overgrote gangbare geval is, dan is er niets te doen. Dezelfde volgorde geldt voor de proofing-profielen die via HPDFSetICCColorPipeline worden geïnstalleerd: configureer ze vóór je gaat oplossen, niet erna
De spiegelbug aan de schrijfkant
Alles hierboven gaat over lezen. Het symmetrische defect aan de schrijfkant is makkelijker te shippen en veel makkelijker te missen. Wanneer HotPDF een /Indexed-array uit een Windows 8-bit bitmap bouwt roept hij GetPaletteEntries aan, en die API vult alleen zoveel entries als het palet werkelijk bevat en geeft dat aantal terug. De oorspronkelijke code testte het resultaat op = 0 en itereerde daarna een vaste 256 entries over een stack-array. Voor een 16-kleuren-bitmap schreef dat 240 entries aan niet-geïnitialiseerd stackgeheugen rechtstreeks in de opzoekstring van een PDF die je daarna aan een klant geeft, met hival hardcoded op 255 zodat elke reader de rest plichtsgetrouw als echte kleurdata behandelde. Het is een correctheidsbug en een informatielekbug in dezelfde kleren. De fix is onglamoureus en compleet: nul de array vóór de aanroep en honoreer daarna het teruggegeven aantal voor zowel de lusgrens als hival. Genereer je zelf ergens /Indexed-afbeeldingen in je eigen code, ga die hival dan nu checken
Twee aangrenzende stukken maken het plaatje compleet: Indexed-paletten en Type 0-sampled-functies delen de decoded-stream-reader en dezelfde clamping-discipline, besproken in het stuk over Type 0-functie kleuropzoektabellen, en de filterregels die lookup-streams beheersen zijn dezelfde die beelddata beheersen in geladen afbeeldingen via decode-filters extraheren. De hier beschreven kleurruimte-resolver zit in de HotPDF Delphi Component voor Delphi en C++Builder, met de volledige THPDFColorSpace-referentie op de productpagina