Teknisk artikel

Indexerade PDF-paletter i Delphi: basrymder och cykler

En /Indexed-uppslagstabell lagrar komponenter av sitt basfärgrymd, inte RGB-tripplar. En DeviceGray-bas packar en byte per post, DeviceCMYK fyra, Lab tre som behöver omfångsskalning, och ICCBased vad profilen /N än deklarerar. HotPDF normaliserar alla vid parse-tidpunkten till den publika RGB-paletten som THPDFColorSpace exponerar

Den meningen är hela buggklassen. Nästan varje palettrenderare börjar sitt liv med att anta en tre-byte-stride, för den överväldigande majoriteten av verkliga /Indexed-rymder sitter på /DeviceRGB och antagandet håller i åratal. Sedan anländer en skannad faktura med [/Indexed /DeviceCMYK 15 <...>], läsaren vandrar igenom uppslagssträngen tre byte i taget, och varje index över noll hamnar mitt i någon annans färg. Utdata är inte blank och den kraschar inte. Det är trovärdigt skräp, vilket är mycket värre, för ingen anmäler en bugg mot färger som bara ser lite aviga ut

Varför blir en Indexed-palett fel?

Striden är fel för att striden inte är en konstant. ISO 32000-1 §8.6.6.3 definierar uppslagstabellen som m × (hival + 1) byte, där m är antalet komponenter i basrymden, och den säger ingenting om att basen är RGB. Samma avsnitt förbjuder bara två baser rakt av: /Indexed själv och /Pattern. Allt annat är fritt fram, inklusive alla fyra CIE-baserade familjerna från §8.6.5.2 till §8.6.5.5

Diagram över en Indexed-PDF-uppslagstabell i HotPDF där poststriden kommer från basfärgrymden: en 8-byte DeviceCMYK-uppslagning skuren av en felaktig 3-byte-RGB-vandring producerar skräpposter, den korrekta 4-byte-grupperingen ger två CMYK-poster normaliserade till RGB-tripplar, och en tabell listar komponenter per post för DeviceGray, DeviceRGB, DeviceCMYK, Lab och ICCBased
HotPDF löser upp basfärgrymden först och frågar den efter komponentantalet, så en CMYK-uppslagning vandrar fyra byte per post innan normalisering till RGB

Så det verkliga arbetet är inte att läsa byte, det är att lösa upp basen först och fråga den hur bred en post är. ResolveIndexedPalette i HotPDF gör exakt det: den löser upp basen till en THPDFColorSpace, tar ComponentCount ur den upplösta posten och avvisar arrayen rakt av om basen kom tillbaka som csfIndexed, csfPattern eller csfUnsupported. Först därefter storlekar den bytebudgeten som PaletteCount * Count, vilket för det lagliga maxvärdet hival 255 och en fyra-komponentbas toppar vid 1024 byte. Två detaljer i den storleksättningen är där en fientlig fil kommer att sondera dig. HotPDF klämmer hival till 0..255 innan den används, enligt gränsen i §8.6.6.3, så en uppslagsarray som påstår hival 100000 förvandlas inte till en gigabyte-allokering; och den trunkerar budgeten till vad uppslagsdatan faktiskt innehåller, och slutar sedan fylla paletten vid den första post vars byte rinner förbi slutet. En kort tabell ger en kort palett, inte en läsning förbi bufferten

Att normalisera fyra olika basrymder till en RGB-tabell

HotPDF konverterar paletter till RGB under parsningen snarare än vid ritningstidpunkten, och det beslutet driver resten av designen. Det publika fältet THPDFColorSpace.Palette är en TBytes av RGB-tripplar med längden 3 * PaletteCount, och det har haft den formen sedan långt innan CIE-baserade baser stöddes. Att skriva om postlayouten för att bära native baskomponenter skulle ha brutit varje konsument av fältet. Att konvertera vid parse-tidpunkten håller layouten intakt och, som bonus, dirigerar Gray-, CMYK-, Lab-, CalGray-, CalRGB- och ICCBased-poster genom samma HPDFResolveColor-väg som renderaren redan använder för direkta färgoperatorer

Skalningssteget är där basrymderna slutar vara utbytbara. En uppslagsbyte är alltid 0..255, men vad den byten betyder beror på det komponentomfång basen deklarerar. Device- och kalibrerade komponenter mappar på 0..1. Lab gör inte det: L*-komponenten går 0..100, medan a* och b* går över det /Range rymden deklarerar. HotPDF drar de gränserna ur fälten AMin, AMax, BMin och BMax i THPDFCIEParams, som Lab-parsern fyller från dictionary-postens /Range och sätter som standard till −108..101 när posten saknas, så hardcoda inte ett symmetriskt omfång ur minnet. ICCBased-baser lägger till en tredje sanningens källa: komponentantalet kommer från profilströmmens /N, exponerat som ICCN, och en ICCBased-ström kan själv bära en /Range-array som HotPDF läser genom HPDFReadNumericArrayUpTo in i samma per-komponent-gränstabell. En trekomponents-ICC-palett och en trekomponents-Lab-palett vandrar därför uppslagssträngen med identiska strider och helt olika aritmetik, vilket är exakt det utfall en fast RGB-stride inte kan producera

var
  CS: THPDFColorSpace;
  Entry: Integer;
  R, G, B: Byte;
begin
  // ColorSpaceObj är /ColorSpace-posten från en bild-XObject eller en
  // resursdictionary; den kan vara ett namn, en array eller en indirekt ref
  CS := HPDFResolveColorSpace(ColorSpaceObj);
  if CS.Family <> csfIndexed then
    Exit;

  // Paletten är redan RGB oavsett vad basrymden var
  for Entry := 0 to CS.PaletteCount - 1 do
  begin
    if (Entry * 3 + 2) >= Length(CS.Palette) then
      Break;                       // kort uppslagstabell, stanna rent
    R := CS.Palette[Entry * 3];
    G := CS.Palette[Entry * 3 + 1];
    B := CS.Palette[Entry * 3 + 2];
    UseColor(Entry, R, G, B);
  end;
end;

När uppslagstabellen bor i en stream

ISO 32000-1 §8.6.6.3 tillåter att uppslagstabellen är en string eller en stream, och stream-formen är den som biter. En stream kan bära /FlateDecode, den kan vara ett indirekt objekt delat med orelaterade delar av filen, och dess TStream kan vara positionerad var som helst i det ögonblick din parser når den. HotPDF hanterar det komprimerade fallet genom att delegera till decoded-bytes-callbacken som registrerats genom HPDFSetColorStreamReader, samma callback som Type 0-sampled-function-motorn använder

När ingen läsare är registrerad är fallbacken medvetet smal snarare än best-effort. HotPDF kontrollerar stream-dictionaryn efter /Filter och vägrar paletten helt om en finns, i stället för att mata komprimerade byte in i uppslagningen och producera en självsäkert fel tabell. Utan filter läser den råa byte, begränsade till den bytebudget som beräknats ur hival och basens komponentantal. Hela stream-grenen sitter inuti en try..finally som återställer det ursprungliga Stream.Position på varje utgångsväg, inklusive vägran, för objektet är delat och nästa konsument kommer inte att tacka dig för en flyttad markör. LookupStreamUsesDecodedReaderAndRestoresPosition i regressionssviten låser fast båda halvorna av det beteendet

Beslutsdiagram för en HotPDF-Indexed-uppslagstabell lagrad som en PDF-stream: en registrerad HPDFSetColorStreamReader-callback avkodar filtrerade byte, en oregistrerad stream med en /Filter-post vägras rakt av, och en filterfri stream läses rå upp till bytebudgeten medan try..finally återställer Stream.Position på varje utgång
Att vägra en filtrerad stream slår att mata komprimerade byte in i uppslagningen, och att återställa den ursprungliga streampositionen håller det delade objektet säkert för nästa konsument
// Registrera båda callbackarna innan du löser upp något färgrymd, annars
// vägras indirekta baser och komprimerade uppslagsströmmar helt enkelt
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;

Vad stoppar ett Indexed-färgrymd från att rekursera i all evighet?

Att avvisa /Indexed som direkt bas är nödvändigt och långt ifrån tillräckligt. Den intressanta attacken går i sidled. Ta en giltig [/Indexed /Separation ...], och kom ihåg att §8.6.6.4 låter en Separation namnge ett alternativt färgrymd och §8.6.6.5 låter DeviceN göra detsamma. Gör det alternativet till en indirekt referens som pekar tillbaka på den ursprungliga Indexed-arrayen. Ingenting i kedjan bryter regeln om direkt bas vid något enskilt hopp: basen är en Separation, Separationens alternativ är en array, och arrayen råkar bara vara den du startade från. Lös upp den naivt och parsern vandrar loopen tills stacken ger upp. Tint-transform-maskineriet de rymderna förlitar sig på är ett ämne för sig, täckt i artikeln om att rendera Separation- och DeviceN-spotfärger; här spelar de roll bara som en länk i cykeln

HotPDF stänger detta vid den enda ingångspunkt varje väg redan rinner genom. HPDFResolveColorSpace sveper den interna resolveren i en djupräknare som begränsas av HPDFColorSpaceMaxDepth, vilket är 64. Korsa den gränsen och vakten sätter en flagga, och flaggan är det som spelar roll: vid unwind kastar varje nästlingsnivå som såg den satt bort sin egen partiellt upplösta post och returnerar csfUnsupported, så hela grafen avvisas snarare än att hälften behålls, och båda räknarna återställs när det yttersta anropet returnerar. De räknarna är deklarerade som threadvar, inte unit-globaler, och det är inte tillfällig prydlighet: GResolveObj och GReadStream bor i thread-lokal lagring av samma anledning, för sidor renderas parallellt. En delad global skulle låta en tråd djupt i en nästlad DeviceN-kedja utlösa gränsen för en annan tråd som parsar en fullt vanlig [/Indexed /DeviceRGB 255 <...>], vilket producerar ett icke-deterministiskt, lastberoende misslyckande — den minst felsökningsbara sorten som finns. Sextiofyra är bekvämt headroom för legitimt innehåll, som sällan nästlar förbi fyra eller fem nivåer, så behandla csfUnsupported från en strukturellt giltig-utseende array som värd att logga: i praktiken betyder det en cykel eller ett djup ingen verklig producent sänder ut. IndirectAlternateCycleIsUnsupported, parameteriserad över Separation och DeviceN, och ExcessivePatternDepthIsUnsupported täcker båda fallen

Cykeldiagram och vakt för HotPDF-färgrymder: en Indexed-array med en Separation-bas vars alternativ är en indirekt referens tillbaka till samma array, upplöst av HPDFResolveColorSpace med en djupräknare begränsad av HPDFColorSpaceMaxDepth vid 64, varefter varje unwind-nivå kastar sin post och returnerar csfUnsupported
Ingenting i ett enskilt hopp bryter regeln om direkt bas, så HotPDF begränsar upplösningsdjupet till 64 nivåer och avvisar hela grafen snarare än att behålla hälften av den
function TryResolveImageSpace(Obj: THPDFObject;
  out CS: THPDFColorSpace): Boolean;
begin
  CS := HPDFResolveColorSpace(Obj);
  // csfUnsupported här täcker en genuint okänd familj, en cykel
  // genom ett indirekt alternativ, och djup bortom 64 nivåer
  Result := CS.Family <> csfUnsupported;
  if not Result then
    CS := HPDFDefaultColorSpace;   // DeviceGray, PDF:ens initiala färgrymd
end;

En API-gräns värd att dokumentera för dina anropare

HotPDF tillämpar den ICC-konverteringskonfiguration som gäller vid parse-tidpunkten när den bygger en Indexed-palett, och senare anrop av HPDFSetColorSpaceRenderingIntent regenererar inte en palett som redan är upplöst. Den funktionen vandrar igenom ICCBased- och alternativtransformerna i posten du lämnar den och bygger om de pipeline:na; de plattade RGB-byten i Palette är en parse-tids-artefakt och förblir som de var. Det är en konsekvens av att normalisera tidigt, inte en förbiseelse, och det är värt att skriva in i din egen API-dokumentation i stället för att lämna till en kund att upptäcka. Om din applikation låter en användare byta rendering intent mitt i en session, lös upp färgrymderna igen efter bytet; om den sätter intent en gång under pipeline-uppsättningen, vilket är det överväldigande vanliga fallet, finns det inget att göra. Samma ordning gäller för proofing-profilerna som installeras genom HPDFSetICCColorPipeline: konfigurera dem innan du börjar lösa upp, inte efter

Spegelbildsbuggen på skrivsidan

Allt ovan handlar om läsning. Den symmetriska defekten på skrivsidan är lättare att släppa och mycket lättare att missa. När HotPDF bygger en /Indexed-array från en Windows 8-bitars bitmap anropar den GetPaletteEntries, och det API:et fyller bara så många poster som paletten faktiskt håller och returnerar det antalet. Den ursprungliga koden testade resultatet för = 0 och itererade sedan över fasta 256 poster i en stack-array. För en 16-färgers bitmap skrev det 240 poster av oinitierat stackminne rakt in i uppslagssträngen i en PDF du sedan lämnar till en kund, med hival hardcodad till 255 så att varje läsare plikttroget behandlade resten som riktig färgdata. Det är en korrekthetsbugg och en informationsläckagebugg klädda i samma kläder. Fixen är oglamorös och fullständig: nollställ arrayen före anropet, hedra sedan det returnerade antalet för både loopgränsen och hival. Genererar du /Indexed-bilder någonstans i din egen kod, gå och kolla hival nu

Två angränsande bitar fullbordar bilden: Indexed-paletter och Type 0-sampled-funktioner delar decoded-stream-läsaren och samma klämdisciplin, diskuterat i stycket om Type 0-funktioners färguppslagstabeller, och filterreglerna som styr uppslagsströmmar är desamma som styr bilddata i att extrahera inlästa bilder genom avkodningsfilter. Färgrymdsresolveren som beskrivs här levereras som en del av HotPDF Delphi Component för Delphi och C++Builder, med fullständig THPDFColorSpace-referens på produktsidan