Technischer Artikel

Adaptives PDF-Bild-Resampling in Delphi mit PDFiumPas

Eine Woche nach dem Ausliefern einer Komprimierungsfunktion treffen zwei Beschwerden ein: Der eingescannte Vertrag hat jetzt treppenartig ausgefranzte Buchstabenformen, und das transparente Logo auf der Titelseite sitzt in einem fahlen Hof. PDFiumPas beantwortet beides an einer einzigen Stelle. TPdf.OptimizeImages vermisst jedes Bild vor dem Verkleinern, wählt dann einen Resampling-Kernel und akkumuliert Farbe in alpha-bewusster Form

Das war nicht immer so. Vor v3.100.0 rechnete dieselbe Methode jedes Nicht-Bilevel-Bild mit einem festen Nearest-Neighbor-Schritt herunter — genau der Algorithmus, der beide Beschwerden erzeugt: Er nimmt pro Ausgabepixel einen Quellpixel als Punktprobe und behandelt das RGB unter einem vollständig transparenten Pixel so, als würde ein Leser es jemals sehen. Die Neufassung in v3.100.0 ersetzt diesen einen Pfad durch fünf Kernel, eine gemessene Auswahlregel und ein explizites Arbeitsspeicher-Budget

Warum lässt Downsampling eingescannten Text ausgefranst aussehen?

Weil Punktproben die falsche Frage beantworten. Wird ein 300-DPI-Scan auf 150 DPI umgerechnet, steht jeder Zielpixel für einen Zweimal-zwei-Block von Quellpixeln, und Nearest Neighbor behält einen der vier und verwirft den Rest. Welcher überlebt, hängt vom Runden ab, sodass eine in der Quelle weich geglättete Strichkante pro Pixel zur Münzwette wird. Das Ergebnis ist die klassische Alias-Treppe entlang der Glyphenkanten, dazu Moiré auf Rasterzonen, in denen die verworfenen Proben zufällig das Muster trugen. In einer PDF wiegt das schwerer als auf dem Bildschirm, weil der Schaden dauerhaft ist. Ein Image-XObject trägt seine Sampledaten zusammen mit /Width, /Height und /BitsPerComponent (ISO 32000-1 §8.9.5) mit sich, und Resampling schreibt alle drei innerhalb der Datei neu. Ein schlechter Zoom im Viewer ist ein Bildrahmen, den man neu zeichnen kann, und PDFiumPas hat dafür eigene Mechanik unter Render-Cache und Zoom-Leistung. Ein schlechtes Downsampling ist ein neues Dokument, das man dem Kunden in die Hand drückt

Warum Nearest-Neighbor-Downsampling eingescannten Text in PDFiumPas für Delphi ruiniert: Jeder Ausgabepixel behält einen von vier Quellpixeln und verwirft den Rest, was Alias-Glyphenkanten und Moiré erzeugt, wogegen die fünf Resampling-Kernel angebaut sind
Punktproben behalten einen Quellpixel pro Ausgabepixel und werfen die anderen drei weg — deshalb bietet PDFiumPas jetzt fünf Kernel statt einem

Wie PDFiumPas Detail misst und einen Kernel wählt

PDFiumPas entscheidet pro Bild, nicht pro Dokument. Bevor ein Kernel gewählt wird, berechnet es aus einem begrenzten Abtastgitter einen normalisierten Luminanz-Detailwert: Die horizontalen und vertikalen Schritte sind (Width + 63) div 64 und (Height + 63) div 64, daher kosten ein 12000-Pixel-Scan und eine 300-Pixel-Miniatur ungefähr denselben 64-mal-64-Durchlauf. An jeder Abtastposition summiert es über bis zu drei Kanäle die absolute Differenz zum Nachbarn rechts und zum Nachbarn darunter und teilt dann durch die Probenanzahl mal 255. Der Wert landet zwischen 0 und 1, wobei flache Geschäftsgrafik nahe null bleibt und dichte Foto-Textur ansteigt

Die Auswahleiter läuft danach in fester Reihenfolge. Ist ResampleFilter etwas anderes als pirfAdaptive, wird dieser Filter unverändert benutzt. Sonst gilt: 1-Bit-Inhalt nimmt pirfBilevel; eine ContentClass von piccLineArt nimmt pirfBox; ein Skalierungsfaktor von 4 oder mehr nimmt ebenfalls pirfBox, weil bei dieser Reduktion ein Flächenmittel die billigste und zugleich korrekteste Antwort ist; piccPhoto, ein Detailwert von 0.08 oder höher oder ein PreferredQuality von 0.9 oder höher nimmt pirfLanczos mit seinem Drei-Lappen-Kernel; eine Skalierung von 2 oder mehr oder eine Qualität von 0.7 oder höher nimmt pirfBicubic mit Radius 2; alles Übrige nimmt pirfBilinear. Da TPdfImageOptimizeOptions.Default PreferredQuality auf 0.85 setzt, fällt ein Standardlauf nie auf bilinear zurück, es sei denn, die Reduktion ist mild und der Inhalt flach

Wie PDFiumPas in Delphi einen Resampling-Kernel wählt: Ein begrenzter 64-mal-64-Durchlauf erzeugt einen normalisierten Detailwert, danach leitet eine feste Leiter aus Bedingungen jedes Bild an den Bilevel-, Box-, Lanczos-, Bicubic- oder Bilinear-Filter weiter
Der Detailwert kostet auf einem 12000-Pixel-Scan dasselbe wie auf einer Miniatur, und die Leiter darunter stoppt an der ersten passenden Bedingung
uses
  PDFium;

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

Ein Bild wird nur dann angefasst, wenn der größere seiner horizontalen und vertikalen Platzierungs-DPI, geteilt durch TargetDpi, MinDpiRatio erreicht. Diese Sperre existiert, damit ein 160-DPI-Foto für ein 150-DPI-Ziel nicht wegen sechs Prozent Gewinn neu kodiert wird, der eine Qualitätsstufe kostet. Bilder unter MinDimension auf einer der beiden Achsen, standardmäßig 8, werden als Icons oder Linien übersprungen

Warum bekommen transparente Logos einen weißen Saum?

Weil die Farbe unter einem vollständig transparenten Pixel beliebig ist, und ein schlichter gewichteter Mittelwert sie mitentscheiden lässt. Exportiert man ein Logo aus einem Designwerkzeug, ist der unsichtbare Rand häufig weiß oder schwarz oder was auch immer die Leinwand war; der Alphakanal versteckt ihn, und eine simple Summe über den Kernel-Fußabdruck mischt ihn umgehend zurück in die sichtbare Kante. PDFiumPas vermeidet das, indem es BGRA-Proben in vormultiplizierter Form akkumuliert und die Vormultiplikation erst am Zielpixel rückgängig macht

Konkret addiert jede beitragende Probe channel * alpha * weight zum Farbakkumulator, alpha * weight zu einem Alpha-Akkumulator und weight zur Gewichtssumme. Die Zielfarbe wird dann durch den Alpha-Akkumulator geteilt statt durch die Gewichtssumme, und das ist der entscheidende Schritt: Teilung durch die Gewichtssumme zöge die Farbe zu den unsichtbaren Pixeln hin, während Teilung durch die akkumulierte Alpha die Farbe rekonstruiert, auf die sich die sichtbaren Proben tatsächlich geeinigt haben. Die Ziel-Alpha ist eine eigene Größe, 255 * AlphaSum / WeightSum. Nicht-Alpha-Formate teilen wie gewohnt durch die Gewichtssumme, das Padding-Byte eines FPDFBitmap_BGRx-Ziels wird als konstantes 255 geschrieben, und jeder Kanal wird vor dem Speichern auf 0 bis 255 begrenzt. Diese Alpha stammt normalerweise aus einem Soft-Mask-Eintrag im Image-Dictionary (ISO 32000-1 §11.4), den PDFium bereits in den BGRA-Puffer komponiert hat, den der Resampler erhält

Wie PDFiumPas in Delphi den weißen Hof von transparenten PDF-Bildern entfernt: Proben werden in vormultiplizierter Form akkumuliert, und die Zielfarbe wird durch die akkumulierte Alpha geteilt statt durch die Gewichtssumme, sodass unsichtbare Pixel nicht mitentscheiden
Die vormultiplizierte Farbe durch die akkumulierte Alpha zu teilen rekonstruiert, worauf sich die sichtbaren Proben geeinigt haben, während Teilung durch die Gewichtssumme die Kante zu den unsichtbaren Pixeln hinzieht
// Form der inneren Akkumulationsschleife, je beitragender Quellprobe
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;

// ... und am Zielpixel: Vormultiplikation gegen die Alpha-Summe rückgängig machen
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-Bit-Strichzeichnungen aus der Grauzone heraushalten

Jeder kontinuierliche Kernel, auf einen Bilevel-Scan angewandt, produziert Grau, und Grau ist genau das, was ein Fax-artiges Bild nicht enthalten darf. PDFiumPas lässt 1-Bit-Bilder standardmäßig deshalb unangetastet: PreserveBilevel ist in TPdfImageOptimizeOptions.Default True, und solche Bilder landen unberührt in SkippedCount. Setzt man es auf False, übernimmt der pirfBilevel-Pfad statt eines Glättungskernels. Er geht das exakte Quellrechteck ab, das jeden Zielpixel abdeckt, mittelt die Luminanz mit den Gewichten 0.114, 0.587 und 0.299 in BGR-Speicherreihenfolge und schwellwertet das Ergebnis bei 127.5 in ein hartes 0 oder 255. Nichts Zwischenwertiges kann geschrieben werden, also bleiben Kanten scharf, und um dünne Striche bildet sich kein grauer Hof; der Alphakanal einer BGRA-Quelle wird normal gemittelt, und ein BGRx-Ziel bekommt die konstante 255. Braucht man die zugrunde liegenden Pixel statt eines kleineren Dokuments, ist das Extrahieren von Bildern aus PDF-Dokumenten der separate Weg

Was passiert, wenn ein Bild das Arbeitsspeicher-Budget überschreitet?

Es bleibt exakt, wie es war, und es wird gezählt. MaxWorkingBytes ist standardmäßig 64 MiB und wird zweimal erzwungen. Bevor die Ziel-Bitmap erzeugt wird, weist PDFiumPas das Bild zurück, wenn Breite mal Höhe mal Bytes pro Pixel das Budget überschreitet. Nachdem FPDFBitmap_CreateEx erfolgreich war, prüft es erneut mit realer Stride mal Höhe, denn Zeilen-Padding kann eine Allokation über eine Grenze schieben, die das naive Produkt noch unterschritten hat. Jede der beiden Zurückweisungen zerstört das Ziel und gibt nichts zurück. Man sollte sich über die Verschlechterung im Klaren sein, die das bedeutet: Ein Bild über Budget wird nicht in niedrigerer Qualität neu berechnet und nicht in Kacheln gesplittet. Das Original bleibt im Dokument, BudgetExceededCount und SkippedCount steigen beide, und ein Lauf kann daher Erfolg melden, während ein Dokument nur teilweise optimiert ist. Das ist absichtliches Fail-safe-Verhalten, aber es bedeutet, dass der Bericht keine optionale Lektüre ist. Einen eigenen Fehlermodus gibt es ebenfalls: Bilder, deren Bitmap PDFium gar nicht erzeugen kann, etwa CMYK, JPX, JBIG2 oder maskierte Quellen, erhöhen stattdessen FailedCount und bleiben ebenso unangetastet

procedure OptimizeBatch(const Files: array of string);
var
  Pdf: TPdf;
  Options: TPdfImageOptimizeOptions;
  Report: TPdfImageOptimizeReport;
  I: Integer;
begin
  Options := TPdfImageOptimizeOptions.Default;
  Options.PreserveBilevel := False;              // die Bilevel-Flächenabstimmung nutzen
  Options.ContentClass := piccPhoto;             // Lanczos für Fotoserien erzwingen
  Options.MaxWorkingBytes := 256 * 1024 * 1024;  // Spielraum für große Scans
  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;

Den Bericht lesen, bevor man die Datei ausliefert

TPdfImageOptimizeReport ist darauf gebaut, diagnostiziert zu werden, nicht bloß geloggt. Neben OptimizedCount, SkippedCount und FailedCount legt es einen Zähler pro Kernel offen, sodass BoxFilterCount, BilinearFilterCount, BicubicFilterCount, LanczosFilterCount und BilevelFilterCount verraten, was die adaptive Regel über den eigenen Bestand tatsächlich geschlossen hat. Ein All-Box-Ergebnis bedeutet, die Reduktionen waren steil oder der Inhalt wurde als Strichzeichnung eingestuft; ein All-Lanczos-Ergebnis bei einem Dokument, das man für Strichzeichnung hielt, ist ein Zeichen, dass ContentClass explizit gesetzt werden sollte. AverageDetailScore ist die Zahl, die man beim Justieren von PreferredQuality gegen die 0.08-Lanczos-Schwelle hält, und PeakWorkingBytes zeigt, wie viel von MaxWorkingBytes der Lauf wirklich brauchte. Ungültige Optionen scheitern laut statt still: ein nicht-positives TargetDpi, ein MinDpiRatio unter 1, ein PreferredQuality außerhalb von 0 bis 1 oder ein nicht-positives MaxWorkingBytes wirft EPdfError, bevor eine Seite angefasst wird. Und OptimizeImages bearbeitet nur das Dokument im Speicher; jede geänderte Seite wird mit FPDFPage_GenerateContent festgeschrieben, danach ruft man SaveAs weiterhin selbst auf. Um zu sehen, was sich geändert hat, rendert man die Dokumente davor und danach in Bitmaps, wie unter PDF-Seiten in JPEG-Bilder konvertieren beschrieben, und vergleicht sie bei vollem Zoom

Adaptives Resampling gehört zu jenen Funktionen, die unsichtbar sind, solange sie funktionieren, und Support-Tickets erzeugen, wenn sie es nicht tun — deshalb mussten Messung, Alpha-Behandlung und Speicherbudget zusammen landen statt als drei getrennte Verfeinerungen. Wer das für ein Delphi-, C++Builder- oder Lazarus-Produkt evaluiert, findet die vollständige API-Oberfläche und Lizenzdetails auf der PDFiumPas-Delphi-PDFium-Komponentenseite