Artykuł techniczny

Redakcja PDF na poziomie operatorów w Delphi z PDFiumPas

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ł

Redakcja na poziomie operatorów w PDFiumPas na tle usuwania całych obiektów w Delphi: stara ścieżka porzuca cały obiekt tekstu, gdy zakryty jest jeden numer konta, a ścieżka z podziałem usuwa tylko przecinające się znaki i emituje każdy ocalały znak ponownie jako osobny obiekt tekstu
Tylko przypadek mieszany uruchamia podział: brak przecięć zostawia obiekt nietkniętym, pełne przecięcie usuwa go w całości

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

Jak PDFiumPas przycina częściowo zredagowany obraz w Delphi: ocalały fragment w przestrzeni strony jest mapowany z powrotem przez odwróconą CTM na znormalizowane współrzędne obrazu, zaciskany do przedziału jednostkowego i zaokrąglany do wewnątrz, więc żadna zredagowana kolumna pikseli nie przetrwa
Ceil po lewej i u góry, Floor po prawej i u dołu, więc cięcie trafia w pełną granicę pikseli
// 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

Przycinanie zasobów w PDFiumPas: trzy zakresy przywołują jeden współdzielony słownik zasobów, ich zbiory używanych nazw są sumowane per kategoria, a usuwane są tylko nazwy, do których nie odwołuje się żaden zakres, zanim nieosiągalne obiekty zostaną zamiecione
Jeden słownik może obsługiwać kilka stron, form XObjects i strumienie wyglądu, więc PDFiumPas sumuje każdy zbiór używanych nazw, zanim porzuci choć jedną nazwę

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