Artykuł techniczny

Dlaczego zapis PDF bez zmian psuje metadane Info i XMP

PDF Library for Delphi w wersjach v3.539.18 i v3.539.20 naprawia dwa sposoby, na jakie zapis PDF niczego niezmieniający wciąż mógł uszkodzić metadane dokumentu: gdy /CreationDate i /ModDate wskazywały ten sam obiekt łańcucha, automatyczna aktualizacja ModDate przepisywała oba, a gdy obiekt XMP powstawał, zanim odczytano oryginalny strumień /Metadata, domyślny pakiet zastępował oryginał. Poprawki podmieniają odwołania w słowniku zamiast modyfikować współdzielone obiekty i przechwytują istniejący pakiet przed leniwą inicjalizacją XMP

Ustawienie to najmniej ciekawe zadanie, jakie wykonuje biblioteka PDF: wczytaj plik, zapisz go pod nową nazwą, nie ruszaj niczego pomiędzy. Strony renderowały się identycznie przed i po. Hashe strumieni treści się zgadzały. Plik przechodził każde sprawdzenie, jakie mieliśmy, a mimo to był błędny w dwóch miejscach, których żaden renderer nigdy ci nie pokaże. Oba defekty siedziały na ścieżce czytaj-modyfikuj-zapisz, przez którą przechodzi każda prawdziwa edycja, więc wystarczył jakikolwiek zapis, żeby je wywołać, i oba znalazły się dopiero wtedy, gdy drugi, niezależny parser porównał 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 biblioteka aktualizowała obiekt, a nie klucz. 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, co dokładnie zrobił pewien projektowy dokument CJK z naszego lokalnego korpusu

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. Stare SetRawInfo szukało obiektu pod kluczem i jeśli znalazło TPDFString, wołało 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, stare SetRawInfo wołało SetTo na tym, na co wskazywał klucz, i przepisywało obie daty czasem zapisu, a nowe SetRawInfo dodaje pod kluczem świeży łańcuch z zachowaniem trybu łańcucha hex
Aktualizacja wpisu słownika podmienia teraz odwołanie tego wpisu, a nie modyfikuje współdzielony obiekt, więc jeden automatyczny zapis ModDate nie zmieni już CreationDate, a zastąpiony obiekt zostaje 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;

Poprawka 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. Nowy 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 co główna zmiana. Stara gałąź dla wpisu o wartości strumieniowej czyściła strumień przez SetTo('') przed podmianą, co opróżniłoby wartość dla każdego innego klucza wciąż wskazującego na ten strumień, więc to czyszczenie zniknęło. A zastąpiony obiekt nie jest usuwany, bo struktura jest jego właścicielem i inne odwołania mogą go jeszcze potrzebować

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

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

Test regresyjny w Tests\SharedInfoSemantics.inc buduje takie aliasowanie celowo, zamiast opierać się na pliku z korpusu: 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 teraz 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. Kilka miejsc wywołania inicjalizowało je leniwie przez XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, co czyta się naturalnie i jest błędne: 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 ten defekt się ukrywa: porównanie słownika Info z pierwszego błędu 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ży

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 wywoływał tę podmianę, bo SetInfo inicjalizuje XMP, żeby xmp:ModifyDate szedł równo z /ModDate, więc każda leniwa inicjalizacja w dokumencie przechodzi teraz 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);

Poprawka robi dwie rzeczy. TPDFDocument.EnsureXMP przechwytuje teraz Source := GetMetadata przed TPDFlibXMP.Create, a każdą leniwą inicjalizację w dokumencie zastąpiono wywołaniem tej funkcji: 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, już przechodziły przez EnsureXMP, a GetXMPProperty czyta przez GetDocumentMetadata, więc cała powierzchnia korzysta z jednej kolejności inicjalizacji. Jeden poprawny egzemplarz trzy linijkowej sekwencji jest wart więcej niż dziesięć egzemplarzy, które akurat dziś się zgadzają

Dwie mniejsze pułapki znalezione 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ść. Stary kod usuwał ją, kasując znaki, aż dotarł do <?xpacket. 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 pętla kasowała cały, poprawny dokument. Serializer lokalizuje teraz zamykające ?> deklaracji i usuwa tylko je. Tests\XMPRetentionSemantics.inc uruchamia swoje sprawdzenie zachowania metadanych 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ą był symbol preprocesora: synchronizacja Info z XMP w SetInfo była strzeżona przez NOVCL, który 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. Build Lazarusa pod Windows miał więc działający obiekt XMP i SetInfo, które po cichu pomijało jego aktualizację. Strażnikiem jest teraz 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 daje. 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 naprawia też wstecz biblioteki, która modyfikuje współdzielone obiekty; pozwala jedynie uniknąć tego jednego zapisu, który defekt ujawnił. Obie powyższe poprawki są tym, co czyni zwykły zapis bezpiecznym, a ta opcja jest tym, co czyni świadomy no-op uczciwym

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

Nie pikselami i nie hashami strumieni, bo oba defekty zostawiają każdą stronę i każdy strumień treści bajt w bajt identycznymi. Sprawdzeniem, które je złapało, jest niewizualny zrzut semantyczny wykonany przez niezależny parser, taki, który nie dzieli ani linii kodu z testowaną biblioteką, 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 defektach, 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 mogą 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 albo czy dokument spełnia PDF/UA lub którąkolwiek część PDF/A. To osobne pytania i osobne narzędzia, a mieszanie metadanych, które przetrwały, z metadanymi, które są zgodne, jest tym, co pozwoliło pierwszemu błędowi ukrywać się tak długo. Po stronie biblioteki oba testy regresyjne biegną teraz przy każdym ukierunkowanym przebiegu na Delphi Win32 i Win64 oraz Free Pascalu Win32 i Win64, a porównanie semantyczne jest warunkiem zaliczenia benchmarku na korpusie prawdziwych dokumentów

Jeśli pracujesz na poziomie poniżej tych poprawek, 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