Artykuł techniczny

Przyrostowe aktualizacje PDF w Delphi: Przewodnik po AppendToStream

Przyrostowe aktualizacje PDF pozwalają aplikacji w Delphi modyfikować dokument poprzez dołączanie tylko zmienionych obiektów, pozostawiając każdy oryginalny bajt nienaruszony. losLab PDF Library implementuje to za pomocą AppendToStream, które zapisuje tylko sekcję przyrostową zdefiniowaną przez ISO 32000-1 §7.5.6, dzięki czemu edycja jednej zakładki w pliku o rozmiarze 2 GB kosztuje kilobajty wyjściowe zamiast pełnego przepisania pliku. Ten sam mechanizm pozwala na aktualizację podpisanych dokumentów bez unieważniania ich podpisów

Problem, który to rozwiązuje, jest konkretny. Pełne zapisanie przepisuje cały plik: każdy obiekt jest ponownie serializowany, każde przesunięcie w tabeli odnośników (cross-reference) jest przeliczane, a plik wyjściowy nie ma żadnego powiązania na poziomie bajtów z plikiem wejściowym. Dla faktury o rozmiarze 40 KB to żaden problem. Dla zeskanowanego archiwum o rozmiarze 2 GB, w którym poprawiono jedynie literówkę w tytule dokumentu, przepisywanie dwóch gigabajtów w celu zmiany dwudziestu bajtów jest absurdalne — a jeśli plik zawierał podpis cyfrowy, ponowny zapis właśnie go zniszczył

Dlaczego zapisanie pliku PDF niszczy jego podpis cyfrowy?

Podpis cyfrowy PDF nie podpisuje logicznej zawartości dokumentu; podpisuje zakresy bajtów fizycznego pliku. Wpis /ByteRange w słowniku podpisu rejestruje dokładnie, które obszary pliku obejmuje skrót kryptograficzny. Każda operacja zapisu, która ponownie serializuje te bajty — nawet taka, która tworzy dokument identyczny semantycznie — zmienia skrót, a każdy walidator zgłosi podpis jako uszkodzony. Dzieje się tak z założenia: podpis poświadcza bajty, które widział podpisujący, a nie abstrakcyjny model dokumentu

Aktualizacje przyrostowe są furtką awaryjną przewidzianą w specyfikacji PDF. Ponieważ zapis przyrostowy dołącza nowe dane po oryginalnym znaczniku %%EOF i nigdy nie dotyka podpisanych zakresów bajtów, istniejący podpis nadal poprawnie waliduje się w odniesieniu do bajtów, które obejmuje. Walidatory klasyfikują następnie dołączone zmiany oddzielnie — jako drugi podpis, wypełnienie formularza, adnotację — i decydują, czy są to dozwolone modyfikacje. Każdy przepływ pracy z wieloma podpisami opiera się na tym rozwiązaniu: każdy podpisujący dodaje sekcję przyrostową na wierzchu poprzedniej. Jeśli budujesz potoki podpisywania, powiązany artykuł o podpisywaniu i walidacji PAdES w Delphi szczegółowo opisuje, jak wchodzą w interakcję zakresy bajtów podpisu i sekcje przyrostowe

Jak działają aktualizacje przyrostowe według ISO 32000-1 §7.5.6

ISO 32000-1 §7.5.6 definiuje ten model za pomocą trzech reguł. Po pierwsze, oryginalna zawartość pliku pozostaje całkowicie nienaruszona — ani jeden bajt się nie przesuwa. Po rozdziale drugim, zmienione i nowo utworzone obiekty są dołączane po ostatnim znaczniku %%EOF, każdy z tym samym numerem obiektu, który miał wcześniej (zmienione obiekty otrzymują po prostu nowszą definicję, która przesłania starą). Po trzecie, dołączana jest nowa sekcja odnośników (cross-reference) i zwiastun (trailer); wpis zwiastuna /Prev wskazuje z powrotem na przesunięcie bajtowe poprzedniej sekcji odnośników, tworząc łańcuch, po którym czytnik przechodzi od najnowszego do najstarszego, aby dopasować każdy obiekt do jego najnowszej definicji

Z tej struktury wynikają dwie użyteczne właściwości. Aktualizacje są tanie proporcjonalnie do tego, co się zmieniło, a nie do rozmiaru dokumentu — koszt dołączenia to rozmiar zmodyfikowanych obiektów plus mały narzut na sekcję xref/trailer. Plik staje się również własną historią wersji: każda wcześniejsza rewizja jest nadal fizycznie obecna, więc audytor może uciąć plik na dowolnym wcześniejszym znaczniku %%EOF i odzyskać dokładnie taki dokument, jaki istniał w tamtym momencie. W przypadku przepływów pracy związanych ze zgodnością, które muszą udowodnić, jak dokument wyglądał przed każdą zmianą, ta wbudowana ścieżka audytu jest często decydującym argumentem za zapisami przyrostowymi

Zapisywanie aktualizacji przyrostowej za pomocą AppendToStream

losLab PDF Library udostępnia wyjście przyrostowe poprzez metodę AppendToStream(AppendMode: Integer; OutStream: TStream): Integer, która zwraca 1 w przypadku sukcesu i 0 w przypadku niepowodzenia. Parametr AppendMode wybiera, co trafia do strumienia docelowego. Tryb 0 zapisuje kompletny plik: oryginalne bajty źródłowe są najpierw kopiowane do strumienia, a następnie dołączana jest sekcja przyrostowa. Tryb 1 zapisuje tylko samą sekcję przyrostową — deltę — i całkowicie pomija bajty źródłowe. Tryb 2 najpierw zapisuje dostarczony przez wywołującego prefiks zarejestrowany przez SetAppendInputFromString, a następnie dołącza na jego wierzchu sekcję aktualizacji

var
  Doc: TPDFlib;
  Delta: TMemoryStream;
begin
  Doc := TPDFlib.Create;
  try
    if Doc.LoadFromFile('contract.pdf', '') <= 0 then
      Exit;

    // Small edit: the kind of change that should not
    // trigger a rewrite of the whole file
    Doc.SetInformation(3, 'Amended 2026-07-04');  // key 3 = /Subject

    Delta := TMemoryStream.Create;
    try
      // AppendMode = 1: write only the incremental section.
      // Original bytes + Delta = a complete, valid PDF.
      if Doc.AppendToStream(1, Delta) = 1 then
        Delta.SaveToFile('contract.delta.bin');
    finally
      Delta.Free;
    end;
  finally
    Doc.Free;
  end;
end;

Tryb 1 jest interesujący z perspektywy projektowania systemu. Ponieważ delta jest samodzielna, można ją przesyłać niezależnie od oryginału: przechowywać rewizje jako osobne obiekty typu blob w magazynie obiektów, replikować tylko delty do zdalnej lokalizacji lub rekonstruować dowolną rewizję poprzez łączenie pliku bazowego z jego łańcuchem przyrostów. Reguła rekonstrukcji to zwykła konkatenacja bajtów — najpierw oryginalny plik, a potem każda kolejna delta po kolei — ponieważ dokładnie taki układ nakazuje §7.5.6 dla pliku zaktualizowanego przyrostowo

Jak biblioteka oblicza przesunięcia xref bez kopiowania oryginalnego pliku?

Wpisy tabeli odnośników wewnątrz sekcji przyrostowej muszą zawierać bezwzględne przesunięcia bajtowe — pozycje mierzone od początku kompletnego pliku, a nie od początku delty. Tworzy to zagadkę dla trybu 1: program zapisujący nigdy nie emituje oryginalnych bajtów, jednak każde rejestrowane przesunięcie musi udawać, że tam są. losLab PDF Library rozwiązuje ten problem za pomocą wewnętrznego adaptera strumienia, TPDFAppendSectionStream, który prezentuje wirtualną przestrzeń współrzędnych dla serializatora. Adapter jest tworzony z długością bajtową oryginalnego pliku jako przesunięciem bazowym, zgłasza swoją pozycję i rozmiar jako tę bazę plus wszystko, co zostało dołączone do tej pory, i przekazuje tylko nowo zapisane bajty do strumienia docelowego wywołującego

Konsekwencją jest to, że tryb 1 nigdy nie tworzy kopii dokumentu źródłowego — ani na dysku, ani w pamięci. Naiwna implementacja (zapisanie całego pliku do bufora tymczasowego, a następnie odcięcie końcówki) wiązałaby się z przejściowym utrzymywaniem kopii całego oryginalnego PDF, co przy danych wejściowych rzędu gigabajtów jest dokładnie tym kosztem, którego unika się dzięki aktualizacjom przyrostowym. Ta technika wirtualizacji przesunięć jest bliskim krewnym przesunięcia referencji bajtowych stosowanego w innych miejscach biblioteki; artykuł o szybkim łączeniu PDF za pomocą przesunięcia referencji bajtowych pokazuje tę samą ideę zastosowaną do łączenia dokumentów, a przewodnik po łączeniu i dzieleniu dużych plików PDF z bezpośrednim dostępem do plików opisuje architekturę wejścia/wyjścia dla plików, które nie mieszczą się w pamięci RAM

Strumieniowanie pełnych zapisów za pomocą SaveToStream

Wyjście przyrostowe to tylko połowa historii o strumieniowaniu; druga połowa dotyczy tego, co dzieje się przy pełnym zapisie. Metoda SaveToStream w losLab PDF Library kieruje serializator dokumentu bezpośrednio do strumienia docelowego, zamiast najpierw renderować cały dokument do pośredniego łańcucha AnsiString, a następnie zapisywać ten bufor w jednym wywołaniu. Starsze podejście działało, ale oznaczało, że każdy pełny zapis przejściowo utrzymywał drugą pełną kopię wyjściową w pamięci — nieszkodliwe przy 10 MB, bolesne przy 500 MB i stanowiące twardą barierę dla gigabajtowych plików wyjściowych w procesach 32-bitowych. Bezpośrednia serializacja sprawia, że szczytowe zużycie pamięci zależy od struktur obiektów dokumentu, a nie od jego serializowanej długości

Lekcja dotycząca trybu udostępniania: gdy AppendToFile zwracało 0

Jedną regresję w tym obszarze warto przytoczyć, ponieważ ten wzorzec awarii ma charakter ogólny. Metoda AppendToFile(FileName) dołącza aktualizację przyrostową bezpośrednio do istniejącego pliku PDF na dysku — co jest naturalnym wywołaniem w przepływie pracy z lokalną ścieżką audytu: załaduj plik, wprowadź zmianę, dołącz do tej samej ścieżki. W wersji 3.71.2 dokładnie ta sekwencja zaczęła zwracać 0. Główna przyczyna leżała po stronie modułu ładującego, a nie zapisującego: aby wspierać odczyt dużych dokumentów na żądanie, LoadFromFile utrzymuje otwarty uchwyt pliku źródłowego przez cały cykl życia obiektu dokumentu, a uchwyt ten był otwierany z flagą fmShareDenyWrite. Gdy AppendToFile próbowało następnie ponownie otworzyć ten sam plik do zapisu, tryb udostępniania samego modułu ładującego zabraniał tego i interfejs API kończył się niepowodzeniem przed zapisaniem chociażby bajtu

Poprawka złagodziła tryb udostępniania modułu ładującego do fmShareDenyNone, co jest bezpieczne właśnie ze względu na to, czym jest dołączanie przyrostowe: dodaje bajty wyłącznie za końcem pliku i nigdy nie przepisuje obszaru, który obsługuje długo żyjący uchwyt czytnika. Ogólna lekcja dla każdego, kto tworzy wrappery dla tej biblioteki — lub buduje podobne ładowarki strumieniowe — jest taka, że leniwe czytniki trzymające uchwyty i zapisujące do tego samego pliku wchodzą ze sobą w konflikt, a tryb udostępniania wybierany przy otwarciu to kontrakt API, a nie szczegół implementacyjny. Jeśli AppendToFile kiedykolwiek zwróci 0 w Twoim kodzie, sprawdź najpierw, czy coś innego w Twoim procesie nie trzyma pliku docelowego w restrykcyjnym trybie udostępniania

Rzeczywiste koszty: kiedy aktualizacje przyrostowe są niewłaściwym narzędziem

Aktualizacje przyrostowe wymieniają rozmiar pliku na wydajność zapisu i ta wymiana nie zawsze jest korzystna. Każda nowa rewizja dołącza swoje zmienione obiekty, podczas gdy przestarzałe definicje pozostają w pliku, więc dokument edytowany setki razy gromadzi martwe obiekty i długi łańcuch /Prev, który każdy czytnik musi przejść. Co gorsza, „usunięta” zawartość nie znika: tekst usunięty w piątej rewizji jest nadal fizycznie obecny w bajtach czwartej rewizji, możliwy do odzyskania przez każdego, kto skróci plik. Redakcja (usuwanie danych wrażliwych), oczyszczanie lub jakiekolwiek usuwanie poufnych treści wymaga przepisania całego dokumentu — przyrostowy zapis redakcji to wyciek danych na własne życzenie

Pełny zapis jest również dobrym rozwiązaniem, gdy celem jest kompaktowanie (usuwanie nagromadzonych przyrostów i nieużywanych obiektów), przy zmianie właściwości całego dokumentu, takich jak szyfrowanie — ponowne szyfrowanie dotyka każdego ciągu znaków i strumienia, więc w zmianie nie ma nic „przyrostowego” — lub przy tworzeniu czystego pliku końcowego, gdzie historia edycji nie powinna być przekazywana wraz z plikiem. Rozsądna zasada: używaj AppendToStream lub AppendToFile, dopóki dokument jest rozwijany i modyfikowany, zwłaszcza gdy zawiera już podpisy; używaj pełnego przepisania za pomocą SaveToStream na granicach cyklu życia, gdy dokument opuszcza Twój system lub jego historia musi zostać spłaszczona

Aktualizacje przyrostowe, wyjściowe delty z wirtualnym przesunięciem oraz bezpośrednia serializacja do strumienia są częścią standardowej biblioteki losLab PDF Library dla Delphi, C# i VB.NET; strona produktu zawiera pełną listę funkcji zapisu i dołączania API wraz z omówionymi wyżej funkcjami podpisywania i obsługi dużych plików