Jemand malt eine schwarze Box über einen Namen, glättet nichts, liefert die Datei aus, und der Prüfer selektiert das Rechteck und fügt den Namen in eine E-Mail ein. PDFiumPas beantwortet das mit Schwärzung auf Operator-Ebene: SaveAsRedacted löscht nur die Unicode-Skalare, deren Zeichenboxen ein Schwärzungsrechteck berühren, baut die Überlebenden aus Original-Font, Größe, Matrix, Rendermodus und Farbe wieder auf und schneidet achsenparallele Pfade und Bilder zu, statt sie ganz fallen zu lassen
Warum ein gemaltes Rechteck keine Schwärzung ist
Eine über einen Content-Stream gelegte Zeichenoperation versteckt nichts, denn die Textanzeige-Operatoren darunter sind weiterhin im Stream und mappen weiterhin auf Codepoints. ISO 32000-1 §9.4 definiert ein Textobjekt als Folge von Positionierungs- und Anzeigeoperatoren innerhalb von BT und ET; ein danach gezeichnetes gefülltes Rechteck ist schlicht ein weiterer Operator im selben Stream. Extraktion wandert über die Operatoren, nicht über die Pixel, also kommt die verdeckte Zeichenkette unversehrt zurück. Echte Schwärzung muss den Operanden entfernen, nicht die Ausgabe verschleiern
Die naheliegende sichere Implementierung ist brutal: Man findet jedes Seitenobjekt, dessen Bounding-Box ein Schwärzungsrechteck schneidet, und löscht das ganze Objekt. Genau das taten frühere PDFiumPas-Versionen, und es ist korrekt, aber teuer. Ein einzelnes Tj kann eine komplette Tabellenzeile tragen, also riss das Schwarz-Übermalen einer Kontonummer Datum, Beschreibung und Betrag mit. Eine rechteckige Füllung, die zufällig ein seitenbreites Tabellenband war, verschwand über die ganze Seite. Ein Rechnungslogo verschwand, weil die Schwärzung eine Ecke davon traf. Version 3.101.0 verlagert die Entscheidung eine Ebene nach unten, vom Seitenobjekt zum Operanden
Was löscht Schwärzung auf Operator-Ebene tatsächlich?
PDFiumPas löscht Unicode-Skalare, keine Textobjekte. Während SaveAsRedacted läuft, baut die Komponente aus der geladenen Textseite eine Zuordnung von Zeichen zu Seitenobjekt, liest dann für jedes Zeichen des zu prüfenden Objekts die Zeichenbox und schneidet diese Box gegen jedes Schwärzungsrechteck. Zeichen, die ein Rechteck berühren, werden zum Entfernen markiert; der Rest wird als Überlebender markiert. Schneidet nichts, bleibt das Objekt vollständig unangetastet. Schneidet jedes Zeichen, wird das Objekt ganz entfernt, genau wie vorher. Nur der gemischte Fall löst eine Spaltung aus
Jeder Überlebende wird dann als eigenes Textobjekt neu ausgegeben, gebaut aus dem Original-Font-Handle, der Original-Fontgröße, der Textmatrix je Zeichen, dem Original-Textrendermodus und dem Füll- und Strichzustand des Elternobjekts einschließlich Strichbreite, Linienverbindung, Linienende und Strichmuster. Das Font-Handle wiederzuverwenden statt ein neues aufzulösen, hält die Glyphen metrisch identisch, und die Textmatrix je Zeichen wiederzuverwenden hält Laufweiten und Wortabstände an Ort und Stelle, ohne das Layout neu zu rechnen. Der Preis ist die Objektanzahl: Ein behaltenes Zeichen wird zu einem Textobjekt, weshalb TPdfRedactionOptions.MaxSplitObjects als harte Obergrenze für erzeugte Fragmente existiert
procedure RedactDocument(const SourcePdf, TargetPdf: string);
var
Pdf: TPdf;
Options: TPdfRedactionOptions;
Report: TPdfRedactionReport;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := SourcePdf; // die Datei trägt bereits /Redact-Annotationen
Pdf.Active := True;
Options := TPdfRedactionOptions.Default;
Options.PreservePartialObjects := True; // Operator-Ebenen-Spaltung (der Standard)
Options.RemoveIntersectingAnnotations := True;
Options.MaxSplitObjects := 20000; // Obergrenze für erzeugte Fragmente
if not Pdf.SaveAsRedacted(TargetPdf, Options, Report) then
raise Exception.Create(Report.ErrorMessage); // fail closed, nicht ausliefern
finally
Pdf.Free;
end;
end;
Rechtecke werden zugeschnitten, rotierte Geometrie nicht
Pfade werden nur gespalten, wenn PDFiumPas beweisen kann, dass der Pfad ein achsenparalleles Rechteck ist. Der Beweis ist bewusst eng: Die Objektmatrix muss beide Scher-Terme unter 0.0001 haben, der Pfad muss aus vier bis sechs Segmenten bestehen, die mit einem MOVETO beginnen und nur mit LINETO fortsetzen, und die transformierten Punkte müssen innerhalb einer Toleranz von 0.01 auf allen vier Ecken der Objektgrenzen landen. Ein Pfad, der diese Prüfung besteht, wird durch sukzessive Rechtecksubtraktion reduziert, jedes Schwärzungsrechteck schnitzt die Überlebendenmenge in linke, rechte, untere und obere Streifen, und jeder entstandene Streifen wird mit Original-Füllmodus, Strich-Flag und Farbzustand neu erzeugt. Kurven, Dreiecke, beschnittene Formen und alles Rotierte fallen durch die Prüfung, und das ganze Objekt wird entfernt
Bei Bildern gilt ISO 32000-1 §8.9: Die Bildsamples belegen das Einheitsquadrat, das durch die aktuelle Transformationsmatrix abgebildet wird. PDFiumPas invertiert diese Abbildung, um jedes überlebende Fragment im Seitenraum zurück in normalisierte Bildkoordinaten zu überführen, klemmt es auf das Einheitsintervall und wandelt dann durch Rundung nach innen in Pixelindizes um: Linke und obere Kante laufen durch Ceil, rechte und untere durch Floor. Diese Richtung zählt. Nach außen gerundet würde eine partielle Spalte von Quellpixeln von der geschwärzten Seite am Fragmentrand überleben. Die ganzzahligen Pixelgrenzen werden dann zurück in normalisierte Koordinaten umgerechnet und zur Ableitung der Fragmentmatrix benutzt, sodass die beschnittene Bitmap exakt auf der Pixelgrenze landet, an der sie geschnitten wurde. Der Zuschnitt selbst ist ein stride-bewusstes Zeilenkopieren über die Formate Gray, BGR, BGRx und BGRA. Wie bei Pfaden wird ein rotiertes oder geschertes Bild, oder eines, dessen Matrix einen degenerierten Skalierungsterm hat, vollständig entfernt
// Nach einem erfolgreichen SaveAsRedacted-Aufruf
Writeln(Format('applied %d redaction(s) on %d page(s)',
[Report.RedactionCount, Report.RedactedPageCount]));
Writeln(Format('scanned %d object(s), removed %d',
[Report.ScannedObjectCount, Report.RemovedObjectCount]));
Writeln(Format('split text/path/image: %d / %d / %d',
[Report.SplitTextObjectCount, Report.SplitPathObjectCount,
Report.SplitImageObjectCount]));
Writeln(Format('preserved %d fragment(s)', [Report.PreservedFragmentCount]));
Writeln(Format('pruned %d resource name(s), swept %d object(s)',
[Report.ResourcePruneReport.RemovedNameCount,
Report.ResourcePruneReport.RemovedObjectCount]));
if Report.PreservedFragmentCount = 0 then
// nichts konnte gespalten werden: jedes schneidende Objekt wurde ganz entfernt
LogWholeObjectFallback(SourcePdf);
Warum scheitert PDFiumPas bei nicht abgebildbaren Zeichen?
Weil eine Glyphe ohne reproduzierbaren Unicode-Skalar nicht ehrlich wiederaufgebaut werden kann. Einen Überlebenden zu rekonstruieren heißt, die Textsetzungs-API mit einer Zeichenkette aufzurufen, und das erfordert einen stabilen Codepoint für jedes behaltene Zeichen. Symbolische Subset-Fonts mit kaputten oder fehlenden ToUnicode-Daten können eine leere Zuordnung liefern, und Umkodieren nach Bauchgefühl würde eine Ausgabe produzieren, die auf dem Bildschirm richtig aussieht, während darunter ein anderes Zeichen steckt. PDFiumPas lehnt ab: Die Prüfung der behaltenen Zeichen wirft, die Ausnahme wird innerhalb von SaveAsRedacted gefangen, TPdfRedactionReport.Succeeded kommt als False mit der Meldung in ErrorMessage zurück, und die Funktion gibt False zurück. Dieselbe Regel gilt für das Spaltungsbudget, das wirft, statt die Fragmentmenge stillschweigend zu kürzen. Hat ein Dokument Fonts, denen man nicht traut, und man will das deterministische alte Verhalten, setzt man Options.PreservePartialObjects := False, und jedes schneidende Objekt geht ganz weg
Ressourcenbereinigung über geteilte Gültigkeitsbereiche
Das Spalten von Objekten hinterlässt Waisen, und sie zu entfernen ist nicht so einfach wie ein Diff des /Resources-Dictionarys auf Seitenebene. ISO 32000-1 §7.8.3 erlaubt, dass dasselbe Ressourcen-Dictionary von mehreren Seiten, von Form-XObjects, von Patterns und von Erscheinungsbild-Streams von Annotationen zugleich referenziert wird. Einen Fontnamen zu löschen, weil eine Seite ihn nicht mehr benutzt, bricht eine andere Seite, die es noch tut. PruneUnusedPdfResources arbeitet deshalb pro Gültigkeitsbereich: Es löst /Contents auf, ob direktes Array, indirekte Referenz auf ein Array oder einzelner Stream, und sammelt dann die Ressourcennutzung aus den Operatoren, die Ressourcen tatsächlich nennen — Tf für Fonts, Do für XObjects, gs für Grafikzustand, CS, cs, SCN und scn für Farbräume und Patterns, sh für Shadings, BDC und DP für Marked-Content-Eigenschaften, dazu den /CS-Eintrag von Inline-Bildern. Teilen sich mehrere Bereiche ein Dictionary, werden die benutzten Namensmengen pro Kategorie vereinigt, bevor irgendetwas entfernt wird
Nur Namen, die über jeden Bereich, der auf das Dictionary zeigt, als unreferenziert bestätigt sind, werden verworfen. Ein Bereich, der sich nicht sicher parsen lässt, bleibt unangetastet, und das ist die konservative Richtung: Eine unbereinigte Datei ist bloß größer, eine falsch bereinigte ist korrupt. Die überlebenden Dictionarys werden als sparsames inkrementelles Update mit exakt den Generationsnummern zurückgeschrieben, und eine Neuschreibung der Erreichbarkeit fegt danach die Objekte weg, die unerreichbar wurden, sobald die Namen verschwanden. TPdfResourcePruneReport meldet ScannedScopeCount, UpdatedScopeCount, RemovedNameCount, RemovedObjectCount, die Bytezahlen und ein Succeeded-Flag. SaveAsRedacted führt diesen Schritt automatisch auf der bereinigten Ausgabe aus, der Schwärzungspfad enthält ihn also bereits, aber die Funktion ist auf Stream-Ebene exportiert, für Pipelines, die sie separat wollen
uses
FPdfCompress;
procedure PruneResourceNames(const SourcePdf, TargetPdf: string);
var
Source, Dest: TFileStream;
Report: TPdfResourcePruneReport;
begin
Source := TFileStream.Create(SourcePdf, fmOpenRead or fmShareDenyWrite);
try
Dest := TFileStream.Create(TargetPdf, fmCreate);
try
// AllowSignedDocument bleibt False: Ein inkrementelles Umschreiben würde
// die Bytebereiche ungültig machen, die eine Signatur abdeckt
PruneUnusedPdfResources(Source, Dest, Report);
if not Report.Succeeded then
raise Exception.Create(Report.ErrorMessage);
Writeln(Format('%d name(s) removed from %d scope(s), %d -> %d bytes',
[Report.RemovedNameCount, Report.UpdatedScopeCount,
Report.SourceByteCount, Report.OutputByteCount]));
finally
Dest.Free;
end;
finally
Source.Free;
end;
end;
Einbau in eine Dokumenten-Pipeline
Der Schwärzungspfad verändert niemals das geladene Dokument. SaveAsRedacted nimmt einen isolierten Schnappschuss, wendet dort die /Redact-Annotationen an, entfernt Anhänge, durchläuft den Sanitisierungsdurchlauf, der Open Action, Catalog-Actions, Namensbäume, Associated Files, das AcroForm und die Metadaten entfernt, bereinigt Ressourcen und schreibt erst dann den Ausgabestream. Diese Ausgabe als unabhängiges Dokument wieder zu öffnen und den Text neu zu extrahieren ist der Verifikationsschritt, den man in der eigenen Testsuite behalten sollte, denn er ist die einzige Prüfung, die die ursprüngliche Frage beantwortet — kann ein Leser die Zeichenkette noch bekommen. Eine Folge, die man einplanen muss: Das Spalten ersetzt Seitenobjekte, also ist jedes FPDF_PAGEOBJECT-Handle, das man hielt, danach tot — dieselbe Lebensdauer-Falle, die unter veraltete Seitenobjekt-Handles nach einer Transformation beschrieben wird
Zwei Nachbarstücke machen den Workflow vollständig. Zu entscheiden, wohin die Schwärzungsrechtecke gehören, beginnt üblicherweise mit extrahierter Geometrie, und das Block- und Lesereihenfolge-Modell unter strukturierte Textblöcke und Lesereihenfolge ist eine bessere Quelle für Kandidatenboxen als rohe Zeichenläufe. Das Ergebnis einem Prüfer zu reichen gehört zu den Härtungsregeln unter einen sicheren PDF-Preview bauen, wo Formularausfüllung und JavaScript standardmäßig aus bleiben. Zusammen decken sie den Kreis ab, den die meisten Compliance-Workflows brauchen: lokalisieren, auf Operator-Ebene schwärzen, durch Wiederöffnen verifizieren, sicher vorschauen. Die vollständige API-Oberfläche, der Testdownload und die Lizenzbedingungen der Komponente liegen auf der PDFium-Delphi-Komponenten-Produktseite