Artykuł techniczny

Wyszukiwanie i zamiana tekstu w istniejącym PDF w Delphi

Komponent HotPDF dla Delphi potrafi wyszukiwać i zamieniać tekst wewnątrz istniejącego pliku PDF z poziomu Delphi i C++Builder. SearchLoadedPageText i SearchLoadedDocumentText odnajdują każde wystąpienie łańcucha z precyzją na poziomie glifu, a ReplaceLoadedPageText i ReplaceLoadedDocumentText przepisują dopasowane bajty w miejscu — pod warunkiem, że każdy znak zamiennika da się zakodować z powrotem przez oryginalną czcionkę, co jest ograniczeniem fizycznym, które ten artykuł traktuje uczciwie, zamiast chować je w przypisie

Prośba stojąca za tą funkcją jest zawsze przyziemna. Firma zmienia nazwę, a trzy tysiące zarchiwizowanych faktur wciąż niesie starą. Szablon umowy wyszedł z zeszłoroczną datą wygaśnięcia. Kod produktu wycofano i każda karta katalogowa, która go wymienia, potrzebuje zamiast niego kodu następcy. W edytorze tekstu każda z tych rzeczy to zadanie na trzydzieści sekund. W pliku PDF to naprawdę trudny problem, a zrozumienie dlaczego stanowi różnicę między dobrym używaniem API a zgłoszeniem błędu, które w istocie jest cytatem ze specyfikacji

Dlaczego zamiana tekstu w PDF jest tak trudna?

Zamiana tekstu w PDF jest trudna, ponieważ strona PDF nie zawiera edytowalnego tekstu — zawiera pozycjonowane glify. W modelu wyświetlania tekstu z ISO 32000-1 §9.4 strumień treści steruje operatorami takimi jak Tj i TJ, które malują sekwencje kodów znaków we współrzędnych ustalonych przez macierz tekstu. Te kody nie są Unicode; są indeksami do dowolnego kodowania, jakie deklaruje czcionka strony, a odwzorowanie z powrotem na czytelne znaki może mieszkać w CMap /ToUnicode, w tablicy różnic kodowania albo w łańcuchu odwzorowań CID. Nie ma obiektu akapitu, nie ma przepływu tekstu i nie ma gwarancji, że jedno wizualne słowo jest w ogóle przechowywane jako jeden łańcuch

Zamiana dokłada drugą warstwę trudności ponad dekodowaniem: musisz wiedzieć dokładnie, które bajty oryginalnego strumienia wyprodukowały każdy glif, żeby wstawić nowe bajty dokładnie w ten przedział i w nic więcej. Ekstraktor tekstu może pozwolić sobie na wyrzucenie pozycji bajtowych, gdy wydobędzie już Unicode. Moduł zamiany nie może. Dlatego HotPDF rozłożył tę pracę na dwa wydania — v2.251.0 zbudowało warstwę śledzenia przesunięć i wyszukiwania, a v2.252.0 zbudowało na niej warstwę przepisywania

Znajdowanie tekstu: wyszukiwanie na poziomie glifów ze śledzeniem przesunięć bajtowych

SearchLoadedDocumentText w HotPDF znajduje każde wystąpienie szukanej frazy przez dopasowanie do zdekodowanej sekwencji glifów Unicode każdej strony, a nie do surowych bajtów strumienia, więc trafienie jest trafieniem niezależnie od tego, jak czcionka je zakodowała. Infrastrukturę pod spodem wprowadzono w v2.251.0: tokenizer strumienia treści zapisuje przedział bajtowy StartOfs/EndOfs dla każdego operandu łańcuchowego — wraz z jego ogranicznikami ( ) albo < > — a każdy zdekodowany glif niesie trójkę TokenIndex/ItemIndex/ByteOffset wskazującą z powrotem na dokładny operand, element tablicy TJ i jednostkę kodową, która go wyprodukowała. Ten sam interpreter glifów napędza API ekstrakcji opisane w artykule o wydobywaniu tekstu z wczytanego pliku PDF w Delphi; wyszukiwanie po prostu zachowuje pochodzenie, które ekstrakcja odrzuca

Jak wyszukiwanie HotPDF w Delphi śledzi przedziały bajtowe StartOfs i EndOfs podczas dekodowania glifów na Unicode dla wyników THPDFTextMatch
Wyszukiwanie w HotPDF działa na zdekodowanej sekwencji glifów, zachowując pochodzenie bajtowe, którego potrzebuje zamiana

Każde dopasowanie wraca jako rekord THPDFTextMatch niosący indeks strony, domknięty zakres glifów, początek X/Y trafienia w przestrzeni użytkownika i jego szerokość, indeks tokenu i elementu źródłowego oraz sam dopasowany tekst. To wystarcza, by napędzić nakładkę podświetlenia, interfejs przeglądu albo krok zamiany. Wyszukiwanie, które nic nie znajduje, zwraca pustą tablicę zamiast zawodzić, więc wzorzec wywołania pozostaje prosty

var
  Pdf: THotPDF;
  Matches: THPDFTextMatchArray;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('invoices-2025.pdf') > 0 then
    begin
      if Pdf.SearchLoadedDocumentText('Acme Corp', False, Matches) then
        for I := 0 to Length(Matches) - 1 do
          WriteLn(Format('page %d at (%.1f, %.1f): "%s"',
            [Matches[I].PageIndex, Matches[I].X, Matches[I].Y,
             Matches[I].Text]));
    end;
  finally
    Pdf.Free;
  end;
end;

Jeden świadomy wybór projektowy zasługuje na notatkę. Gdy CaseSensitive jest False, porównanie ujednolica wielkość liter wyłącznie dla znaków ASCII, i to z założenia: pełne ujednolicanie wielkości liter w Unicode zachowuje się różnie w łańcuchach narzędziowych od Delphi 5 po XE, które HotPDF wspiera, a API wyszukiwania znajdujące różne dopasowania zależnie od tego, który kompilator zbudował twoją aplikację, jest gorsze od takiego z udokumentowanym, przewidywalnym ograniczeniem. Dla łacińskiego tekstu biznesowego — nazw, kodów, dat — ujednolicanie ASCII pokrywa praktyczne przypadki

Zamiana tekstu: odwrotne kodowanie i chirurgiczne wstawianie

ReplaceLoadedDocumentText, dodane w HotPDF v2.252.0, przepisuje każde wystąpienie szukanej frazy, uruchamiając maszynerię dekodowania w drugą stronę. Funkcja HPDFEncodeUnicode jest odwrotnością dekodera kodów znaków: przechodzi ten sam łańcuch strategii wstecz — wyszukanie bfchar i bfrange w /ToUnicode, odwzorowanie CID ze strumienia kodowania, odwzorowania tożsamościowe Type0 oraz predefiniowane tablice WinAnsi i MacRoman — by zamienić każdy znak zamiennika z powrotem na bajty kodu znaku, których oczekuje oryginalna czcionka. Zakodowane na nowo bajty są następnie serializowane w poprawny literał łańcuchowy albo łańcuch szesnastkowy, odzwierciedlając własne reguły cytowania tokenizera, tak by podróż parsowanie → ponowna serializacja była stabilna

Samo wstawienie jest chirurgiczne, a nie hurtowe. Wewnątrz operandu łańcuchowego zastępowany jest wyłącznie zakres bajtów kodu objęty dopasowaniem; niedopasowane bajty w tym samym operandzie, odstępy między tokenami i każdy otaczający operator są zachowywane dosłownie, bajt w bajt. Zamiana bca wewnątrz abcabc daje a + zamiennik + bc, a nie rozwalony operand. Zamienniki mogą być krótsze albo dłuższe od szukanej frazy — literał jest serializowany na nowo, a /Length strumienia odświeżane — a każdy strumień /Contents strony wielostrumieniowej jest przetwarzany osobno, żeby strona pozostała poprawna

Jak zamiana w HotPDF w Delphi odwraca łańcuch dekodowania przez HPDFEncodeUnicode i wstawia wyłącznie dopasowany zakres bajtów operandu łańcuchowego
Odwrotne kodowanie odbudowuje kody znaków czcionki, a potem przepisywany jest wyłącznie dopasowany zakres bajtów wewnątrz operandu
var
  Pdf: THotPDF;
  ReplaceCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-draft.pdf') > 0 then
    begin
      if Pdf.ReplaceLoadedDocumentText('2025-12-31', '2026-12-31',
        True, ReplaceCount) then
        WriteLn(Format('%d operand rewrites performed', [ReplaceCount]));
      Pdf.SaveLoadedDocument('contract-final.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Zwróć uwagę, czego to API nie robi: nie składa strony na nowo. PDF nie ma przepływu tekstu, więc zamiennik wizualnie szerszy od oryginału po prostu zajmie więcej miejsca w poziomie i może stłoczyć to, co namalowano na prawo od niego. Podstawienia o tej samej albo zbliżonej długości — daty, oznaczenia wersji, numery części, poprawki nazwisk — są punktem optymalnym. Hurtowe przeredagowanie należy do dokumentu źródłowego, a nie do PDF

Dlaczego nie da się zamienić tekstu na znaki, których podzbiór czcionki nigdy nie zawierał?

Nie da się zamienić tekstu na znak, którego osadzony podzbiór czcionki nigdy nie zawierał, ponieważ sekwencja bajtów wybierająca ten znak po prostu nie istnieje w tablicach odwzorowań czcionki. Gdy producent PDF osadza czcionkę w postaci podzbioru, jego CMap /ToUnicode i struktury kodowania pokrywają wyłącznie glify, których oryginalny dokument faktycznie użył. HPDFEncodeUnicode potrafi odwrócić tylko odwzorowanie, które jest obecne: jeśli dokument nigdy nie zawierał litery E w tej czcionce, nie ma kodu znaku, do którego E mogłoby się odwrócić. To fizyczna właściwość pliku, a nie ograniczenie konkretnej biblioteki — żadne narzędzie nie wyczaruje odwzorowania glifu, którego nigdy nie osadzono

HotPDF obsługuje to niepowodzenie zachowawczo. Jeśli choć jednego znaku zamiennika nie da się zakodować z powrotem, całe to wystąpienie szukanej frazy jest pomijane — bez wyjątku, bez częściowych śmieci w tekście, a wystąpienie po prostu nie jest liczone w ReplaceCount. Praktyczna konsekwencja: zestaw ReplaceCount z liczbą dopasowań z wcześniejszego wyszukiwania i potraktuj niedobór jako sygnał. W powyższym przykładzie z datą cyfra 6 musi pojawić się gdzieś w tekście dokumentu w tej samej czcionce, by przepisanie się udało — prawdopodobnie na fakturze, nigdy zagwarantowane w ogólności. Gdy potrzebnych znaków po prostu nie ma, a celem jest usunięcie tekstu wrażliwego, a nie jego przeredagowanie, prawdziwe usuwanie treści jest i tak lepszym narzędziem; tę ścieżkę opisuje redakcja i przebudowa wczytanych plików PDF w Delphi

Dlaczego HotPDF pomija całą zamianę tekstu w PDF w Delphi, gdy osadzonemu podzbiorowi czcionki brakuje potrzebnego znaku, co weryfikuje się przez ReplaceCount
Znaki zamiennika, których podzbiór czcionki nie potrafi zakodować, powodują pominięcie całego wystąpienia, więc niedobór w ReplaceCount jest realnym sygnałem
var
  Matches: THPDFTextMatchArray;
  Expected, Replaced: Integer;
begin
  Pdf.SearchLoadedDocumentText('Acme Corp', True, Matches);
  Expected := Length(Matches);
  Pdf.ReplaceLoadedDocumentText('Acme Corp', 'Apex Corp', True, Replaced);
  if Replaced < Expected then
    WriteLn(Format('%d occurrence(s) skipped: characters missing ' +
      'from the font subset, or match spans multiple operands',
      [Expected - Replaced]));
end;

Drugi warunek pominięcia z tego komunikatu to druga udokumentowana granica: szukana fraza rozciągnięta na wiele operandów łańcuchowych — na przykład Hello rozbite na elementy [(He)(llo)] TJ — jest znajdowana przez wyszukiwanie, ponieważ wyszukiwanie dopasowuje zdekodowaną sekwencję glifów, ale pomijana przez zamianę, ponieważ przepisywanie przez granice operandów wymagałoby scalenia sąsiednich przedziałów bajtowych. Wyszukaj, a potem zweryfikuj — to czyni obie granice widocznymi zamiast milczącymi

Co zmienia się w pliku przy zapisie?

Zamieniony strumień /Contents jest zapisywany nieskompresowany. Strumienie skompresowane metodą FlateDecode są dekompresowane do edycji, a gdy HotPDF zapisuje odbudowane bajty, usuwa wpis /Filter strumienia i odświeża /Length, zamiast kompresować ponownie. Powstały PDF jest w pełni prawidłowy i renderuje się normalnie w głównych przeglądarkach; ceną jest większy plik za każdy edytowany strumień. Dla potoku wsadowego przetwarzającego tysiące dokumentów zaplanuj ten przyrost albo uruchom osobne przejście kompresji dalej w łańcuchu. To, jak przepisane obiekty współgrają ze strukturą odsyłaczy dokumentu przy zapisie, jest osobnym tematem, omówionym w artykule o strumieniach obiektów i aktualizacjach przyrostowych w HotPDF

Wszystko inne w pliku zostaje nietknięte. Nieruszane strumienie zachowują swoją kompresję, czcionki i obrazy nie są przepisywane, a wstawianie na poziomie operandu oznacza, że nawet edytowane strumienie różnią się od oryginału wyłącznie tam, gdzie wylądowało dopasowanie. Ta zachowawczość jest celowa: im więcej z wczytanego dokumentu biblioteka przepisuje, tym więcej ma okazji, by zepsuć dziwactwo producenta, którego nie przewidziała

Wyszukiwanie i zamiana tekstu dołączają do ekstrakcji, redakcji i renderowania stron w zestawie narzędzi wczytanego dokumentu w HotPDF, wszystkie napędzane przez ten sam interpreter strumienia treści i dostępne od Delphi 5 po bieżące wydania RAD Studio bez zewnętrznych zależności. Pełne odniesienie do API i pobranie wersji próbnej znajdują się na stronie produktu komponentu HotPDF dla Delphi