Articolo tecnico

Ricampionamento adattivo PDF in Delphi con PDFiumPas

Una settimana dopo il rilascio di una funzione di compressione arrivano due reclami: il contratto scannerizzato mostra lettere dal contatto a scaletta e sfrangiato, e il logo trasparente sulla copertina resta intrappolato in un alone pallido. PDFiumPas risponde a entrambi in un solo punto. TPdf.OptimizeImages misura ogni immagine prima di ridurla, quindi sceglie un kernel di ricampionamento e accumula il colore in forma consapevole di alpha

Non è sempre stato così. Prima della v3.100.0 lo stesso metodo riduceva ogni immagine non bilevel con un passo a vicino più prossimo fisso, cioè esattamente l'algoritmo che genera entrambi i reclami: campiona un solo pixel sorgente per ogni pixel di destinazione e tratta l'RGB nascosto sotto un pixel completamente trasparente come se un lettore potesse vederlo. La riscrittura della v3.100.0 sostituisce quel percorso unico con cinque kernel, una regola di selezione misurata e un budget esplicito di memoria di lavoro

Perché il downsampling rende sfrangiato il testo scannerizzato?

Perché il point sampling risponde alla domanda sbagliata. Quando una scansione a 300 DPI viene riportata a 150 DPI, ogni pixel di destinazione rappresenta un blocco due per due di pixel sorgente, e il vicino più prossimo ne conserva uno solo e scarta gli altri. Quale sopravvive dipende dall'arrotondamento, quindi un bordo di tratto antialiasato con continuità nella sorgente diventa un lancio di moneta per ogni pixel. Il risultato è la classica scala aliasata lungo i bordi dei glifi, oltre al moiré nelle zone a mezzitoni dove i campioni scartati portavano per caso il motivo. In un PDF conta più che sullo schermo perché il danno è permanente. Un image XObject trasporta i suoi dati di campione insieme a /Width, /Height e /BitsPerComponent (ISO 32000-1 §8.9.5), e il ricampionamento riscrive tutti e tre dentro il file. Uno zoom errato in un viewer è un fotogramma che puoi ridisegnare, e PDFiumPas ha un meccanismo separato per quello nella pagina su render cache e prestazioni dello zoom. Un downsampling sbagliato è un documento nuovo che consegni al cliente

Perché il downsampling a vicino più prossimo rovina il testo scannerizzato in PDFiumPas per Delphi: ogni pixel di destinazione conserva uno dei quattro pixel sorgente e scarta gli altri, producendo bordi di glifi aliasati e moiré, sostituito dai cinque kernel di ricampionamento
Il point sampling conserva un pixel sorgente per ogni pixel di destinazione e butta via gli altri tre, ecco perché PDFiumPas ora offre cinque kernel invece di uno

Come PDFiumPas misura il dettaglio e sceglie un kernel

PDFiumPas decide immagine per immagine, non documento per documento. Prima di scegliere un kernel calcola un punteggio di dettaglio della luminanza normalizzato da una griglia di campionamento limitata: i passi orizzontale e verticale sono (Width + 63) div 64 e (Height + 63) div 64, così una scansione da 12000 pixel e una miniatura da 300 pixel costano all'incirca la stessa scansione 64 per 64. In ogni posizione campionata somma la differenza assoluta col vicino a destra e col vicino sotto, su fino a tre canali, poi divide per il numero di campioni per 255. Il punteggio cade tra 0 e 1, dove la grafica aziendale piatta si tiene vicino allo zero e la texture fotografica densa sale

La scala di selezione poi gira in un ordine fisso. Se ResampleFilter è qualcosa di diverso da pirfAdaptive, quel filtro si usa così com'è. Altrimenti: il contenuto a 1 bit prende pirfBilevel; un ContentClass di piccLineArt prende pirfBox; un fattore di scala di 4 o più prende pirfBox anche lui, perché a quella riduzione una media d'area è insieme la risposta più economica e più corretta; piccPhoto, un punteggio di dettaglio di 0.08 o superiore, o un PreferredQuality di 0.9 o superiore prende pirfLanczos col suo kernel a tre lobi; una scala di 2 o più o una qualità di 0.7 o superiore prende pirfBicubic a raggio 2; tutto il resto prende pirfBilinear. Dato che TPdfImageOptimizeOptions.Default imposta PreferredQuality a 0.85, un'esecuzione predefinita non ricade mai sul bilinear se non quando la riduzione è lieve e il contenuto è piatto

Come PDFiumPas sceglie un kernel di ricampionamento in Delphi: una scansione limitata 64 per 64 produce un punteggio di dettaglio normalizzato, poi una scala fissa di condizioni instrada ogni immagine al filtro bilevel, box, Lanczos, bicubico o bilineare
Il punteggio di dettaglio costa lo stesso su una scansione da 12000 pixel e su una miniatura, e la scala sotto si ferma alla prima condizione che corrisponde
uses
  PDFium;

procedure ShrinkScannedPdf(const InputFile, OutputFile: string);
var
  Pdf: TPdf;
  Options: TPdfImageOptimizeOptions;
  Report: TPdfImageOptimizeReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := InputFile;
    // Predefiniti: TargetDpi 150, MinDpiRatio 1.5, PreserveBilevel True,
    // MinDimension 8, pirfAdaptive, piccAuto, qualità 0.85, budget 64 MiB.
    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;

Un'immagine viene toccata solo quando la maggiore tra i DPI di posizionamento orizzontale e verticale, divisa per TargetDpi, raggiunge MinDpiRatio. Quella protezione esiste perché una foto a 160 DPI puntata a un obiettivo di 150 DPI non venga ricodificata per un guadagno del sei per cento che costa una generazione di qualità. Le immagini sotto MinDimension su uno dei due assi, 8 per impostazione predefinita, vengono saltate come icone o righe

Perché i logo trasparenti si prendono una frangia bianca?

Perché il colore sotto un pixel completamente trasparente è arbitrario, e una media pesata semplice lo lascia votare. Esporta un logo da uno strumento di design e il margine invisibile è spesso bianco, o nero, o qualunque fosse la tela; il canale alpha lo nasconde, e una somma diretta sull'impronta del kernel lo rimischia subito nel bordo visibile. PDFiumPas lo evita accumulando i campioni BGRA in forma premoltiplicata e annullando la premoltiplicazione solo al pixel di destinazione

In concreto, ogni campione che contribuisce aggiunge channel * alpha * weight all'accumulatore di colore, alpha * weight a un accumulatore di alpha e weight alla somma dei pesi. Il colore di destinazione viene poi diviso per l'accumulatore di alpha invece che per la somma dei pesi, ed è quel passaggio che conta: dividere per la somma dei pesi trascinerebbe il colore verso i pixel invisibili, mentre dividere per l'alpha accumulato ricostruisce il colore su cui i campioni visibili concordano davvero. L'alpha di destinazione è una quantità separata, 255 * AlphaSum / WeightSum. I formati non alpha dividono per la somma dei pesi come al solito, il byte di riempimento di una destinazione FPDFBitmap_BGRx viene scritto come costante 255, e ogni canale viene limitato tra 0 e 255 prima di essere salvato. Quell'alpha normalmente origina da una voce di soft mask nel dizionario dell'immagine (ISO 32000-1 §11.4), che PDFium ha già compositato nel buffer BGRA che il ricampionatore riceve

Come PDFiumPas rimuove l'alone bianco dalle immagini PDF trasparenti in Delphi: i campioni vengono accumulati in forma premoltiplicata, e il colore di destinazione è diviso per l'alpha accumulato invece che per la somma dei pesi così i pixel invisibili non possono votare
Dividere il colore premoltiplicato per l'alpha accumulato ricostruisce ciò su cui i campioni visibili concordano, mentre dividere per la somma dei pesi trascina il bordo verso i pixel invisibili
// Forma del ciclo interno di accumulo, per ogni campione sorgente che contribuisce
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;

// ... e al pixel di destinazione, annulla la premoltiplicazione contro la somma di alpha
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;

Tenere la line art a 1 bit fuori dalla zona grigia

Qualunque kernel continuo applicato a una scansione bilevel produce grigio, e il grigio è esattamente ciò che un'immagine in stile fax non può contenere. PDFiumPas quindi lascia in pace le immagini a 1 bit per impostazione predefinita: PreserveBilevel è True in TPdfImageOptimizeOptions.Default, e queste immagini finiscono in SkippedCount intoccate. Impostalo a False e il percorso pirfBilevel prende il comando al posto di un kernel di smussatura. Percorre il rettangolo sorgente esatto che copre ogni pixel di destinazione, fa la media della luminanza con i pesi 0.114, 0.587 e 0.299 nell'ordine di memoria BGR, e soglia il risultato a 127.5 in uno secco 0 o 255. Nulla di intermedio può essere scritto, quindi i bordi restano nitidi e nessun alone grigio si forma attorno ai tratti sottili; il canale alpha di una sorgente BGRA viene mediato normalmente, e una destinazione BGRx riceve la costante 255. Se ti servono i pixel sottostanti e non un documento più piccolo, estrarre immagini da documenti PDF è il percorso separato

Cosa succede quando un'immagine supera il budget di memoria di lavoro?

Resta esattamente com'era, e viene contata. MaxWorkingBytes è 64 MiB per impostazione predefinita e viene applicato due volte. Prima che la bitmap di destinazione venga creata, PDFiumPas rifiuta l'immagine se larghezza per altezza per byte per pixel supera il budget. Dopo che FPDFBitmap_CreateEx ha successo verifica di nuovo usando lo stride reale per l'altezza, perché il riempimento di riga può spingere un'allocazione oltre un limite che il prodotto ingenuo avrebbe superato. Uno dei due rifiuti distrugge la destinazione e non restituisce nulla. Sii chiaro sulla degradazione che questo implica: un'immagine oltre budget non viene ricampionata a qualità inferiore, e non viene divisa in tile. L'originale resta nel documento, BudgetExceededCount e SkippedCount aumentano entrambi, e un'esecuzione può quindi riportare successo mentre un documento è ottimizzato solo in parte. È un comportamento fail-safe deliberato, ma significa che il report non è una lettura facoltativa. Esiste anche una modalità di guasto distinta: le immagini la cui bitmap PDFium non può produrre affatto, come CMYK, JPX, JBIG2 o sorgenti con maschera, aumentano invece FailedCount e restano anch'esse intoccate

procedure OptimizeBatch(const Files: array of string);
var
  Pdf: TPdf;
  Options: TPdfImageOptimizeOptions;
  Report: TPdfImageOptimizeReport;
  I: Integer;
begin
  Options := TPdfImageOptimizeOptions.Default;
  Options.PreserveBilevel := False;              // usa il voto d'area bilevel
  Options.ContentClass := piccPhoto;             // forza Lanczos per i set di foto
  Options.MaxWorkingBytes := 256 * 1024 * 1024;  // margine per le scansioni grandi
  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;

Leggere il report prima di consegnare il file

TPdfImageOptimizeReport è costruito per essere diagnosticato, non solo registrato. Accanto a OptimizedCount, SkippedCount e FailedCount espone un contatore per kernel, così BoxFilterCount, BilinearFilterCount, BicubicFilterCount, LanczosFilterCount e BilevelFilterCount ti dicono cosa la regola adattiva ha davvero concluso sul tuo corpus. Un risultato tutto box significa che le riduzioni erano ripide o il contenuto è stato classificato come line art; un risultato tutto Lanczos su un documento che credevi fosse line art è un segnale che ContentClass va impostata esplicitamente. AverageDetailScore è il numero da confrontare con la soglia Lanczos di 0.08 quando regoli PreferredQuality, e PeakWorkingBytes mostra quanto di MaxWorkingBytes l'esecuzione ha davvero richiesto. Le opzioni non valide falliscono rumorosamente piuttosto che in silenzio: un TargetDpi non positivo, un MinDpiRatio sotto 1, un PreferredQuality fuori dall'intervallo 0-1, o un MaxWorkingBytes non positivo sollevano EPdfError prima che una pagina venga toccata. E OptimizeImages modifica solo il documento in memoria; ogni pagina modificata viene confermata con FPDFPage_GenerateContent, dopo cui chiami comunque SaveAs tu stesso. Per vedere a occhio cosa è cambiato, renderizza i documenti prima e dopo in bitmap come descritto nella pagina su convertire le pagine PDF in immagini JPEG e confrontali a zoom pieno

Il ricampionamento adattivo è una di quelle funzioni che sono invisibili quando funzionano e generano ticket di supporto quando non lo fanno, ecco perché la misura, la gestione di alpha e il budget di memoria dovevano arrivare insieme e non come tre raffinamenti separati. Se lo stai valutando per un prodotto Delphi, C++Builder o Lazarus, la superficie API completa e i dettagli di licenza sono sulla pagina del componente PDFium PDF per Delphi PDFiumPas