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
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
// Ź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
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