Artykuł techniczny

CMYK JPEG w PDF: DCTDecode ColorTransform w Delphi

CMYK JPEG wychodzący z PDF-a z błotnistymi, przesuniętymi albo negatywowymi kolorami prawie zawsze znaczy, że dekoder pomylił jeden z dwóch faktów: czy próbki były zapisane jako YCCK i wymagają konwersji, czy były zapisane odwrócone według konwencji Adobe. Dekodery HotPDF respektują parametr DCTDecode /ColorTransform dla obrazów trzy- i czterokanałowych, odrzucają wartości niezdefiniowane przez specyfikację i pozwalają markerowi Adobe APP14 wewnątrz danych JPEG mieć pierwszeństwo przed wpisem ze słownika, jak wymaga ISO 32000-1 Table 13. Dwie sąsiednie reguły należą do tej samej rodziny pułapek: strumienie wielofiltrowe muszą nieść tablice /DecodeParms zgrane z etapami, a obrazy JPEG 2000 zachowują jawne mapowanie /Decode

Wszystkie trzy łączy jeden temat. PDF już mówi, co ma na myśli, a pułapką jest renderer odpowiadający na lekko inne pytanie

Dlaczego CMYK JPEG renderuje się w PDF odwrócony albo wyblakły?

CMYK JPEG psuje się w PDF-ie z jednej z dwóch przyczyn i obie wyglądają na ekranie inaczej. Obraz negatywowy, w którym biały papier robi się czarny, a jasne tony ciemne, wskazuje odwrócone zapisanie. Obraz wyblakły albo z przesuniętą barwą, w którym skóra robi się zielona, a czernie szare przy ostrym detalu K, wskazuje transformację barw zastosowaną, gdy nie powinna być, albo pominiętą, gdy była wymagana

Mapa objawów zepsutego CMYK JPEG w PDF Delphi: negatyw wskazuje odwrócone zapisanie Adobe, wyblakłe barwy niedopasowany ColorTransform
negatyw znaczy odwrócone zapisanie proszące o tablicę /Decode, dryf barw znaczy niedopasowaną transformację, a ostra płaszczyzna K wskazuje najpierw transformację

Problem transformacji bierze się z tego, jak JPEG kompresuje barwę. Baseline JPEG kompresuje najlepiej, gdy luminancja jest oddzielona od chrominancji, więc enkodery zwykle konwertują RGB na YCbCr przed kompresją, a enkodery czterokanałowe konwertują CMY na YCC, przepuszczając K nietknięte — to format zwany YCCK. Dekoder musi cofnąć dokładnie tę konwersję, którą zastosował enkoder. Cofnij taką, która nigdy nie nastąpiła, albo pomiń taką, która nastąpiła, i każdy piksel zmienia miejsce. Czarny kanał przeżywa w obu wypadkach, dlatego zepsuty obraz CMYK często trzyma ostrą czarną typografię, podczas gdy cała reszta dryfuje

Problem odwrócenia jest historyczny. Photoshop i inne narzędzia Adobe piszą CMYK JPEG z każdą próbką zapisaną jako 255 - value i oznaczają plik markerem Adobe APP14. Nic w standardzie JPEG tego nie mówi; to konwencja i każdy konsument musi o niej wiedzieć. W PDF od producenta oczekuje się, że odwrócenie wyrazi w słowniku obrazu, zamiast zostawiać je zgadywaniu

Co /ColorTransform mówi dekoderowi?

/ColorTransform to deklaracja na poziomie PDF, której konwersji dekoder musi cofnąć. ISO 32000-1 §7.4.8, Table 13, definiuje dwie wartości. Zero znaczy brak transformacji: dekoder zwraca próbki tak, jak zostały skompresowane. Jeden znaczy, że enkoder zastosował standardową konwersję, więc obraz trójkanałowy wraca z YCbCr do RGB, a czterokanałowy z YCCK do CMYK. Parametr jest ignorowany dla obrazów jedno- i dwukanałowych. Table 13 ustawia też regułę pierwszeństwa, łatwą do przeoczenia: gdy zakodowane dane niosą marker Adobe z własnym kodem transformacji, ten kod rządzi, a wpis ze słownika jest ignorowany. Tylko JPEG bez tego markera spada na słownik, a gdy i wpisu brak, obraz trójkanałowy domyślnie dostaje transformację, a czterokanałowy jej brak

Macierz decyzyjna DCTDecode ColorTransform dla Delphi: marker Adobe APP14 rządzi nad słownikiem, a niezdefiniowane wartości są odrzucane przez HotPDF
marker Adobe wewnątrz danych JPEG bije wpis ze słownika, i tylko JPEG bez markera spada na /ColorTransform albo domyślną wartość z liczby komponentów

Łatwy błąd to ścieżka obrazu, która przyjmuje słownik parametrów, ale nigdy nie podaje go dekoderowi JPEG. Jednopikselowy RGB JPEG z kolorem (128, 63, 31) obnaża to od razu: taki dekoder zwraca [128, 63, 31] i dla /ColorTransform 0, i dla /ColorTransform 1, podczas gdy poprawna odpowiedź dla zera to surowa trójka YCbCr [79, 101, 163]

HotPDF stosuje parametr na poziomie bajtów. HPDFDecodeHeadlessTerminalImage najpierw przechodzi przez strukturę segmentów JPEG aż do markera start-of-scan i szuka segmentu Adobe APP14; bajty jedynie przypominające go wewnątrz segmentu komentarza się nie liczą. Jeśli dane niosą marker, jego kod transformacji decyduje, a wartość ze słownika jest ignorowana. Jeśli nie niosą, dekoder buduje prywatną kopię JPEG z segmentem APP14 wstawionym tuż przed markerem start-of-scan, z kodem transformacji 0, 1 albo, dla obrazu czterokanałowego z wartością 1, kodem YCCK równym 2. Kiedy efektywna transformacja obrazu trójkanałowego wynosi 0, kopia dodatkowo rozbraja nagłówek JFIF, bo nagłówek JFIF sam z siebie każe libjpeg-turbo zakładać YCbCr. Bufor wywołującego nie jest ruszany, a gdy segment jest wstawiany, jego 16 dodatkowych bajtów obciąża budżet bajtów roboczych, zanim dekoder cokolwiek zaalokuje

Wstawianie tylko przy braku markera ma znaczenie przez detal libjpeg: on używa ostatniego segmentu APP14, jaki widzi. Wstaw segment po istniejącym markerze, a jawna wartość ze słownika po cichu nadpisze konfliktowy marker w danych, czyli dokładnie odwrotnie niż każe Table 13. HotPDF stosuje porządek z Table 13 na pełnej ścieżce image XObject i na ścieżce obrazów inline

Ten sam terminalowy dekoder możesz wołać wprost, gdy pojedynczy obraz wygląda źle i chcesz zobaczyć jego próbki bez renderowania strony. Funkcja mieszka w unit HPDFHeadlessImageCodecs, bierze nazwę filtra, zakodowane bajty, opcjonalny słownik parametrów i geometrię ze słownika obrazu, która musi pasować do nagłówka ramki JPEG

uses
  SysUtils, HPDFObjs, HPDFHeadlessImageCodecs;

// ColorTransform < 0 znaczy „brak wpisu": niech zdecydują markery JPEG
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;

Dekoder jest surowy co do tego, co liczy się jako legalna wartość. Jawne -1, 2 albo niecałkowite jak 0.5 rzuca EHPDFHeadlessImageError zamiast spadać na domyślne, bo wartość niezdefiniowana przez specyfikację nie daje dekoderowi niczego godnego zaufania. Kontrola zostaje w miejscu nawet wtedy, gdy marker Adobe w danych czyni wpis bezprzedmiotowym, bo niezdefiniowana wartość to błąd strukturalny samego dokumentu. Budżet za mały na skonwertowaną kopię zawodzi tak samo, z komunikatem nazywającym budżet bajtów roboczych

Jawne null to inny przypadek, bo nie jest wartością w ogóle. ISO 32000-1 §7.3.9 mówi, że wpis słownika o wartości null równoważny jest wpisowi nieobecnemu, więc /ColorTransform null spada na marker albo na domyślną wartość z liczby komponentów dokładnie tak, jakby klucza nie było. HotPDF traktuje to tak na każdej ścieżce, headless i VCL, strumieniowej i inline

Jeszcze jeden domyślny wybór zasługuje na wzmiankę, bo to miejsce, gdzie HotPDF celowo wykracza poza literę Table 13. JPEG o trzech komponentach bez nagłówka JFIF i bez markera Adobe, którego nagłówek ramki nazywa komponenty R, G i B, mówi własnymi bajtami, że trzyma RGB. Table 13 nadal przydzieliłaby mu domyślną transformację, ale libjpeg i czytniki na nim zbudowane dekodują go bez transformacji, i HotPDF też. Dekodery headless i VCL traktują go tak samo, więc czerwień i błękit zostają tam, gdzie umieściły je dane. Jawne 0 albo 1 ze słownika nadal nadpisuje ten wniosek

uses
  System.IOUtils;

var
  Jpeg, Stored, Converted, ByMarkers: TBytes;
begin
  // surowe bajty DCTDecode obrazu 640 x 480 CMYK, zapisane ze strumienia PDF
  Jpeg := TFile.ReadAllBytes('suspect-image.jpg');
  try
    // Przy markerze Adobe APP14 w danych wszystkie trzy wywołania się zgadzają:
    // marker decyduje, a wartość ze słownika jest ignorowana
    Stored    := DecodeDCT(Jpeg, 640, 480, 4, 0);   // bez markera: próbki jak skompresowano
    Converted := DecodeDCT(Jpeg, 640, 480, 4, 1);   // bez markera: YCCK cofnięte do CMYK
    ByMarkers := DecodeDCT(Jpeg, 640, 480, 4, -1);  // bez markera: domyślne czterokanałowe, nic
    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;

Czterokanałowy piksel referencyjny pokazuje, na co patrzeć. Piksel CMYK (10, 20, 30, 40) zapisany jako YCCK wraca jako [245, 235, 225, 215], gdy konwersja YCCK nie została cofnięta, i jako [0, 116, 0, 215], gdy została. Bajt K, 215, jest identyczny w obu. Jeśli płaszczyzna K wygląda dobrze, a pozostałe trzy nie, niedopasowana transformacja to pierwszy podejrzany

Odwrócona konwencja CMYK należy do /Decode

Terminalowy dekoder HotPDF zwraca próbki CMYK dokładnie tak, jak produkuje je libjpeg-turbo, i nie odwraca ich na podstawie markera Adobe. Liczby powyżej czynią konwencję widoczną: 245, 235, 225, 215 to po prostu 255 minus 10, 20, 30, 40, odwrócona forma, którą zapisują enkodery w stylu Adobe. Odwrócenie tego z powrotem na ilości farby to robota słownika obrazu, gdzie producent osadzający taki JPEG powinien napisać /Decode [1 0 1 0 1 0 1 0]. Renderer aplikuje tablicę /Decode do każdego komponentu, więc poprawnie napisany plik drukuje się poprawnie

Drugą stronę warto powiedzieć wprost. PDF osadzający odwrócone po adobe'owsku CMYK JPEG bez pasującej tablicy /Decode wyrenderuje się jako negatyw w HotPDF, bo plik sam opisuje obraz w ten sposób. Niektóre przeglądarki wykrywają marker APP14 i po cichu kompensują. HotPDF nie dodaje tej heurystyki, więc naprawa takiego pliku należy do oprogramowania, które go napisało

Dlaczego jeden słownik DecodeParms dla dwóch filtrów to błąd?

Strumień, którego /Filter jest tablicą kilku etapów, musi podać /DecodeParms jako tablicę tej samej długości, po jednym słowniku parametrów albo null na etap. ISO 32000-1 §7.3.8.2, Table 5, stanowi tę regułę i HotPDF jej pilnuje: strumień wielofiltrowy podający jeden współdzielony słownik jest raportowany jako nieważny dokument zamiast być dekodowany

Pobłażliwa alternatywa jest gorsza, niż wygląda. Dekoder sprawdzający długość tylko wtedy, gdy /DecodeParms już jest tablicą, przy jednym słowniku i dwóch filtrach po cichu użyje tego słownika na każdym etapie. Gdy słownik jest pusty, to nieszkodliwe, a gdy nie jest — niszczące: /Predictor 12 przeznaczony dla wewnętrznego etapu Flate zostanie też zastosowany do zewnętrznego, a wyjście robi się wyglądającymi wiarygodnie śmieciami zamiast czystego błędu. Ponieważ intencji takiego pliku nie da się odzyskać, HotPDF go odmawia, nawet w przypadku pustym, jak strumień zawartości strony z /Filter [/FlateDecode /FlateDecode] i /DecodeParms << >>

Kształty DecodeParms przyjmowane przez HotPDF dla strumieni PDF wielofiltrowych w Delphi: zgrane tablice przechodzą, współdzielony słownik albo zła długość tablicy są odrzucane
strumień wielofiltrowy potrzebuje słownika albo null na etap, a współdzielony słownik jest odmawiany nawet gdy jest pusty

Przyjmowane i odrzucane kształty da się wyliczyć:

  • Akceptowane: /DecodeParms pominięty albo null, dla dowolnej liczby filtrów
  • Akceptowane: tablica o długości równej liczbie filtrów, każdy wpis słownikiem albo null
  • Akceptowane: pojedynczy słownik, gdy /Filter nazywa dokładnie jeden filtr
  • Odrzucane: pojedynczy słownik współdzielony przez dwa lub więcej filtrów, z diagnostyką "Stream filter chain requires array DecodeParms"
  • Odrzucane: tablica o długości różnej od liczby filtrów, z komunikatem "DecodeParms array must match the filter chain"

Ta sama kontrola działa dla zwykłych strumieni zawartości i dla strumieni obrazów, których ostatni filtr to terminalowy kodek taki jak DCTDecode, więc zepsuty łańcuch pada w tym samym miejscu niezależnie od tego, co strumień niesie. Interakcję z limitami dekompresji opisuje artykuł o budżetach łańcuchów filtrów i bombach dekodowania PDF. Z kodu aplikacji błąd wychodzi na wierzch przy renderowaniu strony

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;

Obrazy JPEG 2000 zachowują jawny /Decode

Obraz JPEG 2000 ignoruje /Decode tylko wtedy, gdy jego słownik nie ma /ColorSpace i nie jest maską obrazu. ISO 32000-1 §8.9.5 robi z tego warunek i HotPDF go stosuje. Wyrzucanie /Decode dla każdego obrazu JPX niebędącego stencil to łatwy skrót i sprawia, że jawne odwrócenie po prostu znika: próbka JPX DeviceGray 64 pod /Decode [1 0] ma wyrenderować się jako 191, nie 64

Trafienie w ten jeden warunek to za mało. Gałęzie przestrzeni barw jak Lab i ICCBased, które ustawiają domyślne zakresy komponentów ilekroć obraz jest JPX, nadpisałyby legalnie zastosowaną tablicę drugi raz, więc renderer zapisuje, czy ważna tablica /Decode faktycznie została zastosowana, i uzupełnia domyślne zakresy tylko, gdy żadnej nie było. Jawne zakresy Lab i ICCBased więc przeżywają, a deklaracja barwy z codestream nadal rządzi, gdy słownik milczy. Stronę dekodera tego formatu opisuje artykuł o JPEG 2000 i JPXDecode

Które ścieżki dekodowania stosują te reguły?

Oba pipeline'y je stosują. Po stronie headless oznacza to ładowanie THPDFHeadlessDocument, współdzielony dekoder strumieni i oparty na Cairo renderer stron za HPDFRenderHeadlessPagePNGFile. Po stronie VCL oznacza to ekstrakcję wczytanych obrazów do TBitmap i renderowanie stron VCL, które aplikują ten sam porządek z Table 13 przy dekodowaniu pełnego obrazu i zredukowanego regionu, więc nagłówek JFIF nie zdoła narzucić YCbCr ponad adobe'owską transformację 0, a nielegalna wartość czyni obraz niedekodowalnym też tam. Obrazy DCT inline stosują te same reguły markera, null, domyślnych i odrzuceń co image XObject, ze skróconymi i pełnymi nazwami kluczy, łącznie ze słownikiem /DP << … >> i skróconymi tablicami filtrów jak /F [/AHx /DCT], a obraz inline z nielegalnymi parametrami jest pomijany, podczas gdy reszta strony się renderuje. Przewodnik po wyciąganiu obrazów z wczytanego PDF-a opisuje resztę obsługi filtrów tego API

Kilka praktycznych notatek, zanim na tym zachowaniu sobie oprzesz:

  • Dekodowanie JPEG potrzebuje w runtime zgodnej biblioteki TurboJPEG; HOTPDF_TURBOJPEG_LIBRARY nadpisuje domyślne turbojpeg.dll albo libturbojpeg.so.0
  • Surowe kontrole strukturalne odrzucają pliki, które pobłażliwe czytniki otwierają, w szczególności współdzielony pusty słownik /DecodeParms, a naprawa takich plików należy po stronie producenta

Dla workflow druku zarządzanego kolorem praktyczny wniosek jest krótki. Pisz CMYK JPEG z jawnym /ColorTransform zgadzającym się z każdym markerem Adobe w danych plus tablicą /Decode pasującą do ich zapisania, dawaj każdemu strumieniowi wielofiltrowemu tablicę /DecodeParms zgraną z etapami i oczekuj, że HotPDF będzie trzymał pliki przy tym, co faktycznie mówią

Headless loader, dekodery strumieni i renderer stron Cairo pokazane tutaj płyną z komponentem HotPDF Delphi PDF dla Delphi, C++Builder i Lazarus, obok klasycznego silnika dokumentów VCL