Technical Article

When JPEG 2000 Images Shift Color or Turn Black in CMYK Conversion

Convert a print job to CMYK and JPEG 2000 pictures are often the only casualties: a red logo turns blue, gray drifts toward magenta, a cut-out shot lands in a black box. PDFium Component's document color conversion, ConvertPdfDocumentColours, keeps them right when the PDF /ColorSpace, JP2 colr boxes and CDEF channel definitions each decide only their own part

Who decides what inside a JPX image

A JPXDecode image describes itself: the SIZ segment carries each channel's precision and signedness, colr boxes carry color declarations, CDEF assigns channel roles, and any of them can disagree with the dictionary around it. ISO 32000-1 §7.4.9 settles who wins: a /ColorSpace in the image dictionary overrides every color specification inside the JPEG 2000 data, embedded ICC profile included, the decoder rather than /BitsPerComponent determines the depth, and PDF admits only baseline JPX features. Opacity runs on its own track, declared by CDEF and requested through the image dictionary's /SMaskInData entry (§8.9.5). ConvertPdfDocumentColours divides the work like this:

SourceWhat it decidesPrecedenceWhat the converter does
PDF /ColorSpaceThe meaning of the decoded color channelsAlways beats the JP2 colr boxesUses it as declared and still looks for an opacity channel in the stream
JP2 colr boxesThe meaning of the channels when /ColorSpace is absentHighest PRECEDENCE, lowest APPROX among equals, file order lastTries the candidates in that order and skips any it cannot use
SIZ precisionReal bit depth and signedness per channelBeats /BitsPerComponentDecodes to an 8-bit buffer when every channel is 8 bits or less, 16-bit otherwise
/DecodeNothing for an ordinary JPX image; inversion for an image maskIgnored by §7.4.9Removes the entry after a successful JPX decode
CDEF boxWhich channel is color, opacity (typ 1) or premultiplied opacity (typ 2)Defines the alpha; with CMAP present it names channelsDivides typ 2 color back by alpha and writes the alpha as an 8-bit DeviceGray /SMask
/SMaskInDataWhether the PDF asks for the embedded maskMust agree with what CDEF actually containsAccepts 1 only when an opacity channel exists, refuses 2, drops the key once /SMask is written

The /Decode row produces the most visible damage when a converter ignores it: [1 0 1 0 1 0] on a JPX image looks like an inversion request, and applying it before the ICC transform prints a negative that conforming viewers never showed. The bit-depth row does its damage quietly. A producer can legally write /BitsPerComponent 8 over 12-bit channels or omit the key, so PDFium Component sizes its buffers from SIZ, shifts each signed channel by 2^(p-1) at its own precision p, and keeps one denominator per channel when precisions are mixed. Divide a 5-bit channel by its 12-bit neighbor's maximum and it sinks into the bottom 1% of the range

Why does a JPEG 2000 image change color after CMYK conversion?

Almost every color cast traces back to interpretation: the decoded channels were read with the wrong meaning before any ICC math ran. Without /ColorSpace, the meaning comes from the colr boxes, and a JP2 file may carry several. The JPX extension (ITU-T T.801) gives each box a signed PRECEDENCE and an APPROX accuracy grade; the converter takes the highest precedence, breaks ties with the lower APPROX, and falls back on file order. An embedded ICC profile (method 2 or 3) must match the image's channel count, describe RGB, gray or CMYK data, and be accepted by the color engine, while enumerated spaces such as sRGB (16), greyscale (17), sYCC (18), CMYK (12) and CIELab (14) have conditions of their own. A failed candidate gives way to the next, so a damaged ICC box with a valid sRGB enumeration behind it still converts. Note that sRGB 16 always means standard sRGB, whatever your DeviceRGBProfile says, and greyscale 17 follows the sRGB tone curve instead of a plain gamma 2.2

sYCC produces both the loudest and the quietest casts. The decoder undoes any codestream multi-component transform by itself; an sYCC colr box is a separate statement that the decoded values are still Y, Cb and Cr. Read them as RGB and 8-bit sRGB red (255, 0, 0), stored as about Y 76, Cb 85, Cr 255, comes out saturated blue. The quiet cast comes from the chroma midpoint, which is 2^(p-1) at the channel's original precision p, while the decoder hands back every channel scaled into a shared 8- or 16-bit buffer. Subtract 128 from that buffer and a 4-bit chroma channel is 8 levels off on both Cb and Cr; through the sYCC matrix (R = Y + 1.402 Cr, G = Y − 0.344136 Cb − 0.714136 Cr, B = Y + 1.772 Cb) a mid-gray of 128 lands near (139, 120, 142). Expected: a magenta cast on every neutral, smaller at higher precision but never zero, since 2^(p-1)/(2^p − 1) always exceeds one half by 0.5/(2^p − 1). PDFium Component maps each sample back to its original code and subtracts and normalizes with that channel's own midpoint and maximum before the matrix and the built-in sRGB profile run. ITU-T T.800 Annex I defines sYCC for unsigned channels only, so a declaration over signed channels is passed over for a lower-ranked one

When the JPX metadata is missing

All of this relies on color and opacity metadata that the native bridge, PdfiumPasImageComponents.dll, reports beyond plain pixel decoding. A bridge build without those metadata functions, such as a different copy earlier on the DLL search path, still loads and still decodes images that declare their color explicitly, and asking the decoder for the metadata interface still succeeds. Only a real inspection call answers "Image colour metadata is unavailable". The converter reports that image instead of treating it as undeclared. When inspection works and no declaration is usable, AllowJpxDeviceFallback decides whether the channel count picks DeviceGray, DeviceRGB or DeviceCMYK, resolved through the page's Default color spaces; it defaults to True and counts each such image in Report.JpxDeviceFallbackImages. For PDF/X work, set it to False. The fallback encodes an unreviewed assumption about the producer's intent, and a log counter protects nobody unless it is read before plates are made

uses
  System.SysUtils, System.Classes, System.IOUtils,
  FPdfProduction, FPdfImageComponents, FPdfColourLittleCms, FPdfDocumentColour;

function ConvertJobToPress(const InputFile, OutputFile, PressIccFile: string;
  Log: TStrings): Boolean;
var
  Source: TFileStream;
  Converted: TMemoryStream;
  PressProfile: TBytes;
  Transform: IPdfColourTransform;
  Options: TPdfDocumentColourOptions;
  Report: TPdfDocumentColourReport;
  Issue: TPdfDocumentColourIssue;
begin
  PressProfile := TFile.ReadAllBytes(PressIccFile);
  Transform := CreatePdfLittleCmsColourTransform('PdfiumPasLittleCms2.dll',
    TPdfLittleCmsOptions.Default);

  Options := TPdfDocumentColourOptions.Default;
  Options.ImageDecoder := CreatePdfImageComponentDecoder(
    'PdfiumPasImageComponents.dll', TPdfImageComponentOptions.Default);
  Options.DeviceCMYKProfile := PressProfile; // untagged CMYK already targets this press
  Options.AllowJpxDeviceFallback := False;   // undeclared JPX color is reported, not guessed
  // FailOnUnsupported keeps its default of True: one unsupported object stops the run

  Source := TFileStream.Create(InputFile, fmOpenRead or fmShareDenyWrite);
  try
    Converted := TMemoryStream.Create;
    try
      Result := ConvertPdfDocumentColours(Source, Converted, pcsDeviceCMYK,
        PressProfile, Transform, Options, Report);
      Log.Add(Format('Images converted: %d, JPX device fallbacks: %d',
        [Report.ImagesConverted, Report.JpxDeviceFallbackImages]));
      for Issue in Report.Issues do
        Log.Add(Format('  object %d: %s', [Issue.ObjectNumber, Issue.Reason]));
      if Result then
        Converted.SaveToFile(OutputFile)
      else
        Log.Add('Conversion refused: ' + Report.ErrorText);
    finally
      Converted.Free;
    end;
  finally
    Source.Free;
  end;
end;

Since PDF admits only baseline JPX, a CRG marker with nonzero component registration offsets has no place in a PDF image; the bridge reads the main header marker by marker and rejects such offsets, which OpenJPEG would otherwise decode as if they were absent. OpenJPEG 2.5 also parses a 31-bit SIZ header cleanly while returning near-midpoint samples, so the bridge accepts channel precisions up to 24 bits and reports deeper ones at inspection. When a supplier's file is refused, pass the JPXDecode payload or the original .jp2 asset to the same decoder: the routine below lists the color channels, every colr declaration and the alpha type, plus the codec versions for your support log

uses
  System.SysUtils, System.StrUtils, System.Classes, System.IOUtils,
  FPdfImageComponents;

procedure DescribeJpxAsset(const Decoder: IPdfImageComponentDecoder;
  const FileName: string; Log: TStrings);
var
  Encoded: TBytes;
  Colours: IPdfImageColourMetadata;
  Opacity: IPdfImageOpacity;
  Info: TPdfImageComponentInfo;
  Specs: TPdfImageColourSpecifications;
  Alpha: TPdfImageOpacityInfo;
  ErrorText: string;
  I: Integer;
begin
  Encoded := TFile.ReadAllBytes(FileName); // JP2 container or raw codestream
  if not Supports(Decoder, IPdfImageColourMetadata, Colours) or
     not Colours.InspectColours(Encoded, Info, Specs, ErrorText) then
  begin
    Log.Add(FileName + ': no colour metadata (' + ErrorText + ')');
    Exit;
  end;
  Log.Add(Format('%s: %d x %d, %d colour channel(s), %d-bit buffer',
    [FileName, Info.Width, Info.Height, Info.Components, Info.DecodedBitsPerComponent]));
  for I := 0 to High(Info.Channels) do
    Log.Add(Format('  channel %d: %d-bit %s, sampling %d x %d',
      [I, Info.Channels[I].BitsPerComponent,
       IfThen(Info.Channels[I].IsSigned, 'signed', 'unsigned'),
       Info.Channels[I].HorizontalSampling, Info.Channels[I].VerticalSampling]));
  for I := 0 to High(Specs) do
    Log.Add(Format('  colr %d: method %d, precedence %d, approx %d, enum %d',
      [I, Specs[I].Method, Specs[I].Precedence, Specs[I].Approximation,
       Integer(Specs[I].EnumeratedColorSpace)]));
  if Supports(Decoder, IPdfImageOpacity, Opacity) and
     Opacity.InspectOpacity(Encoded, Alpha, ErrorText) then
    Log.Add('  opacity: ' + IfThen(not Alpha.Present, 'none',
      IfThen(Alpha.Premultiplied, 'premultiplied (CDEF typ 2)', 'raw (CDEF typ 1)')))
  else
    Log.Add('  opacity metadata unavailable (' + ErrorText + ')');
  Log.Add('  codecs: ' + PdfImageComponentBackendVersions('PdfiumPasImageComponents.dll'));
end;

What turns transparent JPX areas into black boxes?

A lost alpha channel, which exposes color the encoder never meant anyone to see. ITU-T T.800 Annex I defines the Channel Definition box: each entry names a channel and gives it a type, 0 for color, 1 for opacity and 2 for premultiplied opacity, plus an association, where 0 means the whole image. With typ 2 the stored color is C × α, so every fully transparent pixel is stored as black. Drop the alpha and those pixels become an opaque black rectangle; skip the un-premultiply and antialiased edges darken into a fringe. A converter that ignores CDEF and counts four channels reads RGBA as CMYK, alpha lands in K, and every fully opaque pixel prints as 100% black ink. PDFium Component removes the opacity channel from the color set before choosing an interpretation, divides each color sample back by its alpha (zero alpha gives zero color) before any ICC transform, and writes the untouched alpha as an 8-bit DeviceGray /SMask. The order is forced by the math: an ICC transform is nonlinear, so converting C × α is not the same as converting C and scaling the result by α

/SMaskInData is the PDF's half of the same contract, read strictly. A value of 1 says the image data contains soft-mask values; ConvertPdfDocumentColours accepts it only when inspection really finds a CDEF opacity channel, since a flag promising alpha the stream lacks is malformed input that an all-opaque substitute mask would hide. A value of 2 says the color has been preblended with a background, and undoing that blend requires knowing the background, so such images are reported, as is an existing /SMask carrying /Matte. An explicit /ColorSpace overrides the JPX color declaration and has no say over opacity: over an RGBA codestream, the converter still reads the stream with the declared width, height and component count, finds the opacity channel and builds the same soft mask

Palette channels and subsampled alpha

Palette images add a level of indirection that is easy to collapse by accident. In a JP2 file with a PCLR palette, the CMAP box maps codestream components, directly or through palette columns, into channels, and CDEF describes those channels:

  • With CMAP present, a CDEF number is a channel number, and the order of CDEF entries carries no meaning. Reading entry i as codestream component i, or as "the i-th accepted entry", pairs the wrong plane with the alpha once a palette or a permuted list is involved. When the alpha comes from a palette column, the component sample is an index checked against that component's own precision and signedness, and the column value is normalized by the column's precision
  • Many encoders default four-component JP2 files to the CMYK enumeration (12). When CDEF marks the fourth channel as opacity, three color channels remain, the CMYK label no longer fits and is skipped, and the image takes another declaration or lands in the device fallback, the case AllowJpxDeviceFallback and JpxDeviceFallbackImages are there for

Subsampled alpha follows the color planes. JPEG 2000 lets an opacity channel sit at a coarser sampling interval than the color, and the soft mask must land on the same reference grid as the image it cuts out. The converter gives each reference pixel the value of the source cell that contains it, the same rule it applies to subsampled color planes, and does not interpolate. A bilinear mask looks smoother at 2:1, but its edge stops coinciding with the color samples underneath, and at a hard cut-out the mismatch shows up as a thin line of background inside the object or object color outside it. Shrinking the converted image later raises other alpha questions, covered in the article on alpha-aware adaptive image resampling in Delphi

Run the first conversion of a new supplier's files with the device fallback disabled and read every reported object before relaxing anything; if gradients on the same pages shift too, the shading function conversion to CMYK goes through the same call in PDFium Component for Delphi