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

Укрепление TIFF-декодера Delphi: BigTIFF и тайлы

PDFlibPas декодирует TIFF собственным парсером на Object Pascal, а не привязкой к libtiff, и версия 3.534.1 ужесточила именно те места, где этот парсер отказывает входным данным. BigTIFF с магическим числом 43 теперь отклоняется с явным указанием причины, TileOffsets и TileByteCounts отвергаются ещё при разборе тегов, а каждый буфер рассчитывается через арифметику Int64 под потолком декодирования 256 МиБ

Дефект, который здесь закрыт, никогда не проявляется в лаборатории. Он проявляется в шлюзе сканирования, который тихо работал три года, пока клиент не прогонит через него геопространственный архив или медицинское изображение целиком. У файла корректный TIFF-заголовок. Он разбирается. На выходе — страница полосатого шума или многогигабайтное выделение памяти, роняющее сервис, и ни на одном этапе входные данные не были объявлены некорректными. Именно с таким сценарием отказа стоит бороться: не падение, а уверенно выданный неверный ответ

Почему II или MM ещё не означают классический TIFF?

Потому что маркер порядка байтов общий у обоих диалектов. Классический TIFF и BigTIFF открываются одинаково — с II или MM, и поле, которое их действительно различает, — 16-битное магическое число сразу после него: 42 для классического TIFF по спецификации TIFF 6.0, 43 для BigTIFF с 64-битными смещениями. Загрузчик, записанный как FValidTIFF := PopWord = 42, не ошибается насчёт классического TIFF, но сводит два совершенно разных отказа к одному безмолвному логическому значению, и BigTIFF становится неотличим от обрезанного JPEG, переименованного кем-то. Теперь PDFlibPas разделяет эти случаи и записывает каждый в TPDFTIFF.LastError: заголовок короче четырёх байтов, недопустимый маркер порядка байтов, магия 43 и любое другое значение магии дают разный текст. Библиотека по-прежнему не декодирует BigTIFF, и сказать об этом прямо — в этом и суть. Вызывающий код получает разницу между «это не TIFF» и «это TIFF, чью 64-битную схему смещений встроенный декодер не реализует», а это разница между заявкой в поддержку, на которую отвечаешь одним письмом, и той, что превращается в неделю догадок

TIFF-загрузчик PDFlibPas читает маркер порядка байтов и 16-битную магию по отдельности, поэтому короткий заголовок, недопустимый маркер, магия BigTIFF 43 и любая другая магия дают разный текст LastError вместо одного безмолвного логического значения
Классический TIFF и BigTIFF открываются одним и тем же маркером порядка байтов, поэтому PDFlibPas разделяет четыре случая отказа и называет каждый в LastError
var
  Tiff: TPDFTIFF;
  Page: Integer;
begin
  Tiff := TPDFTIFF.Create;
  try
    Tiff.LoadFromFile('inbox\scan-0417.tif');
    if not Tiff.ValidTIFF then
      raise Exception.Create('TIFF rejected: ' + Tiff.LastError);
    if Tiff.PageCount < 1 then
      raise Exception.Create('TIFF carries no decodable page');
    for Page := 1 to Tiff.PageCount do
      Writeln(Format('page %d: %dx%d, %d spp',
        [Page,
         Tiff.PageInfo[Page].Width,
         Tiff.PageInfo[Page].Height,
         Tiff.PageInfo[Page].SamplesPerPixel]));
  finally
    Tiff.Free;
  end;
end;

Тайлы — другая геометрия, а не ещё один массив смещений

PDFlibPas отклоняет тайловый TIFF ещё при разборе тегов, до того как затронут хоть один пиксель данных. Соблазнительный обходной путь виден сразу: теги 324 (TileOffsets) и 325 (TileByteCounts) — это массивы файловых смещений и длин в байтах, структурно идентичные массивам полос, поэтому направить на них существующие поля полос — две строки кода, и компилируется безупречно. И это неверно. Тайлы образуют двумерную сетку с дополненными краевыми блоками, собственным шагом строки внутри каждого тайла и никакой семантикой RowsPerStrip, как прямо сказано в разделе тайловых изображений TIFF 6.0. Поэтому данные тайлов, попавшие в декодер полос, не падают с громким сообщением: SimpleExtract и CompDecode идут по данным с неверным шагом и выдают изображение с правильными размерами и неверными пикселями. Прежний код усугублял это, храня StripsAreTiles, ColumnsPerTile и RowsPerTile в TTIFFPage: геометрия тайлов у декодера, за которым нет сборщика тайлов. В 3.534.1 обработчики тегов 324 и 325 вызывают ошибку тайлов и немедленно покидают IFD, так что отказ содержит слово «tiled», а не всплывает неделями позже как жалоба на отрисовку

PDFlibPas сравнивает раскладку полос, где полосы на всю ширину делят один шаг строки, с раскладкой тайлов — двумерной сеткой с дополненными краевыми блоками, — и отклоняет теги 324 и 325 ещё при разборе тегов, до того как затронут хоть один пиксель данных
Массивы тайлов выглядят структурно идентичными массивам полос, поэтому, попадая в декодер полос, они дают правильные размеры и неверные пиксели

Ограничение одного измерения — это не бюджет памяти

Ограничить ширину и высоту по 65 535 необходимо, но далеко не достаточно, потому что величина, управляющая выделением памяти, — это произведение. RowsPerStrip * Width * SamplesPerPixel может переполнить 32-битную арифметику задолго до того, как какой-либо множитель достигнет собственного предела, и даже без переполнения назвать выделение, за которое не должен браться ни один сервис. PDFlibPas вычисляет байты строки в Int64 и соблюдает три потолка одновременно: 65 535 на измерение, 32 цветовых компонента и 256 МиБ декодированных байтов

const
  PDFLIB_TIFF_MAX_IMAGE_DIM = 65535;
  PDFLIB_TIFF_MAX_COLOR_COMPONENTS = 32;
  PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES = 256 * 1024 * 1024;

// внутри TPDFTIFF.ValidatePageForDecode
BitsPerPixel := Int64(P.BitsPerSample) * P.SamplesPerPixel;
RowBytes := (Int64(P.Width) * BitsPerPixel + 7) div 8;
if (RowBytes < 1) or
   (RowBytes > PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES) or
   (Int64(P.Height) > PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES div RowBytes) then
  Exit(False);

DecodedBytes := RowBytes * P.RowsPerStrip;
if (DecodedBytes < 1) or
   (DecodedBytes > PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES) or
   (DecodedBytes > MaxInt) then
  Exit(False);

Три детали здесь важнее самих констант. Проверка высоты записана делением, а не умножением, поэтому чрезмерное произведение вообще не образуется. RowsPerStrip меньше 1 или выше высоты изображения сначала нормализуется к высоте — это чтение одной полосы, которое TIFF 6.0 и так подразумевает, — и это не даёт враждебному тегу раздуть буфер полосы. И подпрограмма общая: ValidatePageForDecode выполняется в конце разбора тегов и снова на входе в оба — SimpleExtract и CompDecode, так что код, обращающийся к декодеру напрямую, не может обойти бюджет. Это то же правило, которого PDFlibPas придерживается при разборе недоверенных графов PDF-объектов: предел, соблюдаемый на одной двери из трёх, — не предел

PDFlibPas рассчитывает каждый TIFF-буфер через арифметику Int64, проверяет высоту изображения делением, чтобы чрезмерное произведение не образовалось, и выполняет одну и ту же подпрограмму ValidatePageForDecode при разборе тегов и на входе в оба декодера
Три потолка, арифметика Int64 для байтов строки и одна общая проверочная подпрограмма, доступная со всех трёх дверей, — предел, соблюдаемый на одной двери из трёх, не предел

Что должен проверить вызывающий перед чтением PageInfo?

Сначала проверьте ValidTIFF, затем PageCount и только потом индексируйте PageInfo. Отклонённый файл может оставить PageCount равным нулю, а GetPageInfo отвечает на индекс вне диапазона неинициализированной записью TTIFFPage, поэтому путь обработки ошибки, читающий разрешение или число сэмплов по дороге к сообщению об отказе, в итоге читает шум. Версия 3.534.1 исправила обоих вызывавших внутри библиотеки: путь импорта изображений читает XRes и YRes только внутри допустимой ветви, а TPDFlib.GetImagePageCount требует ValidTIFF, вместо того чтобы самому доверять ненулевому числу страниц. Далее по цепочке аргумент Options метода AddImageFromFile — это номер страницы, начиная с 1, для многостраничного TIFF, так что GetImagePageCount должен быть заслуживающим доверия до старта цикла, а не после. Ноль страниц теперь настоящий ответ, означающий «здесь нечего декодировать», а не случайность раннего возврата, что важнее всего при подборке и чередовании дуплексных пакетов сканирования, когда один безмолвно неверно декодированный лист попадает не на свою позицию

var
  Pdf: TPDFlib;
  Pages, I, ImageID: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    Pages := Pdf.GetImagePageCount('inbox\scan-0417.tif');
    if Pages < 1 then
      Exit;  // плохой заголовок, BigTIFF, тайловая раскладка или превышение бюджета
    Pdf.NewDocument;
    for I := 1 to Pages do
    begin
      Pdf.NewPage;
      ImageID := Pdf.AddImageFromFile('inbox\scan-0417.tif', I);
      if ImageID > 0 then
      begin
        Pdf.SelectImage(ImageID);
        Pdf.DrawImage(0, 0, 595, 842);
      end;
    end;
    Pdf.SaveToFile('scan-0417.pdf');
  finally
    Pdf.Free;
  end;
end;

Свой декодер или линковать libtiff?

PDFlibPas оставляет встроенный декодер, и решающий фактор — охват платформ, а не авторство. Около 1 873 строк Object Pascal компилируются везде, куда идёт компилятор: Win32, Win64, macOS, iOS, Android и FPC на Linux. libtiff 4.7.1 — это около 30 000 строк C, разбросанных по 34 единицам трансляции tif_*.c, и существующие сегодня прекомпилированные объектные файлы покрывают только Windows. Переход на него обменял бы полный охват TIFF на список поддерживаемых платформ, сжимающийся до машин, где работает C-тулчейн, плюс проход линковщика, который ещё никто не прогонял

Цену этого стоит назвать без прикрас. Встроенный декодер обрабатывает то, что реально производит работа со сканированными документами: CCITT Group 3 одномерный и двумерный, Group 4, LZW, Deflate, PackBits и JPEG-in-TIFF, при фотометрических схемах WhiteIsZero, BlackIsZero, RGB, палитровой и CMYK с Predictor 1 и 2. Эти данные совпадают с фильтрами PDF из ISO 32000-1 §7.4.4 и §7.4.6, поэтому TIFF-фронтенд несёт такой вес в конвейере сканирования. Что он не обрабатывает: BigTIFF, тайлы, Predictor 3 с плавающей запятой, PixarLog и SGILog, старый стиль JPEG-сжатия 6 и пирамиды sub-IFD. С 3.534.1 каждый такой случай — именованный отказ, а не неверное изображение, и библиотека хранит письменный список триггеров для пересмотра решения о libtiff:

  • клиент присылает файл BigTIFF и нужна нативная поддержка, а не шаг конвертации
  • клиент присылает тайловый TIFF из медицинских, ГИС или промышленных источников, и его нужно декодировать на месте
  • клиент присылает TIFF с плавающей запятой Predictor 3
  • опубликованная уязвимость затрагивает встроенные пути декодирования CCITT или LZW
  • кроссплатформенный аргумент перестаёт действовать — либо потому, что поддержка macOS, iOS и Android снята, либо потому, что переиспользуемая интеграция libtiff уже покрывает macOS и Linux

Сама миграция очерчена, а не гипотетична: условная компиляция USE_LIBTIFF сохранила бы публичную поверхность TPDFTIFF нетронутой, направила бы LoadFromStream через TIFFClientOpen с потоковыми колбэками и оставила бы Pascal-парсер запасным вариантом для не-Windows. Пока один из этих триггеров реально не сработал, поддержка двух декодеров и удвоенная тестовая матрица не покупают ничего ощутимого для клиента. Отложить издержку, заранее записав запасной выход, — не то же самое, что игнорировать её

Что это означает для конвейера сканированных документов

Относитесь к TPDFTIFF как к воротам, а не конвертеру. Загрузите файл, прочтите ValidTIFF и логируйте LastError дословно всякий раз, когда он ложен, — эта строка теперь кратчайший путь от отчёта с мест к диагнозу. Файлы, не прошедшие ворота, остаются восстановимыми конвертацией выше по потоку — сегодня это практический ответ для источников BigTIFF и тайловых. Для входных данных, вообще не относящихся к TIFF, PDFlibPas идёт отдельным путём через маршрут ввода изображений AVIF, HEIF и JPEG XL, так что вопрос о том, какой декодер владеет каким форматом, остаётся явным, а не стихийным

Всё это сидит за обычным API изображений, поэтому документный конвейер получает более жёсткую границу, не меняя ни строки вызывающего кода сверх проверки числа страниц, которую он и так должен был проверять. Если вы взвешиваете нативный путь TIFF-to-PDF для Delphi или C++Builder, полный компонент и его обработка изображений описаны на странице PDF Library for Delphi