Технічна стаття

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

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

Так було не завжди. До v3.100.0 той самий метод зменшував кожне не-білевельне зображення фіксованим кроком nearest-neighbour — рівно той алгоритм, що породжує обидві скарги: він point-семплює один вихідний піксель на цільовий піксель і трактує RGB, що сидить під повністю прозорим пікселем, так, наче читач колись його побачить. Переписування у v3.100.0 замінило той один шлях пʼятьма ядрами, виміряним правилом вибору та явним бюджетом робочої памʼяті

Чому зменшення робить відсканований текст рваним?

Бо point-семплінг відповідає на неправильне питання. Коли 300 DPI скан перенацілюється на 150 DPI, кожен цільовий піксель репрезентує блок два-на-два вихідних пікселів, а nearest neighbour тримає один із чотирьох і відкидає решту. Котрий виживе — залежить від округлення, тож край штриха, гладко антиаліазований у джерелі, стає підкиданням монети на піксель. Результат — класична аліазована драбина вздовж країв гліфів плюс муар на растрових ділянках, де відкинуті семпли випадково несли патерн. У PDF це важить більше, ніж на екрані, бо пошкодження вічне. Зображення XObject несе свої семпли поруч із /Width, /Height і /BitsPerComponent (ISO 32000-1 §8.9.5), і ресемплинг переписує всі три всередині файла. Поганий зум у переглядачі — кадр, який можна перемалювати, і PDFiumPas має для того окрему механіку в render cache і продуктивності зуму. Погане зменшення — новий документ, який ви віддаєте клієнтові

Чому downsampling nearest-neighbour губить відсканований текст у PDFiumPas для Delphi: кожен цільовий піксель тримає один із чотирьох вихідних і відкидає решту, породжуючи аліазовані краї гліфів і муар, які замінюють пʼять ядер ресемплингу
Point-семплінг тримає один вихідний піксель на цільовий і викидає інші три, саме тому PDFiumPas тепер пропонує пʼять ядер замість одного

Як PDFiumPas вимірює деталю і обирає ядро

PDFiumPas вирішує на зображення, а не на документ. Перед вибором ядра він обчислює нормалізований бал деталі люмінансу з обмеженої семплювальної сітки: горизонтальний і вертикальний кроки — (Width + 63) div 64 і (Height + 63) div 64, тож скан на 12000 пікселів і превʼю на 300 пікселів коштують приблизно однаковий обхід 64-на-64. У кожній семпльованій позиції він підсумовує абсолютну різницю до сусіда праворуч і сусіда знизу, по до трьох каналів, потім ділить на кількість семплів, помножену на 255. Бал потрапляє в діапазон від 0 до 1, де плоска ділова графіка сидить біля нуля, а щільна фотографічна текстура зростає

Далі драбина вибору йде у фіксованому порядку. Якщо ResampleFilter — будь-що, крім pirfAdaptive, той фільтр уживається дослівно. Інакше: 1-бітовий вміст бере pirfBilevel; ContentClass у piccLineArt бере pirfBox; коефіцієнт масштабу 4 і більше теж бере pirfBox, бо за такого зменшення площинне середнє — і найдешевша, і найправильніша відповідь; piccPhoto, бал деталі 0.08 чи вище або PreferredQuality 0.9 чи вище беруть pirfLanczos з його трилопасевим ядром; масштаб 2 чи більше або якість 0.7 чи вище беруть pirfBicubic за радіуса 2; все решта бере pirfBilinear. Оскільки TPdfImageOptimizeOptions.Default ставить PreferredQuality у 0.85, типовий прогон ніколи не падає назад у bilinear, хіба що зменшення мʼяке, а вміст плоский

Як PDFiumPas обирає ядро ресемплингу в Delphi: обмежений обхід 64-на-64 дає нормалізований бал деталі, потім фіксована драбина умов спрямовує кожне зображення у фільтр bilevel, box, Lanczos, bicubic чи bilinear
Бал деталі коштує однаково на скані 12000 пікселів і на превʼю, а драбина під ним зупиняється на першій умові, що збігається
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;

Зображення чіпається, лише коли більший із горизонтального та вертикального placement DPI, поділений на TargetDpi, досягає MinDpiRatio. Та варта існує, щоб фото на 160 DPI, націлене на ціль 150 DPI, не перекодовувалося заради шестивідсоткового виграшу, що коштує покоління якості. Зображення нижче MinDimension по будь-якій осі, 8 за замовчуванням, пропускаються як іконки чи лінійки

Чому прозорі логотипи набувають білої облямівки?

Бо колір під повністю прозорим пікселем довільний, і просте зважене середнє дозволяє йому голосувати. Експортуйте логотип з дизайн-інструменту — і невидиме поле часто біле, або чорне, або яким було полотно; альфа-канал його ховає, а пряма сума по сліду ядра оперативно замішує його назад у видимий край. PDFiumPas уникає цього, акумулюючи BGRA-семпли в premultiplied формі і відмінюючи премультиплікацію лише на цільовому пікселі

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

Як PDFiumPas прибирає білий ореол з прозорих зображень PDF у Delphi: семпли акумулюються в premultiplied формі, а цільовий колір ділиться на накопичену альфу замість суми ваг, тож невидимі пікселі не можуть голосувати
Ділення premultiplied кольору на накопичену альфу реконструює те, про що домовилися видимі семпли, тоді як ділення на суму ваг протягує край до невидимих пікселів
// Форма внутрішнього циклу акумуляції, на кожен внесений вихідний семпл
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;

// ... а на цільовому пікселі — unpremultiply відносно суми альфи
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;

Тримання 1-бітової лінійної графіки поза сірою зоною

Будь-яке неперервне ядро, застосоване до білевельного скана, дає сіре, а сіре — рівно те, чого факсимільний стиль зображення містити не дозволено. Тому PDFiumPas лишає 1-бітові зображення спокій за замовчуванням: PreserveBilevelTrue у 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 на висоту, бо padding рядків може проштовхнути виділення за межу, яку наївний добуток пройшов. Будь-яке відхилення знищує ціль і не повертає нічого. Будьте ясними щодо деградації, яку це імплікує: зображення понад бюджет не ресемплюється за нижчої якості і не ріжеться на тайли. Оригінал лишається в документі, BudgetExceededCount і SkippedCount обидва зростають, і прогон може відзвітувати успіх, коли документ лише частково оптимізовано. Це свідома fail-safe поведінка, але це означає, що звіт — не необовʼязкове читання. Окремий режим відмови теж існує: зображення, чиїх бітових зображень 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