Artykuł techniczny

Dlaczego zapis PDF bez zmian psuje metadane Info i XMP

Zapis PDF niczego niezmieniający wciąż może uszkodzić metadane dokumentu na dwa sposoby: gdy /CreationDate i /ModDate wskazują ten sam obiekt łańcucha, automatyczna aktualizacja ModDate, która pisze wprost do tego obiektu, przepisuje oba, a gdy obiekt XMP powstaje, zanim odczytano oryginalny strumień /Metadata, domyślny pakiet zastępuje oryginał. PDF Library for Delphi podmienia odwołania w słowniku zamiast modyfikować współdzielone obiekty i przechwytuje istniejący pakiet przed leniwą inicjalizacją XMP

Zapis ustawień to najmniej ciekawe zadanie, jakie wykonuje biblioteka PDF: wczytaj plik, zapisz go pod nową nazwą, nie ruszaj niczego pomiędzy. Strony renderują się identycznie przed i po, a hashe strumieni treści się zgadzają. Plik może przejść każde takie sprawdzenie, a mimo to być błędny w dwóch miejscach, których żaden renderer nigdy ci nie pokaże. Obie pułapki siedzą na ścieżce czytaj-modyfikuj-zapisz, przez którą przechodzi każda prawdziwa edycja, więc wystarczy jakikolwiek zapis, żeby je wywołać, a ujawnia je dopiero drugi, niezależny parser porównujący niewizualną semantykę obu plików

Dlaczego zapis PDF zmienia CreationDate?

Bo słownik informacji o dokumencie może wskazywać jeden pośredni obiekt łańcucha z dwóch kluczy, a moduł zapisujący, który aktualizuje obiekt zamiast klucza, zmienia oba. ISO 32000-1 §7.3.10 pozwala, by dowolna wartość słownika była odwołaniem pośrednim, a §14.3.3 Tabela 317 nigdzie nie mówi, że wartość pod /CreationDate musi być innym obiektem niż wartość pod /ModDate. Producent, który przy tworzeniu zapisał ten sam znacznik czasu dwa razy, może całkowicie legalnie skierować oba klucze na jeden 2728 0 R, i prawdziwe pliki robią dokładnie to

Wyzwalaczem jest automatyczna data modyfikacji. O ile UserModDate nie jest ustawione, SaveToFile woła przed zapisem SetInfo('ModDate', ...) z bieżącym czasem, co trafia do SetRawInfo. Naiwne SetRawInfo szuka obiektu pod kluczem i jeśli znajdzie TPDFString, woła na nim SetTo. To zapis w miejscu do tego obiektu, na który klucz akurat wskazuje, a gdy obiekt jest współdzielony, /CreationDate też zaczyna podawać czas zapisu. Dokument nadal otwiera się, drukuje i renderuje piksel w piksel tak samo, więc zestaw testów regresji wizualnej przechodzi bez mrugnięcia okiem

Modyfikacja współdzielonego łańcucha Info w PDFlibPas: /CreationDate i /ModDate legalnie wskazują jeden obiekt łańcucha 2728 0 R, zapis w miejscu SetTo na tym, na co wskazuje klucz, przepisałby obie daty czasem zapisu, więc SetRawInfo dodaje pod kluczem świeży łańcuch z zachowaniem trybu łańcucha hex
Aktualizacja wpisu słownika podmienia odwołanie tego wpisu, zamiast modyfikować współdzielony obiekt, więc jeden automatyczny zapis ModDate nie zmieni CreationDate'a, a zastąpiony obiekt zostaje zachowany dla innych odwołań
var
  Lib: TPDFlib;
  Before, After: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('design.pdf', '');
    Before := Lib.GetInformation(7);          // 7 = CreationDate, 8 = ModDate
    Lib.SaveToFile('design-resaved.pdf');
    Lib.LoadFromFile('design-resaved.pdf', '');
    After := Lib.GetInformation(7);
    if Before <> After then
      Log('a save that changed nothing rewrote CreationDate');
  finally
    Lib.Free;
  end;
end;

Reguła w TPDFDocument.SetRawInfo jest niewielka, a zasada za nią stoi ogólna: aktualizacja wpisu słownika podmienia odwołanie tego wpisu, nigdy obiekt, na który ono przypadkiem wskazywało. Kod czyta istniejący TPDFStringMode, żeby łańcuch hex pozostał hexem, a łańcuch literalny literałem, po czym dodaje pod kluczem świeży łańcuch z FStructure.NewString(Value, StringMode). Dwa inne szczegóły znaczą tyle samo. Wpis o wartości strumieniowej jest podmieniany bez wcześniejszego czyszczenia starego strumienia, bo SetTo('') na nim opróżniłby wartość dla każdego innego klucza wciąż wskazującego na ten strumień. A zastąpiony obiekt nie jest usuwany, bo struktura jest jego właścicielem i inne odwołania mogą go jeszcze potrzebować

// Naiwnie: modyfikuj to, na co klucz akurat wskazuje
if Obj is TPDFString then
  TPDFString(Obj).SetTo(Value);

// Poprawnie: zachowaj reprezentację, podmień tylko odwołanie tego klucza
StringMode := smLiteral;
if Obj is TPDFString then
  StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));

Test na to powinien budować aliasowanie celowo, zamiast opierać się na pliku, który przypadkiem współdzieli obiekty: jeden łańcuch hex wskazywany z obu kluczy dat, jeden łańcuch bezpośredni współdzielony przez /Title i /Subject, jeden strumień współdzielony przez /Author i /Keywords. Po aktualizacji jednego klucza z każdej pary drugi musi nadal czytać swoją pierwotną wartość, a zaktualizowany łańcuch musi pozostać hexem. Publiczna dokumentacja SetInformation formułuje tę gwarancję w jednym zdaniu: aktualizacja pola Info podmienia tylko to pole, nawet gdy inne pola wskazują ten sam obiekt

Dlaczego istniejący pakiet XMP zostaje zastąpiony domyślnym?

Z powodu kolejności dwóch linii. TPDFDocument.GetMetadata ma szybką ścieżkę: gdy pole XMP jest już przypisane, zwraca XMP.SaveToString, zamiast dekodować strumień /Metadata z katalogu. Leniwą inicjalizację napisaną jako XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata); czyta się naturalnie i jest błędna: w chwili uruchomienia GetMetadata pole XMP jest już przypisane, więc „źródłem”, które się wczytuje, jest zserializowany domyślny pakiet obiektu utworzonego linię wcześniej. Oryginalny pakiet, z jego dc:creator, własnymi przestrzeniami nazw i wszelką identyfikacją standardów, nigdy nie dociera do obiektu i zostaje nadpisany przy zapisie. Ta sama automatyczna data modyfikacji wystarcza, żeby to wywołać, bo SetInfo inicjalizuje XMP, zanim dotknie słownika Info, tak aby xmp:ModifyDate szedł równo z /ModDate. Zauważ, za czym ta pułapka się ukrywa: porównanie słownika Info z pierwszej pułapki przechodzi, bo /Author i /Title w /Info są nietknięte. Zmieniło się tylko drzewo XMP i tylko sprawdzenie, które to drzewo parsuje i porównuje, to zauważa

Kolejność leniwej inicjalizacji XMP w PDFlibPas: utworzenie obiektu XMP przed wywołaniem GetMetadata sprawia, że szybka ścieżka serializuje domyślny pakiet i gubi dc:creator, własne przestrzenie nazw i identyfikację standardów, a przechwycenie Source przed TPDFlibXMP.Create wczytuje oryginalny strumień /Metadata z katalogu
Każdy zapis może wywołać tę podmianę, bo SetInfo inicjalizuje XMP, żeby xmp:ModifyDate szedł równo z /ModDate, więc każda leniwa inicjalizacja w dokumencie przechodzi przez jedno EnsureXMP, które przechwytuje istniejący pakiet przed utworzeniem obiektu
// Źle: GetMetadata serializuje teraz obiekt utworzony w poprzedniej linii
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);

// Dobrze: najpierw przechwyć strumień /Metadata, potem utwórz i wczytaj
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);

PDF Library for Delphi trzyma tę kolejność w jednym miejscu. TPDFDocument.EnsureXMP przechwytuje Source := GetMetadata przed TPDFlibXMP.Create, a każda leniwa inicjalizacja w dokumencie przechodzi przez nią: SetInfo, SetXMPInformation, GetXMPInformation, settery trybów PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR i PDF/UA oraz ścieżkę naprawy metadanych. Publiczne punkty wejścia, takie jak SetXMPProperty, też przechodzą przez EnsureXMP, a GetXMPProperty czyta przez GetDocumentMetadata, więc cała powierzchnia korzysta z jednej kolejności inicjalizacji. Jeden poprawny egzemplarz trzyliniowej sekwencji jest wart więcej niż dziesięć egzemplarzy, które akurat dziś się zgadzają

Dwie mniejsze pułapki na tej samej ścieżce

Serializer XMP pod Windows korzysta z platformowego writera XML, który emituje deklarację XML, a pakiet nie może jej nieść. Oczywisty sposób na jej usunięcie, kasowanie znaków aż do <?xpacket, zawodzi na poprawnym wejściu: ISO 16684-1 §7.3.2 czyni opakowanie xpacket opcjonalnym, a producent zapisujący goły element <x:xmpmeta> mieści się w standardzie, więc na takim pakiecie ta pętla kasuje cały, poprawny dokument. Serializer lokalizuje zamykające ?> deklaracji i usuwa tylko je. Sprawdzenie zachowania metadanych warte skopiowania biegnie dwa razy, raz z opakowaniem, raz z odciętym, i wymaga, żeby znacznik własnej przestrzeni nazw oraz pierwotny autor przetrwały SetInfo, GetMetadata, SaveToString i ponowne wczytanie. Drugą pułapką jest symbol preprocesora. NOVCL jest ustawiany dla buildów Free Pascala, ale backend XMP jest ograniczany przez system operacyjny, a nie przez framework, bo PDFlibXMP.pas definiuje NO_XMP tylko wtedy, gdy brakuje OS_WINDOWS. Strzeżenie synchronizacji Info z XMP w SetInfo przez NOVCL zostawiłoby build Lazarusa pod Windows z działającym obiektem XMP i SetInfo, które po cichu pomija jego aktualizację. Strażnikiem jest NO_XMP, więc aplikacja Free Pascala pod Windows dostaje tę samą synchronizację co Delphi

Jak zachować pierwotne ModDate przy zapisie przepuszczającym?

Ustaw KeepModDate w TPDFlibSaveOptions i zapisuj przez SaveToFileOptions. Ta opcja ustawia UserModDate na czas wywołania, a SaveToFile pomija wtedy automatyczny znacznik czasu, czyli ten sam krok, który leniwie inicjalizuje obiekt XMP. Dokument, którego metadanych nigdy nie dotknąłeś i dla którego nie włączono żadnego trybu zgodności, zachowuje zarówno swój słownik Info, jak i strumień /Metadata w postaci wczytanej. Wywołanie SetInformation(8, ...) daje ten sam efekt na stałe, bo ustawienie daty modyfikacji samodzielnie oznacza ją jako kontrolowaną przez użytkownika

var
  Options: TPDFlibSaveOptions;
begin
  FillChar(Options, SizeOf(Options), 0);
  Options.OptimizeContentStreams := True;
  Options.PackObjectStreams := True;
  Options.KeepModDate := True;      // bez automatycznego /ModDate, bez leniwej inicjalizacji XMP
  if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
    Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;

Bądź uczciwy co do tego, co to kupuje. KeepModDate to właściwy wybór dla kroku przepuszczającego, którego wynik ma opisywać tę samą rewizję co wejście, i zły wybór dla wszystkiego, co naprawdę edytuje treść, bo §14.3.3 oczekuje, że /ModDate odzwierciedla najnowszą modyfikację. Nie zastępuje też poprawnej obsługi odwołań, bo unika tylko tego jednego zapisu, który wyzwala automatyczną aktualizację. Podmienianie odwołań zamiast modyfikowania współdzielonych obiektów i przechwytywanie pakietu przed inicjalizacją to to, co czyni zwykły zapis bezpiecznym; ta opcja to to, co czyni świadomy no-op uczciwym

Jak sprawdzić, że zapis nie zmienił niczego poza ModDate?

Nie pikselami i nie hashami strumieni, bo obie pułapki zostawiają każdą stronę i każdy strumień treści bajt w bajt identycznymi. Sprawdzeniem, które je łapie, jest niewizualny zrzut semantyczny wykonany przez niezależny parser, taki, który nie dzieli z testowaną biblioteką ani linii kodu, z pliku źródłowego i z pliku zapisanego, a po nim porównanie strukturalne. Zrzut obejmuje słownik Info z wyłączonym /ModDate, drzewo zakładek z każdą zakładką rozwiązaną do numeru strony, a nie numeru obiektu, nazwane miejsca docelowe i cele odsyłaczy rozwiązywane tak samo, wartości pól formularza, bajty załączników jako hashe oraz pakiet XMP parsowany jako drzewo, a nie porównywany jako tekst. Numery obiektów celowo nie wchodzą w jego skład, bo pełne przepisanie numeruje wszystko od nowa, a porównanie oparte na nich zgłaszałoby szum

Niewizualna weryfikacja semantyczna zapisów w PDFlibPas: niezależny parser bez wspólnego kodu zrzuca słownik Info bez /ModDate, strony zakładek i miejsc docelowych, wartości formularzy, hashe załączników i drzewo XMP, a potem porównuje źródło z plikiem zapisanym, pomijając /ModDate, xmp:ModifyDate i xmp:MetadataDate jako oczekiwane zmiany
Piksele i hashe strumieni pozostają bajt w bajt identyczne przy obu pułapkach, więc porównanie pracuje na rozwiązanej semantyce, a nie na numerach obiektów, a zachowane metadane są raportowane uczciwie jako utrzymane, a nie jako zgodne ze schematem czy z PDF/UA i PDF/A

Wyłączenia są tak samo ważne jak włączenia. /ModDate, xmp:ModifyDate i xmp:MetadataDate mają się zmienić i są odrzucane przed porównaniem; plik, którego źródło nie niosło żadnego XMP, nie jest karany za zyskanie pakietu. To, czego to sprawdzenie nie twierdzi, jest równie wyraźne: zachowanie istniejącego pakietu nie mówi nic o tym, czy pakiet jest zgodny ze schematem, ani czy dokument spełnia PDF/UA lub którąkolwiek część PDF/A. To osobne pytania i osobne narzędzia, a mieszanie „metadane przetrwały” z „metadane są zgodne” jest sposobem, na jaki przepisanie współdzielonej daty potrafi ukrywać się przez długi czas

Jeśli pracujesz na poziomie poniżej tych reguł, mechanikę przepisywania obiektów przy zapisie omawiają aktualizacje przyrostowe i zapis dopisujący, czyli ten jedyny tryb zapisu, w którym współdzielony obiekt zostaje po prostu tam, gdzie był, oraz poziomy modyfikacji i porównywanie rewizji, czyli drugie miejsce, w którym nieaktualna albo przepisana data wprowadza czytelnika w błąd. Widok tej samej pary Info i XMP od strony naprawy, gdzie obie połowy są uzgadniane, a nie tylko zachowywane, jest w artykule o konwersji do PDF/A i naprawie metadanych

PDF Library for Delphi to natywna pascalowa biblioteka PDF dla Delphi, C++Buildera i Lazarusa, a opisana tu ścieżka czytaj-modyfikuj-zapisz jest tą samą, przez którą przechodzi każda edycja w twoim procesie, więc powyższe gwarancje obowiązują niezależnie od tego, czy zapisujesz raz, czy tysiąc razy dziennie — obsługiwane kompilatory i platformy znajdziesz na stronie produktu PDF Library for Delphi