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
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
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
// 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