Artykuł techniczny

Przyrostowe aktualizacje PDF w Delphi: AppendToStream

Przyrostowe aktualizacje PDF pozwalają aplikacji w Delphi zmodyfikować dokument przez dopisanie wyłącznie zmienionych obiektów, pozostawiając każdy oryginalny bajt nietknięty. losLab PDF Library realizuje to przez AppendToStream, które zapisuje tylko sekcję przyrostową zdefiniowaną w ISO 32000-1 §7.5.6, więc edycja jednej zakładki w pliku o rozmiarze 2 GB kosztuje kilobajty wyjścia zamiast pełnego przepisania. Ten sam mechanizm sprawia, że podpisane dokumenty można aktualizować bez unieważniania ich podpisów

Ból, który to rozwiązuje, jest konkretny. Pełny zapis przepisuje cały plik: każdy obiekt jest serializowany na nowo, każde przesunięcie odsyłacza jest przeliczane, a wyjście nie ma z wejściem żadnego związku na poziomie bajtów. Dla faktury o rozmiarze 40 KB to bez znaczenia. Dla skanowanego archiwum o rozmiarze 2 GB, w którym poprawiłeś tylko literówkę w tytule dokumentu, przepisywanie dwóch gigabajtów, by zmienić dwadzieścia bajtów, jest absurdem — a jeśli plik niósł podpis cyfrowy, przepisanie właśnie go zniszczyło

Dlaczego zapisanie pliku PDF psuje jego podpis cyfrowy?

Podpis cyfrowy PDF nie podpisuje logicznej treści dokumentu; podpisuje zakresy bajtów pliku fizycznego. Wpis /ByteRange w słowniku podpisu zapisuje dokładnie, które przedziały pliku obejmuje skrót kryptograficzny. Każda operacja zapisu, która serializuje te bajty na nowo — nawet taka, która daje semantycznie identyczny dokument — zmienia skrót, a każdy walidator zgłosi podpis jako zepsuty. Tak to zaprojektowano: podpis poświadcza bajty, które widział podpisujący, a nie jakiś abstrakcyjny model dokumentu

Przyrostowe aktualizacje są furtką, którą zapewnia specyfikacja PDF. Ponieważ zapis przyrostowy dopisuje nowe dane za oryginalnym %%EOF i nigdy nie dotyka podpisanych zakresów bajtów, istniejący podpis nadal waliduje się względem bajtów, które obejmuje. Walidatory klasyfikują następnie dopisane zmiany osobno — drugi podpis, wypełnienie formularza, adnotację — i rozstrzygają, czy są to modyfikacje dozwolone. Zależy od tego każdy proces z wieloma podpisami: każdy podpisujący dokłada sekcję przyrostową na wierzchu poprzedniej. Jeśli budujesz potoki podpisywania, towarzyszący artykuł o podpisywaniu i walidacji PAdES w Delphi szczegółowo omawia, jak zakresy bajtów podpisu współgrają z sekcjami przyrostowymi

PDF Library for Delphi: diagram zakresów bajtów pokazujący, dlaczego pełny zapis PDF unieważnia podpisy cyfrowe, podczas gdy przyrostowe aktualizacje przez AppendToStream je zachowują
Ponieważ skrót obejmuje stałe zakresy bajtów, pełny zapis je miesza i psuje walidację, podczas gdy aktualizacja przyrostowa dopisuje poza podpisanym obszarem i każdy podpis w łańcuchu przeżywa

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

ISO 32000-1 §7.5.6 definiuje ten model trzema regułami. Po pierwsze, oryginalna treść pliku zostaje w całości nienaruszona — ani jeden bajt się nie przesuwa. Po drugie, obiekty zmienione i nowo utworzone są dopisywane za ostatnim %%EOF, każdy z tym samym numerem obiektu, który miał wcześniej (zmienione obiekty po prostu dostają nowszą definicję przesłaniającą starą). Po trzecie, dopisywana jest nowa sekcja odsyłaczy i zwiastun; wpis /Prev zwiastuna wskazuje wstecz na przesunięcie bajtowe poprzedniej sekcji odsyłaczy, tworząc łańcuch, po którym czytnik idzie od najnowszej do najstarszej, by rozwiązać każdy obiekt do jego najświeższej definicji

Z tej struktury wypadają dwie użyteczne właściwości. Aktualizacje są tanie proporcjonalnie do tego, co się zmieniło, a nie do rozmiaru dokumentu — koszt dopisania to rozmiar zmodyfikowanych obiektów plus niewielki narzut xref i zwiastuna. A plik staje się własną historią wersji: każda wcześniejsza rewizja jest wciąż fizycznie obecna, więc audytor może obciąć plik przy dowolnym wcześniejszym %%EOF i odzyskać dokładnie ten dokument, który istniał w tamtym momencie. Dla procesów zgodności, które muszą dowieść, jak dokument wyglądał przed każdą poprawką, ta wbudowana ścieżka audytu bywa argumentem rozstrzygającym na rzecz zapisów przyrostowych

Zapisywanie aktualizacji przyrostowej przez AppendToStream

losLab PDF Library udostępnia wyjście przyrostowe przez AppendToStream(AppendMode: Integer; OutStream: TStream): Integer, które zwraca 1 przy powodzeniu i 0 przy niepowodzeniu. Parametr AppendMode wybiera, co ląduje w strumieniu docelowym. Tryb 0 zapisuje kompletny plik: najpierw do strumienia kopiowane są oryginalne bajty źródłowe, a potem dopisywana jest sekcja przyrostowa. Tryb 1 zapisuje wyłącznie samą sekcję przyrostową — różnicę — i całkowicie pomija bajty źródłowe. Tryb 2 zapisuje najpierw prefiks dostarczony przez wywołującego i zarejestrowany przez SetAppendInputFromString, a następnie dopisuje na nim sekcję aktualizacji

Porównanie trybów AppendToStream 0, 1 i 2 zapisujących bajty źródłowe, prefiks wywołującego oraz sekcję przyrostową do strumienia w Delphi
AppendMode wybiera, co trafia do strumienia, od kompletnej kopii po prefiks wywołującego wraz z różnicą, przy czym tryb 1 emituje przenośną gołą sekcję przyrostową
var
  Doc: TPDFlib;
  Delta: TMemoryStream;
begin
  Doc := TPDFlib.Create;
  try
    if Doc.LoadFromFile('contract.pdf', '') <= 0 then
      Exit;

    // Drobna edycja: zmiana takiego rodzaju, która nie powinna
    // wywoływać przepisania całego pliku
    Doc.SetInformation(3, 'Amended 2026-07-04');  // klucz 3 = /Subject

    Delta := TMemoryStream.Create;
    try
      // AppendMode = 1: zapisz tylko sekcję przyrostową.
      // Oryginalne bajty + Delta = kompletny, prawidłowy 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 tym ciekawym z punktu widzenia projektowania systemu. Ponieważ różnica jest samodzielna, możesz ją dostarczać niezależnie od oryginału: przechowywać rewizje jako osobne obiekty w magazynie obiektowym, replikować do zdalnej lokalizacji wyłącznie różnice albo odtworzyć dowolną rewizję przez sklejenie pliku bazowego z jego łańcuchem przyrostów. Reguła odtwarzania to zwykła konkatenacja bajtów — najpierw plik oryginalny, potem każda różnica po kolei — ponieważ dokładnie taki układ §7.5.6 przepisuje dla pliku aktualizowanego przyrostowo

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

Wpisy odsyłaczy wewnątrz sekcji przyrostowej muszą zawierać bezwzględne przesunięcia bajtowe — pozycje mierzone od początku kompletnego pliku, a nie od początku różnicy. To tworzy zagadkę dla trybu 1: writer nigdy nie emituje oryginalnych bajtów, a mimo to każde zapisywane przez niego przesunięcie musi udawać, że one tam są. losLab PDF Library rozwiązuje to wewnętrznym adapterem strumienia, TPDFAppendSectionStream, który przedstawia serializatorowi wirtualną przestrzeń współrzędnych. Adapter jest tworzony z długością bajtową oryginalnego pliku jako przesunięciem bazowym, raportuje swoją pozycję i rozmiar jako tę bazę plus to, co dotąd dopisano, i przekazuje do strumienia docelowego wywołującego wyłącznie nowo zapisane bajty

Konsekwencją jest to, że tryb 1 nigdy nie materializuje kopii dokumentu źródłowego — ani na dysku, ani w pamięci. Naiwna implementacja (zapisz pełny plik do bufora tymczasowego, a potem odetnij ogon) niosłaby chwilową kopię całego oryginalnego pliku PDF, co przy wejściach rzędu gigabajtów jest dokładnie tym kosztem, którego przyrostowe aktualizacje mają unikać. Ta technika wirtualizacji przesunięć jest bliską kuzynką przesuwania odwołań bajtowych używanego gdzie indziej w bibliotece; artykuł o szybkim scalaniu PDF przez przesuwanie odwołań bajtowych pokazuje tę samą ideę zastosowaną do łączenia dokumentów, a przewodnik po scalaniu i dzieleniu dużych plików PDF z bezpośrednim dostępem do pliku omawia otaczającą architekturę wejścia-wyjścia dla plików, które nie mieszczą się wygodnie w pamięci RAM

Strumieniowe pełne zapisy przez SaveToStream

Wyjście przyrostowe to połowa opowieści o strumieniowaniu; drugą połową jest to, co dzieje się przy pełnym zapisie. SaveToStream w losLab PDF Library steruje serializatorem dokumentu bezpośrednio na strumień docelowy, zamiast najpierw renderować cały dokument do pośredniego AnsiString, a potem wypisywać ten bufor jednym wywołaniem. Starsze podejście działało, ale oznaczało, że każdy pełny zapis chwilowo trzymał w pamięci drugą kompletną kopię wyjścia — nieszkodliwe przy 10 MB, bolesne przy 500 MB i twarda ściana dla wyjść wielogigabajtowych w procesach 32-bitowych. Bezpośrednia serializacja sprawia, że szczyt zużycia pamięci podąża za strukturami obiektowymi dokumentu, a nie za jego długością po serializacji

var
  Doc: TPDFlib;
  Output: TFileStream;
begin
  Doc := TPDFlib.Create;
  try
    if Doc.LoadFromFile('archive.pdf', '') <= 0 then
      Exit;

    // ... edycje uzasadniające pełne przepisanie ...

    Output := TFileStream.Create('archive-rewritten.pdf', fmCreate);
    try
      if Doc.SaveToStream(Output) = 0 then
        Writeln('Save failed, error ', Doc.LastErrorCode);
    finally
      Output.Free;
    end;
  finally
    Doc.Free;
  end;
end;

Lekcja o trybie współdzielenia: gdy AppendToFile zwracało 0

Jedna regresja w tym obszarze warta jest opowiedzenia, bo wzorzec awarii daje się uogólnić. AppendToFile(FileName) dopisuje aktualizację przyrostową wprost do istniejącego PDF na dysku — naturalne wywołanie dla ścieżki audytu w miejscu: wczytaj plik, wprowadź zmianę, dopisz do tej samej ścieżki. W wersji v3.71.2 dokładnie ta sekwencja zaczęła zwracać 0. Przyczyna źródłowa siedziała w module wczytującym, a nie zapisującym: aby wspierać odczyt na żądanie dużych dokumentów, LoadFromFile trzyma uchwyt pliku źródłowego otwarty przez cały czas życia obiektu dokumentu, a ten uchwyt był otwierany z fmShareDenyWrite. Gdy AppendToFile próbowało następnie otworzyć ten sam plik do zapisu, tryb współdzielenia samego modułu wczytującego mu tego odmawiał, a API zawodziło przed zapisaniem choćby bajta

Poprawka rozluźniła tryb współdzielenia modułu wczytującego do fmShareDenyNone, co jest bezpieczne właśnie ze względu na to, czym jest dopisanie przyrostowe: dokłada bajty ściśle za końcem pliku i nigdy nie przepisuje obszaru obsługiwanego przez długo żyjący uchwyt czytelnika. Ogólna lekcja dla każdego, kto opakowuje tę bibliotekę — albo buduje podobne strumieniowe moduły wczytujące — brzmi, że leniwe czytniki trzymające uchwyt i zapisujący do tego samego pliku pozostają w napięciu, a tryb współdzielenia wybrany przy otwarciu jest kontraktem API, a nie szczegółem implementacyjnym. Jeśli AppendToFile kiedykolwiek zwróci 0 w twoim kodzie, sprawdź najpierw, czy coś innego w twoim procesie wciąż trzyma plik docelowy z restrykcyjnym trybem współdzielenia

Uczciwe koszty: kiedy przyrostowe aktualizacje są złym narzędziem

Przyrostowe aktualizacje wymieniają rozmiar pliku na efektywność zapisu, a ta wymiana nie zawsze jest korzystna. Każda rewizja dopisuje swoje zmienione obiekty, podczas gdy zastąpione definicje zostają w pliku, więc dokument edytowany setki razy gromadzi martwe obiekty i długi łańcuch /Prev, po którym musi przejść każdy czytnik. Gorzej, treść „usunięta” wcale nie znika: tekst usunięty w rewizji piątej jest wciąż fizycznie obecny w bajtach rewizji czwartej, do odzyskania przez każdego, kto obetnie plik. Redakcja, sanityzacja czy jakiekolwiek usuwanie treści wrażliwych wymaga zatem pełnego przepisania — przyrostowy zapis redakcji to wyciek danych z dodatkowymi krokami

Pełny zapis jest też właściwym wyborem, gdy celem jest zagęszczenie (wyciśnięcie nagromadzonych przyrostów i nieużywanych obiektów), gdy zmieniasz właściwości obejmujące cały dokument, takie jak szyfrowanie — ponowne zaszyfrowanie dotyka każdego łańcucha i strumienia, więc nic „przyrostowego” w tej zmianie nie zostaje — albo gdy wytwarzasz czysty produkt końcowy, z którym historia edycji nie powinna podróżować. Rozsądna zasada: używaj AppendToStream albo AppendToFile, dopóki dokument żyje i się zmienia, zwłaszcza gdy niesie już podpisy; użyj pełnego przepisania przez SaveToStream na granicach cyklu życia, gdy dokument opuszcza twój system albo jego historia musi zostać spłaszczona

Przewodnik decyzyjny PDF Library for Delphi wybierający między przyrostowymi zapisami przez AppendToStream a pełnymi przepisaniami przez SaveToStream w cyklu życia dokumentu
Dopisuj, dopóki dokument żyje i jest podpisany, i przejdź na pełne przepisanie zawsze wtedy, gdy bajty muszą zniknąć, historia musi się spłaszczyć albo zmienia się szyfrowanie

Przyrostowe aktualizacje, wyjście różnicowe z wirtualnymi przesunięciami i serializacja prosto do strumienia to elementy standardowej losLab PDF Library dla Delphi, C# i VB.NET; strona produktu wymienia pełną powierzchnię API zapisu i dopisywania obok omówionych wyżej funkcji podpisywania i obsługi dużych plików