Техническая статья

CMYK JPEG в PDF: DCTDecode ColorTransform на Delphi

CMYK JPEG, вышедший из PDF с грязными, смещёнными или отрицательными цветами, почти всегда означает, что декодер перепутал один из двух фактов: хранились ли отсчёты как YCCK с необходимостью конверсии, и хранились ли они инвертированными по Adobe-соглашению. Декодеры HotPDF соблюдают параметр /ColorTransform фильтра DCTDecode для трёх- и четырёхканальных изображений, отвергают значения, которых спецификация не определяет, и позволяют маркеру Adobe APP14 внутри JPEG-данных иметь приоритет над записью словаря, как того требует ISO 32000-1 Table 13. Два соседних правила из той же семьи ловушек: multi-filter потоки обязаны нести массивы /DecodeParms, выровненные по стадиям, а JPEG 2000 сохраняет явное отображение /Decode

Все три случая объединяет тема: PDF уже сказал, что имеет в виду, а ловушка — рендерер, отвечающий на чуть другой вопрос

Почему CMYK JPEG рендерится в PDF инверсией или выцветшим?

CMYK JPEG ломается в PDF по одной из двух причин, и на экране они выглядят по-разному. Негатив, где белая бумага становится чёрной, а светлые оттенки темнеют, указывает на инвертированное хранение. Выцветшее или смещённое по оттенку изображение, где кожа зеленеет, а чёрный сереет при резком K-канале, указывает на трансформацию цвета, применённую зря, или пропущенную там, где была обязательна

Карта симптомов сломанного CMYK JPEG в PDF на Delphi: негатив указывает на инвертированное Adobe-хранение, выцветшие оттенки — на неподходящий ColorTransform
негатив означает инвертированное хранение и просит массив /Decode, дрейф оттенков — неподходящую трансформацию, а резкий K-канал указывает прежде всего на трансформацию

Проблема трансформации растёт из того, как JPEG сжимает цвет. Baseline JPEG сжимает лучше всего, когда яркость отделена от хромы, поэтому энкодеры обычно конвертируют RGB в YCbCr перед сжатием, а четырёхканальные энкодеры переводят CMY в YCC, пропуская K нетронутым, — это формат, называемый YCCK. Декодер обязан отменить ровно ту конверсию, которую применил энкодер. Отмените несостоявшуюся или пропустите состоявшуюся — и сдвинется каждый пиксель. Чёрный канал переживает оба сценария, потому у сломанного CMYK часто остаётся резкий чёрный текст, а дрейфует всё остальное

Проблема инверсии историческая. Photoshop и другие Adobe-инструменты пишут CMYK JPEG с каждым отсчётом, сохранённым как 255 - value, и помечают файл маркером Adobe APP14. Ничто в стандарте JPEG этого не требует — это соглашение, и каждый потребитель обязан о нём знать. В PDF от производителя ожидается, что инверсию он выразит в словаре изображения, а не оставит на догадках

Что декодеру говорит /ColorTransform?

/ColorTransform — утверждение уровня PDF о том, какую конверсию декодер обязан отменить. ISO 32000-1 §7.4.8, Table 13, определяет два значения. Ноль — никакой трансформации: декодер возвращает отсчёты как сжато. Единица — энкодер применил стандартную конверсию, поэтому трёхканальное изображение идёт из YCbCr назад в RGB, а четырёхканальное — из YCCK назад в CMYK. Для одно- и двухканальных изображений параметр игнорируется. Table 13 также задаёт лёгкое к пропуску правило приоритета: когда кодированные данные несут Adobe-маркер с собственным кодом трансформации, управляет он, а запись словаря игнорируется. Только JPEG без такого маркера откатывается к словарю, а когда нет и записи — трёхканальное изображение по умолчанию получает трансформацию, четырёхканальное — нет

Матрица решений DCTDecode ColorTransform для Delphi: маркер Adobe APP14 главнее словаря, а неопределённые значения HotPDF отвергает
Adobe-маркер внутри JPEG-данных старше записи словаря, и только JPEG без него откатывается к /ColorTransform или умолчанию по числу компонент

Лёгкая ошибка — путь изображения, принимающий словарь параметров, но так и не передающий его JPEG-декодеру. RGB JPEG в один пиксель цвета (128, 63, 31) вскрывает это немедленно: такой декодер вернёт [128, 63, 31] и для /ColorTransform 0, и для /ColorTransform 1, тогда как правильный ответ для нуля — сырая YCbCr-тройка [79, 101, 163]

HotPDF применяет параметр на уровне байтов. HPDFDecodeHeadlessTerminalImage сперва проходит структуру JPEG-сегментов до маркера start-of-scan и ищет сегмент Adobe APP14; байты, лишь похожие на него внутри сегмента комментария, не считаются. Если данные несут маркер, его код трансформации решает, а значение словаря игнорируется. Если не несут, декодер собирает приватную копию JPEG с вставленным сегментом APP14 прямо перед маркером start-of-scan — с кодом трансформации 0, 1 или, для четырёхканального изображения со значением 1, YCCK-кодом 2. Когда эффективная трансформация трёхканального изображения равна 0, копия заодно обезоруживает заголовок JFIF: JFIF-заголовок сам по себе велит libjpeg-turbo предполагать YCbCr. Буфер вызывающего не трогается, а когда сегмент вставлен, его 16 лишних байтов списываются с рабочего байтового бюджета до любых выделений декодера

Вставка только при отсутствии маркера важна из-за детали libjpeg: тот использует последний увиденный сегмент APP14. Вставьте сегмент после существующего маркера — и явное значение словаря молча перебьёт противоречащий маркер в данных, что ровно обратно правилу Table 13. HotPDF соблюдает порядок Table 13 на полном пути image XObject и на пути inline-изображений одинаково

Тот же терминальный декодер можно вызвать напрямую, когда одно изображение выглядит неправильно и хочется увидеть его отсчёты без рендеринга страницы. Функция живёт в юните HPDFHeadlessImageCodecs, принимает имя фильтра, кодированные байты, опциональный словарь параметров и геометрию из словаря изображения, которая обязана совпадать с заголовком кадра JPEG

uses
  SysUtils, HPDFObjs, HPDFHeadlessImageCodecs;

// ColorTransform < 0 означает "записи нет": решают маркеры 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;

Декодер строг к тому, что считать легальным значением. Явный -1, 2 или нецелое вроде 0.5 поднимают EHPDFHeadlessImageError вместо отката к умолчанию: значение, которого спецификация не определяет, не даёт декодеру ничего надёжного, на что можно опереться. Проверка остаётся на месте даже когда Adobe-маркер в данных делает запись неважной — неопределённое значение структурная ошибка самого документа. Бюджет, слишком маленький для трансформированной копии, падает так же, с сообщением, называющим рабочий байтовый бюджет

Явный null — другой случай, потому что это вовсе не значение. ISO 32000-1 §7.3.9 говорит, что запись словаря со значением null эквивалентна отсутствующей, поэтому /ColorTransform null проваливается к маркеру или умолчанию по числу компонент ровно как при отсутствующем ключе. HotPDF трактует его так на каждом пути — headless и VCL, потоковом и inline

Ещё одно умолчание заслуживает упоминания, потому что HotPDF здесь намеренно идёт дальше буквы Table 13. Трёхкомпонентный JPEG без JFIF-заголовка и без Adobe-маркера, чей заголовок кадра подписывает компоненты R, G и B, самими своими байтами говорит, что держит RGB. Table 13 всё равно назначила бы ему трансформацию, но libjpeg и построенные на нём ридеры декодируют его без трансформации — и HotPDF тоже. Оба декодера, headless и VCL, трактуют его так, поэтому красный и синий остаются там, куда их положили данные. Явные словарные 0 или 1 по-прежнему перебивают этот вывод

uses
  System.IOUtils;

var
  Jpeg, Stored, Converted, ByMarkers: TBytes;
begin
  // сырые байты DCTDecode изображения CMYK 640 x 480, сохранённые из PDF-потока
  Jpeg := TFile.ReadAllBytes('suspect-image.jpg');
  try
    // С маркером Adobe APP14 в данных все три вызова согласны:
    // решает маркер, значение словаря игнорируется
    Stored    := DecodeDCT(Jpeg, 640, 480, 4, 0);   // без маркера: отсчёты как сжаты
    Converted := DecodeDCT(Jpeg, 640, 480, 4, 1);   // без маркера: YCCK отменён к CMYK
    ByMarkers := DecodeDCT(Jpeg, 640, 480, 4, -1);  // без маркера: умолчание на 4 канала, ничего
    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;

Референсный четырёхканальный пиксель показывает, на что смотреть. CMYK-пиксель (10, 20, 30, 40), сохранённый как YCCK, возвращается как [245, 235, 225, 215], когда конверсия YCCK не отменена, и как [0, 116, 0, 215], когда отменена. K-байт, 215, в обоих случаях одинаков. Если K-плоскость выглядит верно, а остальные три нет — первым подозреваемым будет неподходящая трансформация

Инвертированное CMYK-соглашение живёт в /Decode

Терминальный декодер HotPDF возвращает CMYK-отсчёты ровно как их выдаёт libjpeg-turbo и не переворачивает их на основании Adobe-маркера. Числа выше делают соглашение видимым: 245, 235, 225, 215 — это просто 255 минус 10, 20, 30, 40, инвертированная форма, которую хранят энкодеры в стиле Adobe. Вернуть это в количества краски — работа словаря изображения, где производитель, встраивающий такой JPEG, должен написать /Decode [1 0 1 0 1 0 1 0]. Рендерер применяет массив /Decode к каждой компоненте, поэтому правильно написанный файл печатается правильно

Обратная сторона стоит прямых слов. PDF, встроивший Adobe-инвертированный CMYK JPEG без подобающего массива /Decode, отрендерится в HotPDF как негатив — потому что сам файл описывает изображение именно так. Некоторые вьюеры обнаруживают маркер APP14 и молча компенсируют. HotPDF такую эвристику не добавляет, поэтому починка такого файла живёт в софте, который его написал

Почему один словарь DecodeParms на два фильтра — ошибка?

Поток, чей /Filter — массив из нескольких стадий, обязан подавать /DecodeParms массивом той же длины: по словарю параметров или null на стадию. Это правило формулирует ISO 32000-1 §7.3.8.2, Table 5, и HotPDF его обеспечивает: multi-filter поток с одним общим словарём сообщается как недопустимый документ вместо декодирования

Снисходительная альтернатива хуже, чем кажется. Декодер, проверяющий длину только когда /DecodeParms уже массив, при одном словаре и двух фильтрах тихо переиспользует словарь на каждой стадии. С пустым словарём это безвредно, с непустым — разрушительно: /Predictor 12, предназначенный для внутреннего Flate-этапа, применится и к внешнему, и на выходе получится правдоподобная на вид ерунда вместо чистой ошибки. Поскольку намерение такого файла не восстановить, HotPDF его отвергает — даже в пустом случае, вроде content-потока страницы с /Filter [/FlateDecode /FlateDecode] и /DecodeParms << >>

Формы DecodeParms, которые HotPDF принимает для multi-filter PDF-потоков в Delphi: выровненные массивы проходят, общий словарь или неверная длина массива отвергаются
multi-filter потоку нужен словарь или null на каждую стадию, а общий словарь отвергается, даже когда он пуст

Принимаемые и отвергаемые формы легко перечислить:

  • Принимается: /DecodeParms опущен или null при любом числе фильтров
  • Принимается: массив, чья длина равна числу фильтров, каждая запись — словарь или null
  • Принимается: одиночный словарь, когда /Filter называет ровно один фильтр
  • Отвергается: одиночный словарь, разделённый двумя и более фильтрами, с диагностикой "Stream filter chain requires array DecodeParms"
  • Отвергается: массив, чья длина отличается от числа фильтров, с "DecodeParms array must match the filter chain"

Та же проверка гоняется для обычных content-потоков и для потоков изображений, чей последний фильтр — терминальный кодек вроде DCTDecode, поэтому кривая цепочка падает в одном месте, что бы ни нёс поток. Взаимодействие с лимитами декомпрессии раскрыто в статье о бюджетах цепочек фильтров и PDF-бомбах декодирования. Из кода приложения ошибка всплывает при рендеринге страницы

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 хранит явный /Decode

JPEG 2000 игнорирует /Decode, только когда в его словаре нет /ColorSpace и он не image mask. Это условность ISO 32000-1 §8.9.5, и HotPDF её соблюдает. Выбрасывать /Decode для каждого не-stencil JPX — лёгкий путь, и он заставляет явную инверсию просто исчезнуть: JPX-сэмпл DeviceGray 64 под /Decode [1 0] обязан отрендериться как 191, а не 64

Поймать одно это условие недостаточно. Ветви color space вроде Lab и ICCBased, выставляющие диапазоны компонент по умолчанию при каждом JPX-изображении, перезаписали бы законно применённый массив вторично, поэтому рендерер запоминает, был ли реально применён валидный массив /Decode, и подставляет диапазоны по умолчанию, только если никакой не был. Явные диапазоны Lab и ICCBased поэтому выживают, а при молчащем словаре по-прежнему управляет декларация цвета кодового потока. Сторона декодера формата описана в статье о JPEG 2000 и JPXDecode

Какие пути декодирования следуют этим правилам?

Оба конвейера. На headless-стороне это загрузка THPDFHeadlessDocument, общий потоковый декодер и основанный на Cairo рендерер страниц за HPDFRenderHeadlessPagePNGFile. На VCL-стороне — извлечение загруженных изображений в TBitmap и VCL-рендеринг страниц, применяющие тот же порядок Table 13 при декодировании полных изображений и уменьшенных областей, поэтому JFIF-заголовок не продавит YCbCr против Adobe-трансформации 0, а нелегальное значение делает изображение недекодируемым и там. Inline DCT-изображения следуют тем же правилам маркера, null, умолчаний и отказов, что и image XObject, с сокращёнными и полными именами ключей — включая словарь /DP << … >> и сокращённые массивы фильтров вроде /F [/AHx /DCT], — а inline-изображение с нелегальными параметрами пропускается, пока остальная страница рендерится. Остальное поведение фильтров этого API раскрывает гид по извлечению изображений из загруженного PDF

Несколько практических заметок, прежде чем полагаться на это поведение:

  • Декодирование JPEG требует совместимой библиотеки TurboJPEG в рантайме; HOTPDF_TURBOJPEG_LIBRARY переопределяет умолчальные turbojpeg.dll или libturbojpeg.so.0
  • Строгие структурные проверки отвергают файлы, которые снисходительные ридеры открывают, — в первую очередь общий пустой словарь /DecodeParms; починка таких файлов — на стороне производителя

Для color-managed печатного конвейера практический вывод короток. Пишите CMYK JPEG с явным /ColorTransform, согласным с любым Adobe-маркером в данных, плюс массивом /Decode, отвечающим их хранению; давайте каждому multi-filter потоку выровненный по стадиям массив /DecodeParms; и ждите, что HotPDF будет требовать от файлов того, что они на самом деле говорят

Headless-загрузчик, потоковые декодеры и Cairo-рендерер страниц из этой статьи поставляются с HotPDF Delphi PDF component для Delphi, C++Builder и Lazarus — рядом с классическим VCL-движком документов