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

Адаптивный ресемплинг изображений PDF в Delphi с PDFiumPas

Через неделю после выхода функции сжатия приходят две жалобы: у отсканированного договора буквы стали ступенчатыми и пушистыми, а прозрачный логотип на обложке сидит внутри бледного ореола. PDFiumPas отвечает на обе в одном месте. TPdf.OptimizeImages измеряет каждое изображение, прежде чем уменьшать его, затем выбирает ядро ресемплинга и накапливает цвет с учётом альфы

Так было не всегда. До v3.100.0 тот же метод уменьшал каждое небинарное изображение фиксированным шагом ближайшего соседа — это ровно тот алгоритм, который порождает обе жалобы: он берёт по одному исходному пикселю на выходной методом точечной выборки и обращается с RGB под полностью прозрачным пикселем так, будто читатель когда-нибудь его увидит. Переписывание в v3.100.0 заменяет тот единственный путь пятью ядрами, измеряемым правилом выбора и явным бюджетом рабочей памяти

Почему уменьшение делает отсканированный текст рваным?

Потому что точечная выборка отвечает не на тот вопрос. Когда скан 300 DPI перецеливается на 150 DPI, каждый пиксель получателя представляет блок исходных пикселей два на два, и ближайший сосед сохраняет один из четырёх, отбрасывая остальные. Какой именно выживет, решает округление, поэтому край штриха, плавно сглаженный в источнике, становится монеткой, бросаемой на каждый пиксель. Результат — классическая лестница алиасинга по краям глифов плюс муар на растровых областях, где отброшенные образцы случилось нести узору. В PDF это важнее, чем на экране, потому что повреждение вечно. XObject изображения несёт свои данные выборок вместе с /Width, /Height и /BitsPerComponent (ISO 32000-1 §8.9.5), и ресемплинг переписывает все три внутри файла. Плохое масштабирование в просмотрщике — кадр, который можно перерисовать, и для этого у PDFiumPas есть отдельная механика в кэше рендера и производительности масштабирования. Плохое уменьшение — новый документ, который вы вручаете клиенту

Почему уменьшение методом ближайшего соседа губит отсканированный текст в PDFiumPas для Delphi: каждый выходной пиксель сохраняет один из четырёх исходных и отбрасывает остальные, порождая алиасинг краёв глифов и муар, что заменяют пять ядер ресемплинга
Точечная выборка сохраняет один исходный пиксель на выходной и выбрасывает остальные три, поэтому PDFiumPas теперь предлагает пять ядер вместо одного

Как PDFiumPas измеряет детальность и выбирает ядро

PDFiumPas решает по каждому изображению, а не по документу. Перед выбором ядра он вычисляет нормированную оценку детальности яркости на ограниченной сетке выборки: горизонтальный и вертикальный шаги равны (Width + 63) div 64 и (Height + 63) div 64, поэтому скан на 12 000 пикселей и миниатюра на 300 пикселей стоят примерно одинаково — проход 64 на 64. В каждой выборочной позиции он суммирует абсолютную разность с соседом справа и соседом снизу по трём каналам максимум, затем делит на число образцов, умноженное на 255. Оценка попадает в диапазон от 0 до 1, где плоская деловая графика сидит у нуля, а плотная фотографическая текстура растёт

Затем лестница выбора проходит в фиксированном порядке. Если ResampleFilter есть что угодно, кроме pirfAdaptive, используется именно этот фильтр. Иначе: однобитовый контент берёт pirfBilevel; ContentClass со значением piccLineArt берёт pirfBox; коэффициент масштаба 4 и более тоже берёт pirfBox, потому что при таком уменьшении среднее по площади — и самый дешёвый, и самый верный ответ; piccPhoto, оценка детальности 0.08 и выше или PreferredQuality 0.9 и выше берут pirfLanczos с его трёхлепестковым ядром; масштаб 2 и выше или качество 0.7 и выше берут pirfBicubic радиуса 2; всё оставшееся берёт pirfBilinear. Поскольку TPdfImageOptimizeOptions.Default задаёт PreferredQuality равным 0.85, запуск по умолчанию никогда не откатывается к билинейному, если только уменьшение мягкое, а контент плоский

Как PDFiumPas выбирает ядро ресемплинга в Delphi: ограниченный проход шестьдесят четыре на шестьдесят четыре даёт нормированную оценку детальности, затем фиксированная лестница условий ведёт каждое изображение к фильтру bilevel, box, Lanczos, bicubic или bilinear
Оценка детальности стоит одинаково на скане в 12 000 пикселей и на миниатюре, а лестница под ней останавливается на первом совпавшем условии
uses
  PDFium;

procedure ShrinkScannedPdf(const InputFile, OutputFile: string);
var
  Pdf: TPdf;
  Options: TPdfImageOptimizeOptions;
  Report: TPdfImageOptimizeReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := InputFile;
    // По умолчанию: TargetDpi 150, MinDpiRatio 1.5, PreserveBilevel True,
    // MinDimension 8, pirfAdaptive, piccAuto, качество 0.85, бюджет 64 МиБ.
    Options := TPdfImageOptimizeOptions.Default;
    Options.TargetDpi := 150;
    Options.MinDpiRatio := 1.5;
    Options.ContentClass := piccAuto;
    Options.PreferredQuality := 0.85;
    if Pdf.OptimizeImages(Options, Report) and (Report.OptimizedCount > 0) then
      Pdf.SaveAs(OutputFile);
  finally
    Pdf.Free;
  end;
end;

Изображение затрагивается, только когда большее из горизонтального и вертикального DPI размещения, делённое на TargetDpi, достигает MinDpiRatio. Эта защита существует затем, чтобы фото на 160 DPI, нацеленное на 150 DPI, не перекодировалось ради шести процентов выигрыша ценой поколения качества. Изображения ниже MinDimension по любой из осей, по умолчанию 8, пропускаются как иконки или линейки

Почему прозрачные логотипы обрастают белой каймой?

Потому что цвет под полностью прозрачным пикселем произволен, и обычное взвешенное среднее даёт ему голос. Экспортируйте логотип из дизайн-инструмента, и невидимое поле часто белое, или чёрное, или каким ни был холст; альфа-канал его прячет, а прямая сумма по следу ядра быстро примешивает его обратно к видимому краю. PDFiumPas избегает этого, накапливая выборки BGRA в премультиплицированной форме и отменяя премультипликацию только в пикселе получателя

Конкретно каждая вносящая вклад выборка добавляет channel * alpha * weight к накопителю цвета, alpha * weight к накопителю альфы и weight к сумме весов. Цвет получателя затем делится на накопитель альфы, а не на сумму весов, и это тот шаг, который имеет значение: деление на сумму весов утянуло бы цвет к невидимым пикселям, а деление на накопленную альфу восстанавливает цвет, о котором видимые выборки действительно договорились. Альфа получателя — отдельная величина, 255 * AlphaSum / WeightSum. Форматы без альфы делят на сумму весов как обычно, байт-заполнитель получателя FPDFBitmap_BGRx записывается как константа 255, и каждый канал зажимается в диапазон от 0 до 255 перед сохранением. Эта альфа обычно происходит из записи мягкой маски в словаре изображения (ISO 32000-1 §11.4), которую PDFium уже скомпозитил в BGRA-буфер, получаемый ресемплером

Как PDFiumPas убирает белый ореол с прозрачных изображений PDF в Delphi: выборки накапливаются в премультиплицированной форме, а цвет получателя делится на накопленную альфу вместо суммы весов, так что невидимые пиксели не голосуют
Деление премультиплицированного цвета на накопленную альфу восстанавливает то, о чём договорились видимые выборки, а деление на сумму весов утягивает край к невидимым пикселям
// Форма внутреннего накопительного цикла, на каждый вносящий вклад исходный образец
if SrcFormat = FPDFBitmap_BGRA then
  Alpha := PByte(PAnsiChar(Pixel) + 3)^ / 255
else
  Alpha := 1;
for Channel := 0 to Min(BytesPerPixel, 3) - 1 do
  Accumulated[Channel] := Accumulated[Channel] +
    PByte(PAnsiChar(Pixel) + Channel)^ * Alpha * Weight;
AlphaSum := AlphaSum + Alpha * Weight;
WeightSum := WeightSum + Weight;

// ... а в пикселе получателя — распремультипликация по сумме альфы
if SrcFormat = FPDFBitmap_BGRA then
begin
  if Abs(AlphaSum) > 1E-12 then
    ValueSum := Accumulated[Channel] / AlphaSum
  else
    ValueSum := 0;
end
else
  ValueSum := Accumulated[Channel] / WeightSum;

Держим однобитовую линейную графику вне серой зоны

Любое непрерывное ядро, применённое к бинарному скану, производит серый, а серый — ровно то, чего факсимильному изображению содержать нельзя. Поэтому PDFiumPas по умолчанию не трогает однобитовые изображения: PreserveBilevel равен True в TPdfImageOptimizeOptions.Default, и такие изображения попадают в SkippedCount нетронутыми. Установите False, и путь pirfBilevel возьмёт верх вместо сглаживающего ядра. Он обходит точный исходный прямоугольник, покрывающий каждый пиксель получателя, усредняет яркость с весами 0.114, 0.587 и 0.299 в порядке памяти BGR и порогует результат на 127.5 в плоский 0 или 255. Промежуточное записано быть не может, поэтому края остаются резкими и серый ореол не образуется вокруг тонких штрихов; альфа-канал источника BGRA усредняется как обычно, а получатель BGRx получает константу 255. Если нужны сами пиксели, а не документ поменьше, извлечение изображений из PDF-документов — отдельный путь

Что происходит, когда изображение превышает бюджет рабочей памяти?

Оно остаётся ровно как было, и оно учитывается. MaxWorkingBytes по умолчанию 64 МиБ и принуждается дважды. До создания растра получателя PDFiumPas отклоняет изображение, если ширина на высоту на байты на пиксель превышает бюджет. После успешного FPDFBitmap_CreateEx он проверяет снова по реальному stride на высоту, потому что выравнивание строк способно вытолкнуть выделение за предел, который наивное произведение прошло. Любое из двух отклонений уничтожает получателя и не возвращает ничего. Отчётливо понимайте деградацию, которую это подразумевает: изображение сверх бюджета не ресемплируется с более низким качеством и не режется на плитки. Оригинал остаётся в документе, BudgetExceededCount и SkippedCount растут оба, и запуск может отчитаться успехом, пока документ оптимизирован лишь частично. Это сознательно отказобезопасное поведение, но оно значит, что отчёт — не необязательное чтение. Существует и отдельный режим отказа: изображения, чей растр PDFium не может произвести вовсе, как CMYK, JPX, JBIG2 или замаскированные источники, увеличивают вместо этого FailedCount и тоже остаются нетронутыми

procedure OptimizeBatch(const Files: array of string);
var
  Pdf: TPdf;
  Options: TPdfImageOptimizeOptions;
  Report: TPdfImageOptimizeReport;
  I: Integer;
begin
  Options := TPdfImageOptimizeOptions.Default;
  Options.PreserveBilevel := False;              // голосование площади для bilevel
  Options.ContentClass := piccPhoto;             // принудительный Lanczos для фото
  Options.MaxWorkingBytes := 256 * 1024 * 1024;  // запас для больших сканов
  Pdf := TPdf.Create(nil);
  try
    for I := Low(Files) to High(Files) do
    begin
      Pdf.FileName := Files[I];
      if not Pdf.OptimizeImages(Options, Report) then
      begin
        WriteLn('optimize failed: ', Report.ErrorMessage);
        Continue;
      end;
      if Report.BudgetExceededCount > 0 then
        WriteLn(Files[I], ': ', Report.BudgetExceededCount,
          ' image(s) over budget and kept at full size');
      if Report.FailedCount > 0 then
        WriteLn(Files[I], ': ', Report.FailedCount,
          ' image(s) could not be decoded to a bitmap');
      if Report.OptimizedCount > 0 then
        Pdf.SaveAs(ChangeFileExt(Files[I], '.opt.pdf'));
    end;
  finally
    Pdf.Free;
  end;
end;

Читаем отчёт, прежде чем отдавать файл

TPdfImageOptimizeReport создан для диагностики, а не только для журнала. Наряду с OptimizedCount, SkippedCount и FailedCount он выставляет по счётчику на каждое ядро, так что BoxFilterCount, BilinearFilterCount, BicubicFilterCount, LanczosFilterCount и BilevelFilterCount говорят, к какому выводу адаптивное правило действительно пришло о вашем корпусе. Сплошной box-результат означает, что уменьшения были крутыми или контент классифицирован как линейная графика; сплошной Lanczos-результат на документе, который вы считали линейной графикой, — знак, что ContentClass стоит задать явно. AverageDetailScore — число для сравнения с порогом Lanczos 0.08 при настройке PreferredQuality, а PeakWorkingBytes показывает, сколько из MaxWorkingBytes запуск действительно потребовал. Недопустимые опции падают громко, а не молча: неположительный TargetDpi, MinDpiRatio ниже 1, PreferredQuality вне диапазона от 0 до 1 или неположительный MaxWorkingBytes вызывает EPdfError до касания любой страницы. И OptimizeImages правит только документ в памяти; каждая изменённая страница коммитится через FPDFPage_GenerateContent, после чего SaveAs вы всё ещё вызываете сами. Чтобы глазами увидеть, что изменилось, отрендерите документы до и после в растры, как описано в конвертации страниц PDF в изображения JPEG, и сравните их при полном масштабе

Адаптивный ресемплинг — из тех функций, что невидимы, когда работают, и плодят обращения в поддержку, когда нет, поэтому измерение, обработка альфы и бюджет памяти должны были приземлиться вместе, а не тремя отдельными улучшениями. Если вы оцениваете это для продукта на Delphi, C++Builder или Lazarus, полный API и условия лицензирования на странице компонента PDFiumPas Delphi PDFium