Через неделю после выхода функции сжатия приходят две жалобы: у отсканированного договора буквы стали ступенчатыми и пушистыми, а прозрачный логотип на обложке сидит внутри бледного ореола. 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 измеряет детальность и выбирает ядро
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, запуск по умолчанию никогда не откатывается к билинейному, если только уменьшение мягкое, а контент плоский
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-буфер, получаемый ресемплером
// Форма внутреннего накопительного цикла, на каждый вносящий вклад исходный образец
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