Technisch artikel

CMYK JPEG in PDF: DCTDecode ColorTransform in Delphi

Een CMYK JPEG die uit een PDF komt met modderige, verschoven of negatieve kleuren betekent vrijwel altijd dat de decoder een van twee feiten verkeerd had: of de samples als YCCK waren opgeslagen en moesten worden omgezet, of ze volgens de Adobe-conventie geïnverteerd waren opgeslagen. De decoders van HotPDF volgen de DCTDecode-/ColorTransform-parameter voor drie- en vierkanaalsafbeeldingen, weigeren waarden die de specificatie niet definieert en laten een Adobe APP14-marker in de JPEG-data voorgaan op die dictionary-entry, zoals ISO 32000-1 Table 13 vereist. Twee buurregels horen bij dezelfde valkuilenfamilie: multi-filter-streams moeten stage-uitgelijnde /DecodeParms-arrays meedragen en JPEG 2000-beelden houden een expliciete /Decode-mapping

Alle drie delen één thema. De PDF zegt al wat hij bedoelt, en de valkuil is een renderer die een net andere vraag beantwoordt

Waarom toont een CMYK JPEG in een PDF geïnverteerd of uitgewassen?

Een CMYK JPEG gaat in een PDF op twee manieren mis, en die zien er op het scherm verschillend uit. Een negatief beeld, waarin wit papier zwart wordt en lichte tinten donker, wijst op geïnverteerde opslag. Een uitgewassen of kleurverschoven beeld, waarin huid groen wordt en zwart grijs terwijl de K-detail scherp blijft, wijst op een kleurtransformatie die werd toegepast terwijl dat niet hoorde, of werd overgeslagen terwijl die vereist was

Symptoomkaart voor een kapotte CMYK JPEG in een Delphi-PDF: negatieven wijzen op geïnverteerde Adobe-opslag, uitgewassen kleuren op een niet-passende ColorTransform
een negatief betekent geïnverteerde opslag die om een /Decode-array vraagt, kleurdrift betekent een niet-passende transformatie en een scherp K-vlak wijst eerst op de transformatie

Het transformatieprobleem komt uit hoe JPEG kleur comprimeert. Baseline JPEG comprimeert het best wanneer luminantie van chrominantie is gescheiden, dus encoders zetten RGB meestal eerst om naar YCbCr, en vierkanaalsencoders zetten CMY om naar YCC terwijl K onaangetast doorgaat, wat het formaat is dat YCCK heet. De decoder moet precies de conversie ongedaan maken die de encoder toepaste. Haal er een weg die nooit heeft plaatsgevonden of sla er een over die wel gebeurde, en elke pixel schuift. Het zwarte kanaal overleeft beide scenario's, en daarom houdt een kapotte CMYK-afbeelding vaak scherpe zwarte tekst terwijl al het restant verschuift

Het inversieprobleem is historisch. Photoshop en andere Adobe-tools schrijven CMYK JPEGs met elke sample opgeslagen als 255 - value, en ze merken het bestand met een Adobe APP14-marker. Niets in de JPEG-standaard zegt dat; het is een conventie en elke consument moet die kennen. In een PDF wordt van de producent verwacht dat hij de inversie in de image dictionary uitdrukt, in plaats van het aan giswerk over te laten

Wat vertelt /ColorTransform de decoder?

/ColorTransform is de aankondiging op PDF-niveau van welke conversie de decoder ongedaan moet maken. ISO 32000-1 §7.4.8, Table 13, definieert twee waarden. Nul betekent geen transformatie: de decoder geeft de samples terug zoals gecomprimeerd. Eén betekent dat de encoder de standaardconversie toepaste, dus een driekanaalsafbeelding gaat van YCbCr terug naar RGB en een vierkanaalsafbeelding van YCCK terug naar CMYK. De parameter wordt genegeerd bij één- en tweekanaalsafbeeldingen. Table 13 zet ook een voorrangsregel neer die makkelijk wordt gemist: als de gecodeerde data een Adobe-marker met een eigen transformcode meedraagt, geldt die code en wordt de dictionary-entry genegeerd. Alleen een JPEG zonder die marker valt terug op de dictionary, en als de entry er ook nog eens niet is, krijgt een driekanaalsafbeelding de transformatie als default en een vierkanaalsafbeelding geen

DCTDecode ColorTransform-beslissingsmatrix voor Delphi: de Adobe APP14-marker gaat boven de dictionary en HotPDF weigert ongedefinieerde waarden
de Adobe-marker in de JPEG-data rangschikt hoger dan de dictionary-entry en alleen een JPEG zonder een valt terug op /ColorTransform of de default op basis van het aantal componenten

De gemakkelijke fout is een imagepad dat de parameterdictionary accepteert maar haar nooit aan de JPEG-decoder doorgeeft. Een RGB JPEG van één pixel met de kleur (128, 63, 31) ontmaskert hem direct: zo'n decoder geeft [128, 63, 31] terug voor zowel /ColorTransform 0 als /ColorTransform 1, terwijl het juiste antwoord voor nul de rauwe YCbCr-driedubbel [79, 101, 163] is

HotPDF past de parameter toe op byteniveau. HPDFDecodeHeadlessTerminalImage loopt eerst door de JPEG-segmentstructuur tot aan de start-of-scan-marker en zoekt naar een Adobe APP14-segment; bytes die er slechts op lijken binnen een commentsegment tellen niet mee. Draagt de data de marker, dan beslist zijn transformcode en wordt de dictionarywaarde genegeerd. Zo niet, dan bouwt de decoder een privékopie van de JPEG met een APP14-segment vlak vóór de start-of-scan-marker, met transformcode 0, 1 of, voor een vierkanaalsafbeelding met waarde 1, de YCCK-code 2. Wanneer de effectieve transformatie van een driekanaalsafbeelding 0 is, ontwapent de kopie ook de JFIF-header, want een JFIF-header alleen al vertelt libjpeg-turbo dat YCbCr aangenomen mag worden. De buffer van de aanroeper wordt nooit aangeraakt, en wanneer er een segment wordt ingevoegd, worden zijn 16 extra bytes verrekend op het werkbudget voordat de decoder iets alloceert

Alleen invoegen wanneer er geen marker is, is van belang vanwege een libjpeg-detail: hij gebruikt het laatste APP14-segment dat hij ziet. Voeg je een segment ná een bestaande marker in, dan overschrijft een expliciete dictionarywaarde stilletjes een conflicterende marker in de data, precies omgekeerd aan Table 13. HotPDF past de Table 13-volgorde toe op zowel het volledige image XObject-pad als het inline-afbeeldingspad

Je kunt dezelfde terminaldecoder rechtstreeks aanroepen wanneer één afbeelding er verkeerd uitziet en je zijn samples wilt zien zonder de pagina te renderen. De functie zit in de unit HPDFHeadlessImageCodecs, krijgt de filternaam, de gecodeerde bytes, een optionele parameterdictionary en de geometrie uit de image dictionary mee, die moet matchen met de JPEG-frameheader

uses
  SysUtils, HPDFObjs, HPDFHeadlessImageCodecs;

// ColorTransform < 0 betekent "entry ontbreekt": laat de JPEG-markers beslissen
function DecodeDCT(const Encoded: TBytes; Width, Height, Channels,
  ColorTransform: Integer): TBytes;
var
  Parms: THPDFDictionaryObject;
begin
  Parms := nil;
  if ColorTransform >= 0 then
  begin
    Parms := THPDFDictionaryObject.Create(nil);
    Parms.AddNumericValue('ColorTransform', ColorTransform);
  end;
  try
    Result := HPDFDecodeHeadlessTerminalImage('DCTDecode', Encoded, nil, Parms,
      Width, Height, Channels, 8, 64 * 1024 * 1024, nil);
  finally
    Parms.Free;
  end;
end;

De decoder is streng over wat als geldige waarde telt. Een expliciete -1, een 2 of een niet-integer zoals 0.5 roept EHPDFHeadlessImageError op in plaats van terug te vallen op een default, want een waarde die de specificatie niet definieert geeft de decoder niets betrouwbaars om op te handelen. De controle blijft staan zelfs wanneer een Adobe-marker in de data de entry irrelevant maakt, want een ongedefinieerde waarde is een structurele fout in het document zelf. Een te klein budget voor de omgezette kopie faalt op dezelfde manier, met een melding die het werkbudget bij naam noemt

Een expliciete null is een ander geval, want dat is helemaal geen waarde. ISO 32000-1 §7.3.9 zegt dat een dictionary-entry met waarde null gelijkstaat aan een afwezige entry, dus /ColorTransform null zakt door naar de marker of naar de default op basis van het aantal componenten, precies alsof de key ontbrak. HotPDF behandelt het op elk pad zo, headless en VCL, stream en inline

Nog één default verdient een vermelding, want hier gaat HotPDF bewust voorbij aan de letter van Table 13. Een JPEG met drie componenten, zonder JFIF-header en zonder Adobe-marker, waarvan de frameheader zijn componenten R, G en B noemt, zegt in zijn eigen bytes dat hij RGB bevat. Table 13 zou hem nog steeds de transformatie als default geven, maar libjpeg en de readers die erop bouwen decoderen hem ongetransformeerd, en HotPDF doet dat ook. De headless- en VCL-decoders behandelen hem allebei zo, dus rood en blauw blijven waar de data ze zette. Een expliciete dictionary-0 of 1 overschrijft die afleiding nog steeds

uses
  System.IOUtils;

var
  Jpeg, Stored, Converted, ByMarkers: TBytes;
begin
  // rauwe DCTDecode-bytes van een CMYK-afbeelding 640 x 480, opgeslagen uit de PDF-stream
  Jpeg := TFile.ReadAllBytes('suspect-image.jpg');
  try
    // Met een Adobe APP14-marker in de data zijn alle drie de aanroepen het eens:
    // de marker beslist en de dictionarywaarde wordt genegeerd
    Stored    := DecodeDCT(Jpeg, 640, 480, 4, 0);   // geen marker: samples zoals gecomprimeerd
    Converted := DecodeDCT(Jpeg, 640, 480, 4, 1);   // geen marker: YCCK teruggedraaid naar CMYK
    ByMarkers := DecodeDCT(Jpeg, 640, 480, 4, -1);  // geen marker: vierkanaals default, geen
    Writeln(Format('pixel 0: stored %d,%d,%d,%d  converted %d,%d,%d,%d',
      [Stored[0], Stored[1], Stored[2], Stored[3],
       Converted[0], Converted[1], Converted[2], Converted[3]]));
  except
    on E: EHPDFHeadlessImageError do
      Writeln('Decoder refused the image: ', E.Message);
  end;
end;

Een referentiepixel met vier kanalen laat zien waar je op moet letten. Een CMYK-pixel van (10, 20, 30, 40) die als YCCK is opgeslagen komt terug als [245, 235, 225, 215] wanneer de YCCK-conversie niet is teruggedraaid en als [0, 116, 0, 215] wanneer dat wel zo is. De K-byte, 215, is in beide identiek. Ziet het K-vlak er goed uit en de andere drie niet, dan is een niet-passende transformatie de eerste verdachte

De geïnverteerde CMYK-conventie hoort bij /Decode

De HotPDF-terminaldecoder geeft CMYK-samples exact terug zoals libjpeg-turbo ze oplevert en draait ze niet om op basis van een Adobe-marker. De cijfers hierboven maken de conventie zichtbaar: 245, 235, 225, 215 zijn simpelweg 255 min 10, 20, 30, 40, de geïnverteerde vorm die Adobe-stijl encoders opslaan. Dat terugrekenen naar inkt Hoeveelheden is het werk van de image dictionary, waar een producent die zo'n JPEG insluit /Decode [1 0 1 0 1 0 1 0] hoort te schrijven. De renderer past de /Decode-array op elke component toe, dus een correct geschreven bestand drukt correct af

De keerzijde is het openlijk zeggen waard. Een PDF die een door Adobe geïnverteerde CMYK JPEG insluit zonder de passende /Decode-array, rendert in HotPDF als een negatief, want het bestand zelf beschrijft het beeld zo. Sommige viewers detecteren de APP14-marker en compenseren stilletjes. HotPDF voegt die heuristic niet toe, dus de fix voor zo'n bestand hoort thuis in de software die het schreef

Waarom is één DecodeParms-dictionary voor twee filters een fout?

Een stream waarvan /Filter een array van meerdere stadia is, moet /DecodeParms leveren als een array van dezelfde lengte, één parameterdictionary of null per stadium. ISO 32000-1 §7.3.8.2, Table 5, stelt die regel en HotPDF handhaaft haar: een multi-filter-stream die één gedeelde dictionary meelevert wordt gemeld als ongeldig document in plaats van te worden gedecodeerd

Het toegeeflijke alternatief is erger dan het eruitziet. Een decoder die de lengte alleen controleert wanneer /DecodeParms al een array is, zal bij één dictionary en twee filters stilletjes die dictionary bij elk stadium hergebruiken. Dat is onschadelijk als de dictionary leeg is en verwoestend als dat niet zo is: een /Predictor 12 bedoeld voor de binnenste Flate-stadium wordt dan ook op de buitenste toegepast, en de output wordt ogenschijnlijk plausibele troep in plaats van een nette foutmelding. Omdat de intentie van zo'n bestand niet meer terug te halen is, weigert HotPDF hem, zelfs in het lege geval, zoals een paginainhoudsstroom met /Filter [/FlateDecode /FlateDecode] en /DecodeParms << >>

DecodeParms-vormen die HotPDF accepteert voor multi-filter-PDF-streams in Delphi: uitgelijnde arrays gaan door, terwijl een gedeelde dictionary of een niet-passende arraylengte wordt geweigerd
een multi-filter-stream heeft één dictionary of null per stadium nodig en een gedeelde dictionary wordt geweigerd, ook als die leeg is

De geaccepteerde en geweigerde vormen zijn makkelijk op te sommen:

  • Geaccepteerd: /DecodeParms weggelaten, of null, bij elk aantal filters
  • Geaccepteerd: een array waarvan de lengte gelijk is aan het aantal filters, elke entry een dictionary of null
  • Geaccepteerd: één dictionary wanneer /Filter precies één filter noemt
  • Geweigerd: één dictionary gedeeld door twee of meer filters, met de diagnose "Stream filter chain requires array DecodeParms"
  • Geweigerd: een array waarvan de lengte afwijkt van het aantal filters, met "DecodeParms array must match the filter chain"

Dezelfde controle draait voor gewone inhoudsstreams en voor afbeeldingsstreams waarvan het laatste filter een terminalcodec is zoals DCTDecode, dus een misvormde keten faalt op dezelfde plek wat de stream ook bevat. De interactie met decompressielimieten wordt behandeld in het artikel over filterketenbudgetten en PDF-decodebommen. Vanuit applicatiecode komt de fout naar boven op het moment dat de pagina wordt gerenderd

uses
  SysUtils, HPDFHeadlessDocument, HPDFHeadlessCairoDevice;

var
  Document: THPDFHeadlessDocument;
  Options: THPDFHeadlessRenderOptions;
  Info: THPDFHeadlessRenderResult;
begin
  Document := THPDFHeadlessDocument.Create;
  try
    try
      Document.LoadFromFile('catalog-cmyk.pdf');
      Options := THPDFHeadlessRenderOptions.Default;
      Info := HPDFRenderHeadlessPagePNGFile(Document.GetRenderAccess(0),
        'page1.png', Options);
      Writeln('images=', Info.Images, ' decoded bytes=', Info.DecodedBytes);
    except
      on E: Exception do
        Writeln('Rejected: ', E.ClassName, ': ', E.Message);
    end;
  finally
    Document.Free;
  end;
end;

JPEG 2000-beelden houden hun expliciete /Decode

Een JPEG 2000-beeld negeert /Decode alleen wanneer zijn dictionary geen /ColorSpace heeft en het geen imagemask is. ISO 32000-1 §8.9.5 maakt dat voorwaardelijk en HotPDF volgt dat. /Decode laten vallen bij elke niet-stencil JPX-afbeelding is de makkelijke shortcut, en daarmee verdwijnt een expliciete inversie gewoon: een DeviceGray JPX-sample van 64 onder /Decode [1 0] moet renderen als 191, niet als 64

Die ene voorwaarde goed hebben is op zich nog niet genoeg. Kleurruimtetakken zoals Lab en ICCBased die standaard componentbereiken zetten zodra het beeld JPX is, zouden een terecht toegepaste array een tweede keer overschrijven, dus de renderer noteert of er daadwerkelijk een geldige /Decode-array is toegepast en vult standaardbereiken alleen aan wanneer dat niet zo was. Expliciete Lab- en ICCBased-bereiken overleven daardoor, en de kleurdeclaratie van de codestream blijft leidend wanneer de dictionary zwijgt. De decoderkant van het formaat wordt beschreven in het JPEG 2000- en JPXDecode-artikel

Welke decodepaden volgen deze regels?

Beide pipelines volgen ze. Aan de headless kant betekent dat het laden met THPDFHeadlessDocument, de gedeelde streamdecoder en de Cairo-paginerenderer achter HPDFRenderHeadlessPagePNGFile. Aan de VCL-kant betekent het extractie van geladen afbeeldingen naar een TBitmap en VCL-pagerendering, die dezelfde Table 13-volgorde toepassen bij het decoderen van volledige beelden en verkleinde regio's, zodat een JFIF-header YCbCr niet kan forceren over een Adobe-transformatie van 0 heen en een ongeldige waarde het beeld ook daar ondecodeerbaar maakt. Inline DCT-afbeeldingen volgen dezelfde marker-, null-, default- en weigeringsregels als image XObjects, met afgekorte en volledige keynamen door elkaar, inclusief een /DP << … >>-dictionary en afgekorte filterarrays zoals /F [/AHx /DCT], en een inline afbeelding met ongeldige parameters wordt overgeslagen terwijl de rest van de pagina rendert. De gids voor het extraheren van afbeeldingen uit een geladen PDF behandelt de rest van de filterafhandeling van die API

Nog een paar praktische notities voordat je op dit gedrag leunt:

  • JPEG decoderen vraagt om een compatibele TurboJPEG-bibliotheek op runtime; HOTPDF_TURBOJPEG_LIBRARY overschrijft de default turbojpeg.dll of libturbojpeg.so.0
  • Strikte structurele controles weigeren bestanden die toegeeflijke readers openen, met name de gedeelde lege /DecodeParms-dictionary, en de fix voor die bestanden zit aan de producentenkant

Voor een kleurgecontroleerde printflow is de praktische conclusie kort. Schrijf CMYK JPEGs met een expliciete /ColorTransform die aansluit bij elke Adobe-marker in de data, plus de /Decode-array die bij hun opslag past, geef elke multi-filter-stream een stage-uitgelijnde /DecodeParms-array en verwacht dat HotPDF bestanden houdt aan wat ze werkelijk zeggen

De headless loader, streamdecoders en Cairo-paginerenderer die hier zijn getoond, worden meegeleverd met de HotPDF Delphi PDF-component voor Delphi, C++Builder en Lazarus, naast de klassieke VCL-documentengine