Artykuł techniczny

Mapowanie tekstu PDF na bajty strumienia treści w Delphi

Jeden błędny znak w numerze faktury, a jedyna dostępna operacja edycji przepisuje cały przebieg tekstu. PDF Library for Delphi zamyka tę lukę: GetTextBlockCharContentLocation mapuje każdą pozycję UTF-16 z powrotem na instrukcję strumienia treści, operand i zakres zakodowanych bajtów, które ją wytworzyły, a ReplaceTextBlockCharSourceBytes nadpisuje tylko ten zakres. Zwykła ekstrakcja tekstu odrzuca wszystko, co byłoby do tego potrzebne. Otrzymujesz Unicode, szerokości i geometrię, a pochodzenie znika, więc znak na pozycji 7 bloku 3 jest po prostu znakiem. Który strumień go wytworzył, która instrukcja, który operand, który bajt w tym operandzie — wszystko znika. Każda strategia edycji punktowej zbudowana na tym poziomie musi zgadywać, zwykle wyszukując podłańcuch w zdekodowanej treści i licząc, że występuje dokładnie raz. Na prawdziwej stronie tak nie jest

Dlaczego przepisanie całego przebiegu tekstu niszczy układ strony?

Bo przebieg to nie tylko tekst. Operatory wyświetlania tekstu w ISO 32000-1 §9.4.3 obejmują TJ, którego operand jest tablicą przeplatającą łańcuchy z liczbowymi korektami, a te liczby są składem tekstu. Wiersz zapisany jako [(AB) -120 (CD)] TJ zawiera kerning 120 tysięcznych em między dwoma łańcuchami. Wygeneruj nowy Tj ze scalonym tekstem, a kerning zniknie, wiersz przesunie się odrobinę, a na formularzu wartość wyjdzie poza pole. Ten sam argument dotyczy fontu: bajty operandu są kodami w kodowaniu wybranym przez Tf, a dla fontu złożonego mogą być dwubajtowymi CID bez związku z odczytanym znakiem. Ponowne wygenerowanie przebiegu wymaga poprawności kodowania fontu, jego mapy /ToUnicode i pokrycia glifów. Edycja punktowa omija to wszystko, ponieważ nigdy nie opuszcza domeny bajtów

Co zwraca GetTextBlockCharContentLocation?

Metoda rozwiązuje jeden znak do rekordu z dziewięcioma polami, a każde pole jest adresem, nie wartością. ContentLayer to indeks liczony od 1 w tablicy /Contents strony albo 0, gdy znak pochodzi z zagnieżdżonej treści. StreamObjectNumber i StreamGeneration identyfikują zawierający go strumień. InstructionIndex to pozycja liczona od 0 w zdekodowanym programie treści, OperandIndex oznacza operand łańcucha tekstowego, a ArrayElementIndex element w tablicy TJ albo -1 dla bezpośredniego operandu łańcuchowego. SourceByteOffset i SourceByteLength wskazują następnie zakres bajtów wewnątrz tego zdekodowanego łańcucha

Var
  Lib: TPDFlib;
  ListID, Block, CharPos: Integer;
  ContentLayer, StreamObjectNumber, StreamGeneration: Integer;
  InstructionIndex, OperandIndex, ArrayElementIndex: Integer;
  SourceByteOffset, SourceByteLength, Flags: Integer;
Begin
  Lib:= TPDFlib.Create;
  Try
    Lib.LoadFromFile('invoice.pdf', '');
    Lib.SelectPage(1);
    ListID:= Lib.ExtractPageTextBlocks(3);
    Try
      // Block i CharPos pochodzą z własnego skanowania GetTextBlockText
      If Lib.GetTextBlockCharContentLocation(ListID, Block, CharPos,
        ContentLayer, StreamObjectNumber, StreamGeneration,
        InstructionIndex, OperandIndex, ArrayElementIndex,
        SourceByteOffset, SourceByteLength, Flags)= 1 Then
      Begin
        // ContentLayer = 0 oznacza, że glif znajduje się w zagnieżdżonym Form XObject
        // ArrayElementIndex = -1 oznacza zwykły operand Tj, a nie tablicę TJ
      End;
    Finally
      Lib.ReleaseTextBlocks(ListID);
    End;
  Finally
    Lib.Free;
  End;
End;

Wyszukanie nic nie kosztuje w chwili zapytania. Gdy renderer dekoduje każdą warstwę treści, rejestruje logiczne zakresy, po których się porusza, więc zapytanie o pozycję jest wyszukiwaniem binarnym w uporządkowanej liście przedziałów, a nie liniowym skanowaniem każdego zakresu treści dla każdego znaku. Przy zapytaniu nic nie jest ponownie parsowane; mapa powstała podczas opłaconej już fazy ekstrakcji. Jeśli już wyliczasz trafienia przez wyszukiwanie tekstu PDF zwracające współrzędne trafień, dodanie lokalizacji treści do każdego trafienia jest prawie darmowe

Edycja bajtów, nie Unicode

ReplaceTextBlockCharSourceBytes przyjmuje AnsiString surowych bajtów zastępczych w aktywnym kodowaniu fontu PDF. To cała koncepcja i jest zamierzona. Nic nie jest transkodowane, nic nie jest ponownie kodowane, niczego nie zgaduje się na podstawie fontu. Biblioteka wstawia twoje bajty w nazwanym zakresie docelowego łańcucha i ponownie emituje zawierającą go warstwę treści. Sąsiednie łańcuchy w tej samej tablicy TJ oraz liczbowe wartości kerningu między nimi pozostają identyczne bajt po bajcie. W powyższym układzie znalezienie B w [(AB) -120 (CD)] TJ daje ArrayElementIndex 0, SourceByteOffset 1 i SourceByteLength 1. Zastąp go Z, a wyemitowana treść będzie zawierać (AZ), nadal za nią -120 i (CD), oba nietknięte. Pakiet regresyjny sprawdza to dokładnie, ponieważ stwierdzenie „zachowaliśmy kerning” należy do twierdzeń, które po cichu przestają być prawdziwe

Function EditableHere(Flags: Integer): Boolean;
Begin
  Result:= ((Flags and PDF_TEXT_CHAR_CONTENT_LOCATION_VALID)<> 0)and
    ((Flags and (PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED or
      PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT or
      PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED or
      PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER or
      PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED))= 0);
End;

// ...
If EditableHere(Flags) Then
Begin
  If Lib.ReplaceTextBlockCharSourceBytes(ListID, Block, CharPos, 'Z')= 1 Then
  Begin
    // Każda lokalizacja na starej liście jest teraz nieaktualna. Wykonaj ekstrakcję ponownie
    Lib.ReleaseTextBlocks(ListID);
    ListID:= Lib.ExtractPageTextBlocks(3);
  End
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_STALE Then
    // Warstwa zmieniła się od czasu ekstrakcji
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_READ_ONLY Then
    // Flaga, której nie sprawdziliśmy, albo flaga dodana przez nowszą wersję
End;

Warto zapamiętać dwa szczegóły operacyjne. Wywołanie tymczasowo przełącza się na stronę, z której wyodrębniono listę tekstu, i przywraca poprzednio wybraną stronę zarówno po sukcesie, jak i po błędzie, więc nie przesuwa po cichu twojego kursora. Po sukcesie czyści migawki elementów strony, co unieważnia wszystkie uchwyty zachowane z wcześniejszego przebiegu enumeracji

Których znaków nie można edytować?

Sześciu kategorii, a biblioteka nazywa każdą z nich w masce bitowej Flags zamiast kończyć działanie niejasnym błędem. Ma to większe znaczenie niż ścieżka sukcesu, ponieważ w prawdziwych dokumentach przypadki bez mapowania są częste, a każdy z nich ma inną przyczynę

  • PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE: kilka pozycji UTF-16 z ekstrakcji rozwija się z jednego glifu źródłowego. Wpis /ToUnicode mapujący jeden kod na fi daje dwa znaki współdzielące jeden zakres bajtów, więc traktuj je jako jeden glif źródłowy i edytuj zakres tylko raz
  • PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED: znak został zsyntetyzowany podczas układu. Zwykle są to wywnioskowane spacje między słowami, które nie mają żadnych bajtów źródłowych, więc SourceByteOffset wraca jako -1, a SourceByteLength wynosi 0
  • PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT: odczytany tekst pochodził z zamiany /ActualText. Nie istnieje jednoznaczne mapowanie odwrotne z podstawionego łańcucha na bajty źródłowe, więc lokalizacja ma charakter wyłącznie diagnostyczny
  • PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED: glif znajduje się wewnątrz Form XObject. Bajty są adresowalne, ale Form może być rysowany przez wiele stron, więc edycja go przez API wysokiego poziomu byłaby edycją, o którą nie prosiłeś
  • PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED: operand był łańcuchem szesnastkowym zawierającym znacznik kolejności bajtów UTF-16BE, który istniejąca ścieżka ekstrakcji dekoduje przed mapowaniem fontu. Offsety w zdekodowanym wyniku nie adresują już oryginalnych bajtów, więc flaga valid zostaje wyczyszczona
  • PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER: operand łańcuchowy i operator wyświetlający tekst znajdują się w dwóch różnych strumieniach

Ten ostatni przypadek zasługuje na osobne zdanie, ponieważ inżynierowie regularnie zakładają, że nie może się wydarzyć. ISO 32000-1 §7.8.2 mówi, że strumienie w tablicy /Contents strony są łączone, a podział między nimi musi wypadać wyłącznie na granicy leksykalnej. Zatem BT /F1 16 Tf 220 340 Td (CrossLayer) w jednym strumieniu oraz Tj ET w następnym są całkowicie poprawną stroną. Mapowanie zachowuje pozycję diagnostyczną, ale oznacza ją jako tylko do odczytu, ponieważ indeks instrukcji operatora należy do innej warstwy niż bajty operandu, a użycie jednego do adresowania drugiego uszkodziłoby plik

Skąd biblioteka wie, że mapa jest nadal poprawna?

Po odciskach palca sprawdzanych bezpośrednio przed zapisem. Każda lista ekstrakcji rejestruje stronę źródłową oraz dla każdej warstwy treści długość warstwy i dwa niezależne hasze kroczące: hasz FNV-1a i hasz XOR w stylu DJB2. Zanim ReplaceTextBlockCharSourceBytes przeparsuje cokolwiek, ponownie odczytuje docelową warstwę i porównuje wszystkie trzy wartości. Dowolna zmiana bajtu w tej warstwie zwraca PDFLIB_ERROR_TEXT_LOCATION_STALE i zapis nie następuje. To celowo konserwatywne podejście: sprawdzenie dotyczy warstwy, a nie instrukcji, więc niezwiązana edycja w innym miejscu tego samego strumienia treści także unieważnia lokalizację. To właściwy kompromis: offset w strumieniu przesunięty choćby o jeden bajt nie jest prawie poprawny, lecz prowadzi do cichego uszkodzenia. Ta sama dyscyplina obowiązuje resztę powierzchni edycji, w tym tracker stanu strumienia treści dla CTM i clipping. Po każdym udanym zastąpieniu porzuć listę i wykonaj ekstrakcję ponownie

Mapowanie tylko do odczytu przez Direct Access

DAGetTextBlockCharContentLocation daje identyczny rekord dla strony otwartej przez ścieżkę Direct Access, z identycznym słownictwem flag. Z definicji służy wyłącznie diagnostyce: ReplaceTextBlockCharSourceBytes działa na wybranym edytowalnym dokumencie, a Direct Access jest ścieżką odczytu. Dane lokalizacji pozostają w liście bloków tekstu po zamknięciu uchwytu pliku, dzięki czemu można ich używać do audytu offline

FileHandle:= Lib.DAOpenFileReadOnly('audit.pdf', '');
Try
  PageRef:= Lib.DAFindPage(FileHandle, 1);
  DirectList:= Lib.DAExtractPageTextBlocks(FileHandle, PageRef, 3);
  Try
    Lib.DAGetTextBlockCharContentLocation(DirectList, Block, 1,
      ContentLayer, StreamObjectNumber, StreamGeneration,
      InstructionIndex, OperandIndex, ArrayElementIndex,
      SourceByteOffset, SourceByteLength, Flags);
    // Lokalizacje pozostają czytelne po DACloseFile
  Finally
    Lib.DAReleaseTextBlocks(DirectList);
  End;
Finally
  Lib.DACloseFile(FileHandle);
End;

Używaj tego do odpowiadania na pytania, a nie do zmieniania danych. Które strony zawierają tekst, którego nigdy nie dało się edytować w miejscu? Jaka część korpusu przychodzi z nadpisaniami /ActualText? Który producent rozdziela operatory między warstwy treści? Takie zapytania są tanie, gdy każdy znak ma adres, i warto je uruchomić przed zatwierdzeniem pipeline'u korekty

Gdzie kończy się edycja punktowa

Edycja punktowa jest skalpelem, a nie silnikiem tekstowym. Zmienia bajty w miejscu, więc tekst zastępczy szerszy albo węższy od oryginalnego nie przeprowadzi reflow, nie zawinie się ponownie i nie zaktualizuje kerningu wokół siebie. Zamiana jednej cyfry na inną w polu o stałej szerokości jest dobrym zastosowaniem. Przepisanie akapitu nie jest. I zdecydowanie nie jest to narzędzie bezpieczeństwa: nadpisanie bajtów glifu pozostawia oryginalne bajty możliwe do odzyskania z historii rewizji pliku, więc wszystko, co wymaga poufności, należy do prawdziwej redakcji usuwającej treść, a nie zakrywającej ją. W zamian za te ograniczenia otrzymujesz uczciwość. Każdy znak ma albo adres bajtowy, na który możesz oddziałać, albo nazwane oznaczenie mówiące, dlaczego nie możesz, a kontrola odcisku palca zmienia nieaktualną mapę w twardy błąd zamiast w uszkodzoną stronę. Mapowanie znaku na bajt treści i zastępowanie bajtów źródłowych w miejscu są częścią powierzchni ekstrakcji tekstu i edycji treści PDF Library for Delphi, natywnej biblioteki PDF Object Pascal dla Delphi, C++Builder i Lazarus