Ktoś rysuje czarny prostokąt na nazwisku, niczego nie spłaszcza, wysyła plik, a recenzent zaznacza ten prostokąt i wkleja nazwisko do wiadomości e-mail. PDFiumPas odpowiada na to redakcją na poziomie operatorów: SaveAsRedacted usuwa wyłącznie skalary Unicode, których ramki znaków stykają się z prostokątem redakcji, odtwarza ocalałe znaki z oryginalnej czcionki, rozmiaru, macierzy, trybu renderowania i koloru, a ścieżki i obrazy wyrównane do osi kadruje, zamiast porzucać je w całości
Dlaczego namalowany prostokąt to nie redakcja
Operacja rysowania dodana na wierzchu strumienia treści niczego nie ukrywa, ponieważ operatory wyświetlania tekstu znajdujące się pod nią wciąż są w strumieniu i wciąż mapują na punkty kodowe. ISO 32000-1 §9.4 definiuje obiekt tekstu jako sekwencję operatorów pozycjonowania i wyświetlania wewnątrz BT i ET; wypełniony prostokąt narysowany później jest po prostu kolejnym operatorem w tym samym strumieniu. Ekstrakcja przechodzi po operatorach, nie po pikselach, więc zakryty ciąg wraca w całości. Prawdziwa redakcja musi usunąć operand, a nie zasłonić wynik
Oczywista bezpieczna implementacja jest brutalna: znaleźć każdy obiekt strony, którego ramka otaczająca przecina prostokąt redakcji, i usunąć cały obiekt. Tak robiły wcześniejsze wydania PDFiumPas, jest to poprawne, ale kosztowne. Pojedynczy Tj może nieść cały wiersz tabeli, więc zamalowanie jednego numeru konta zabierało ze sobą datę, opis i kwotę. Wypełnienie prostokątne będące akurat pasem tabeli na pełną szerokość znikało z całej strony. Logo na fakturze znikało, bo redakcja dotykała jednego jego rogu. Wersja 3.101.0 przesuwa decyzję o poziom niżej, z obiektu strony na operand
Co faktycznie usuwa redakcja na poziomie operatorów?
PDFiumPas usuwa skalary Unicode, a nie obiekty tekstu. Podczas SaveAsRedacted komponent buduje mapowanie znaków na obiekty strony z wczytanej strony tekstowej, a następnie dla każdego znaku należącego do badanego obiektu odczytuje jego ramkę i przecina ją z każdym prostokątem redakcji. Znaki, które stykają się z prostokątem, są oznaczane do usunięcia; pozostałe oznaczane są jako ocalałe. Jeśli nic się nie przecina, obiekt pozostaje całkowicie nietknięty. Jeśli przecina się każdy znak, obiekt jest usuwany w całości, dokładnie jak wcześniej. Tylko przypadek mieszany uruchamia podział
Każdy ocalały znak jest następnie emitowany ponownie jako własny obiekt tekstu zbudowany z oryginalnego uchwytu czcionki, oryginalnego rozmiaru czcionki, macierzy tekstu per znak, oryginalnego trybu renderowania tekstu oraz stanu wypełnienia i obrysu obiektu nadrzędnego, łącznie ze szerokością obrysu, łączeniem i zakończeniem linii oraz wzorem kreski. Ponowne użycie uchwytu czcionki zamiast rozwiązywania nowego zachowuje identyczność metryczną glifów, a ponowne użycie macierzy per znak utrzymuje kerning i odstępy między słowami bez ponownego uruchamiania layoutu. Kosztem jest liczba obiektów: jeden zachowany znak staje się jednym obiektem tekstu, dlatego TPdfRedactionOptions.MaxSplitObjects istnieje jako twardy limit generowanych fragmentów
procedure RedactDocument(const SourcePdf, TargetPdf: string);
var
Pdf: TPdf;
Options: TPdfRedactionOptions;
Report: TPdfRedactionReport;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := SourcePdf; // plik już zawiera adnotacje /Redact
Pdf.Active := True;
Options := TPdfRedactionOptions.Default;
Options.PreservePartialObjects := True; // podział na poziomie operatorów (domyślny)
Options.RemoveIntersectingAnnotations := True;
Options.MaxSplitObjects := 20000; // limit generowanych fragmentów
if not Pdf.SaveAsRedacted(TargetPdf, Options, Report) then
raise Exception.Create(Report.ErrorMessage); // porażka blokuje zapis, nie wysyłaj pliku
finally
Pdf.Free;
end;
end;
Prostokąty podlegają kadrowaniu, obrócona geometria nie
Ścieżki są dzielone tylko wtedy, gdy PDFiumPas może udowodnić, że ścieżka jest prostokątem wyrównanym do osi. Dowód jest celowo wąski: macierz obiektu musi mieć oba współczynniki ścinania poniżej 0.0001, ścieżka musi składać się z czterech do sześciu segmentów zaczynających się od MOVETO i kontynuowanych wyłącznie przez LINETO, a przekształcone punkty muszą trafiać we wszystkie cztery rogi ramki obiektu z tolerancją 0.01. Ścieżka, która przechodzi tę kontrolę, jest redukowana przez kolejne odejmowanie prostokątów, przy czym każdy prostokąt redakcji wycina z zestawu ocalałych pasy lewy, prawy, dolny i górny, a każdy powstały pas jest odtwarzany z oryginalnym trybem wypełnienia, flagą obrysu i stanem malowania. Krzywe, trójkąty, kształty przycięte i wszystko obrócone nie przechodzą kontroli, a cały obiekt jest usuwany
Obrazy podlegają ISO 32000-1 §8.9, gdzie próbki obrazu zajmują kwadrat jednostkowy mapowany przez bieżącą macierz transformacji. PDFiumPas odwraca to mapowanie, aby zamienić każdy ocalały fragment w przestrzeni strony z powrotem na znormalizowane współrzędne obrazu, zaciska je do przedziału jednostkowego, a następnie konwertuje na indeksy pikseli, zaokrąglając do wewnątrz: lewa i górna krawędź przechodzą przez Ceil, prawa i dolna przez Floor. Ten kierunek ma znaczenie. Zaokrąglenie na zewnątrz pozwoliłoby przetrwać na krawędzi fragmentu częściowej kolumnie pikseli źródłowych po stronie zredagowanej. Całkowite granice pikseli są następnie przekształcane z powrotem na współrzędne znormalizowane i służą do wyprowadzenia macierzy fragmentu, więc przycięta bitmapa trafia dokładnie na granicę pikseli, wzdłuż której była cięta. Samo przycięcie to kopiowanie wierszy z uwzględnieniem stride w formatach Gray, BGR, BGRx i BGRA. Podobnie jak ścieżki, obraz obrócony lub skośny, albo obraz, którego macierz ma zdegenerowany współczynnik skali, jest usuwany w całości
// Po udanym wywołaniu SaveAsRedacted
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
// niczego nie dało się podzielić: każdy przecinający obiekt porzucono w całości
LogWholeObjectFallback(SourcePdf);
Dlaczego PDFiumPas kończy błędem przy znakach bez mapowania?
Ponieważ glif bez odtworzalnego skalaru Unicode nie może zostać uczciwie odtworzony. Rekonstrukcja ocalałego znaku oznacza wywołanie API ustawiania tekstu z ciągiem, a to wymaga stabilnego punktu kodowego dla każdego zachowanego znaku. Symboliczne czcionki podzbiorów z uszkodzonymi lub nieobecnymi danymi ToUnicode mogą dać puste mapowanie, a ponowne kodowanie na wyczucie wytworzyłoby wynik wyglądający poprawnie na ekranie, ale niosący pod spodem inny znak. PDFiumPas odmawia: kontrola zachowanych znaków zgłasza wyjątek, wyjątek jest przechwytywany wewnątrz SaveAsRedacted, TPdfRedactionReport.Succeeded zwraca False z komunikatem w ErrorMessage, a funkcja zwraca False. Ta sama reguła dotyczy budżetu podziału, który zgłasza wyjątek, zamiast po cichu skracać zestaw fragmentów. Gdy dokument ma czcionki, którym nie ufasz, a chcesz deterministycznego starego zachowania, ustaw Options.PreservePartialObjects := False, a każdy przecinający obiekt zniknie w całości
Przycinanie zasobów we współdzielonych zakresach
Podział obiektów zostawia sieroty, a ich przycinanie nie jest tak proste jak porównanie słownika /Resources na poziomie strony. ISO 32000-1 §7.8.3 pozwala, aby ten sam słownik zasobów był jednocześnie przywoływany przez kilka stron, przez Form XObjects, wzory i strumienie wyglądu adnotacji. Usunięcie nazwy czcionki, bo jedna strona przestała jej używać, zepsuje inną stronę, która wciąż jej używa. PruneUnusedPdfResources działa zatem per zakres: rozwiązuje /Contents, niezależnie czy to bezpośrednia tablica, referencja pośrednia do tablicy czy pojedynczy strumień, a następnie zbiera użycie zasobów z operatorów faktycznie nazywających zasoby — Tf dla czcionek, Do dla XObjects, gs dla stanu grafiki, CS, cs, SCN i scn dla przestrzeni kolorów i wzorów, sh dla cieniowań, BDC i DP dla właściwości marked-content, plus wpis /CS obrazów inline. Gdy jeden słownik jest współdzielony przez kilka zakresów, zbiory używanych nazw są sumowane per kategoria, zanim cokolwiek zostanie usunięte
Porzucane są tylko nazwy potwierdzone jako nieprzywoływane w każdym zakresie wskazującym na słownik. Zakres, którego nie można pewnie sparsować, pozostaje nietknięty, co jest kierunkiem konserwatywnym: nieprzycięty plik jest jedynie większy, błędnie przycięty jest uszkodzony. Przetrwałe słowniki są zapisywane z powrotem jako rzadka aktualizacja przyrostowa z dokładnymi numerami generacji, a przepisanie osiągalności zamiatą potem obiekty, które stały się nieosiągalne po zniknięciu nazw. TPdfResourcePruneReport raportuje ScannedScopeCount, UpdatedScopeCount, RemovedNameCount, RemovedObjectCount, liczby bajtów oraz flagę Succeeded. SaveAsRedacted uruchamia ten krok automatycznie na zsanityzowanym wyniku, więc ścieżka redakcji już go obejmuje, ale funkcja jest eksportowana na poziomie strumienia dla potoków, które chcą jej osobno
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 pozostaje False: przyrostowy zapis od nowa
// unieważniłby zakresy bajtów pokryte podpisem
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;
Wpięcie tego w potok dokumentów
Ścieżka redakcji nigdy nie mutuje wczytanego dokumentu. SaveAsRedacted przechwytuje odizolowaną migawkę, aplikuje tam adnotacje /Redact, zdejmuje załączniki, uruchamia przebieg sanityzacji usuwający akcję otwarcia, akcje katalogu, drzewa nazw, powiązane pliki, AcroForm i metadane, przycina zasoby i dopiero wtedy zapisuje strumień wyjściowy. Ponowne otwarcie tego wyniku jako niezależnego dokumentu i ponowna ekstrakcja tekstu to krok weryfikacji wart utrzymania we własnym zestawie testów, bo to jedyna kontrola, która odpowiada na pierwotne pytanie — czy czytnik wciąż może dostać ten ciąg. Jedna konsekwencja do zaplanowania: podział zastępuje obiekty strony, więc każdy trzymany uchwyt FPDF_PAGEOBJECT jest potem martwy, ta sama pułapka cyklu życia opisana w artykule o nieaktualnych uchwytach obiektów strony po transformacji
Dwa sąsiednie elementy dopełniają ten przepływ pracy. Decydowanie, gdzie trafią prostokąty redakcji, zwykle zaczyna się od wyekstrahowanej geometrii, a model bloków i kolejności czytania w artykule o strukturalnych blokach tekstu i kolejności czytania jest lepszym źródłem kandydujących ramek niż surowe ciągi znaków. Prezentowanie wyniku recenzentowi należy do zasad hartowania w artykule o budowaniu bezpiecznego podglądu PDF, gdzie wypełnianie formularzy i JavaScript pozostają domyślnie wyłączone. Razem pokrywają pętlę, której potrzebuje większość przepływów zgodności: zlokalizuj, zredaguj na poziomie operatorów, zweryfikuj przez ponowne otwarcie, podejrzyj bezpiecznie. Pełna powierzchnia API, pobranie wersji próbnej i warunki licencyjne komponentu są na stronie produktu PDFium Delphi Component