W PDFlibPas, bibliotece PDF dla Delphi, strona przeniesiona przez MovePage dostawała wcześniej dokładnie te same obiekty MediaBox, CropBox i Resources, które trzymał jej stary węzeł Pages, więc późniejszy SetPageBox albo DrawText na przeniesionej stronie po cichu przepisywał ten węzeł i każdą siostrę wciąż po nim dziedziczącą. Od v3.539.36 przeniesiona strona dostaje własne kopie, a referencja pośrednia zostaje referencją. To samo wydanie domyka dwie powiązane ścieżki: SetPageBox na pośrednim boxie współdzielonym przez kilka stron oraz CopyPageRanges zostawiający strony dokumentu źródłowego przywiązane do ich węzła Pages, z CropBoxem przypiętym do MediaBoxa
Zgłoszenia, które prowadzą tutaj, nigdy nie mówią o tożsamości obiektów. Brzmią tak: „obciąłem stronę 7, a strony od 8 do 12 też się obcięły", „zwęziłem CropBox, a MediaBox ruszył się razem z nim" albo, najbardziej mylące, „skopiowałem stronę do nowego dokumentu, a oryginalny plik się zmienił". Nic się nie sypie, nic nie cieknie, a zapisany plik jest w pełni poprawnym PDF. Zawiera po prostu geometrię, o którą nikt nie prosił
Dlaczego SetPageBox na jednej stronie przeskalowuje jej siostry?
SetPageBox przeskalowywał siostry, bo dwa wpisy drzewa stron wskazywały na jedną tablicę w pamięci, a SetPageBox edytuje swoją tablicę docelową w miejscu. Każda strona albo węzeł Pages trzymający tę samą instancję widział edycję. Trzy ścieżki kodu w PDFlibPas produkowały to współdzielenie przed v3.539.36:
MovePagematerializuje dziedziczne atrybuty na stronie przed odpięciem jej od rodzica i przypinał obiekty przodka same w sobie, a nie kopie, więc przeniesiona strona i jej dawne siostry dzieliły tablicę boxa i słownik ResourcesSetPageBoxpodążał za referencjami pośrednimi i edytował wskazywaną tablicę, więc plik, w którym kilka stron wskazuje na jeden obiekt/MediaBox 11 0 R, miał wszystkie te strony przeskalowane jednym wywołaniem, niezależnie od tego, czyMovePagew ogóle brał w tym udziałCopyPageRangesmaterializuje odziedziczone wartości na stronie źródłowej przed klonowaniem jej do dokumentu docelowego i przypinał instancje węzła Pages do strony źródłowej, do tego samą instancję MediaBoxa jako domyślny CropBox
Przypadek MovePage ma krótką historię. Przed v3.539.27 MovePage przenosił za stroną tylko /Resources, więc strona przeniesiona pod innego rodzica po cichu przybierała jego rozmiar i obrót. v3.539.27 naprawiło brakujące MediaBox, CropBox i Rotate, co jest też fundamentem CollateDocumentsEx przy przestawianiu stron, ale przypinało wartości przodka jako współdzielone instancje. To właśnie tę lukę zamyka v3.539.36. Ścieżki SetPageBox i CopyPageRanges są starsze; każda kompilacja sprzed v3.539.36 je ma
Wartości bezpośrednie, referencje pośrednie i dziedziczenie atrybutów strony
Poprawna kopia dziedziczonego atrybutu strony powiela wartości bezpośrednie i zostawia referencje pośrednie referencjami, bo takie rozróżnienie wprowadza sam ISO 32000-1. Obiekt bezpośredni, taki jak [0 0 400 300] zapisany w słowniku, należy tylko do tego słownika. Obiekt pośredni, zdefiniowany raz jako 11 0 obj i cytowany jako 11 0 R, jest z założenia współdzielony: ISO 32000-1 §7.3.10 czyni go adresowalnym z dowolnego miejsca pliku, a każde 11 0 R znaczy ten sam obiekt
Dziedziczenie atrybutów strony, ISO 32000-1 §7.7.3.4, dodaje trzeci przypadek. Resources, MediaBox, CropBox i Rotate mogą siedzieć na węźle Pages i obowiązywać każdą stronę potomną, która nie definiuje własnych. Strona nie trzyma wartości; odszukuje ją przez /Parent. Ten łańcuch odszukiwania pęka w momencie, gdy strona zmienia rodzica, dlatego MovePage i BalancePageTree muszą najpierw zapisać wartości efektywne na samej stronie. Pytanie brzmi tylko, jak je zapisać
Dlaczego pula obiektów ukrywa błąd
W PDFlibPas każdy sparsowany albo utworzony obiekt PDF jest własnością puli TPDFStructure dokumentu, a słowniki i tablice trzymają gołe wskaźniki na swoje wpisy. TPDFDictionary.Add zapamiętuje wskaźnik i nic poza tym. Dodanie jednej instancji do dwóch kontenerów rodzicielskich jest więc legalne na każdym poziomie, jaki runtime potrafi sprawdzić: żadnego podwójnego zwolnienia przy demontażu, żadnej liczby referencji, która mogłaby się pogubić, żadnego wyjątku. Serializacja jest równie wyrozumiała, bo każdy kontener zapisuje bieżącą wartość współdzielonej instancji inline, a przed jakąkolwiek edycją wyjście jest bajt w bajt tym, co wyprodukowałaby poprawna kopia
Aliasing wychodzi na jaw dopiero, gdy ktoś zmienia współdzieloną instancję w miejscu. Dokładnie to robi SetPageBox przez prostokątny wrapper na istniejącej tablicy, a rysowanie po stronie robi to ze słownikiem Resources w chwili rejestracji fontu albo obrazu. Edycja ląduje, po cichu, w każdym innym kontenerze trzymającym wskaźnik
Jak PDFlibPas v3.539.36 kopiuje zamiast współdzielić
PDFlibPas v3.539.36 naprawia problem na obu końcach: materializacja przypina teraz kopie, a zapisy boxów edytują wyłącznie tablicę, której strona jest właścicielem. Każda z poprawek obejmuje przypadek, którego druga nie pokryje
Helper materializacji, PLInheritPageAttributes, przypina teraz Page.Owner.Decode(Value.Output) zamiast Value. Podróż w obie strony przez serializator to toporne, ale dokładne dostanie semantyki PDF gratis. Tablica albo słownik bezpośrednie serializują się do swojego dosłownego tekstu i dekodują do świeżej, niezależnej instancji. Referencja pośrednia serializuje się do 11 0 R i dekoduje do nowego obiektu referencji wskazującego ten sam obiekt 11, więc strona wciąż odnosi się do współdzielonego obiektu, zamiast dostać wklejoną kopię — zachowanie referencyjne z v3.539.27 zostaje zachowane. Kopia jest dokładnie tak głęboka, jak bezpośrednia struktura: cokolwiek osiągalnego przez referencję wewnątrz kopiowanego słownika pozostaje współdzielone, tak jak zamierza format pliku. BalancePageTree woła ten sam helper dla każdej przepinanej strony, więc strony zmaterializowane tam też dostają osobne instancje
Samo kopiowanie nie wystarczy, bo przypadek referencyjny wciąż wskazuje na współdzielony obiekt. Gdyby SetPageBox podążał za tą referencją i edytował obiekt 11, przeniesiona strona znów przeskalowałaby starego rodzica i jego pozostałe dzieci. Pisarz boxów stosuje więc teraz copy-on-write: edytuje w miejscu tylko wtedy, gdy własny wpis strony to tablica bezpośrednia, a box pośredni albo brakujący zastępuje nową tablicą bezpośrednią. Obiekt 11 zostaje nietknięty dla każdej innej strony, która go cytuje
| Ścieżka kodu | Przed v3.539.36 | Od v3.539.36 |
|---|---|---|
Materializacja w MovePage | Strona trzyma bezpośrednie instancje przodka same w sobie | Strona trzyma zdekodowane kopie; referencje zostają referencjami |
SetPageBox | Podąża za referencją i edytuje współdzieloną tablicę | Edytuje tylko tablicę bezpośrednią na stronie, w innym wypadku zapisuje nową |
Strona źródłowa w CopyPageRanges | Dzieli boxy węzła Pages; CropBox to instancja MediaBoxa | Każda zmaterializowana wartość na stronie źródłowej to kopia |
| Domyślne boxy przy klonowaniu zasobów strony | CropBox, BleedBox, TrimBox i ArtBox dzielą jedną tablicę | Każdy domyślny box dostaje własną tablicę |
Ostatni wiersz to ten utajony. Gdy biblioteka klonuje zasoby strony dla przechwytywania stron albo scalania, uzupełnia brakujące wpisy CropBox, BleedBox, TrimBox i ArtBox, a one były kiedyś tą samą instancją tablicy. Żaden z obecnych wołających nie pozwolił temu aliasowi przeżyć na tyle długo, żeby został edytowany, ale następny by to zrobił. Jak wybiera się te domyślne wartości boxów, to osobny temat, opisany w przewodniku PDFlibPas po domyślnych TrimBox, BleedBox i CropBox
Odtwarzanie aliasingu z MovePage na ręcznie zbudowanym PDF
Najszybszy sposób sprawdzenia każdej kompilacji PDFlibPas to mały ręcznie pisany PDF wczytany przez LoadFromString, w którym numer każdego obiektu jest znany z góry. Helper poniżej pisze klasyczną tabelę krzyżowych referencji z poprawnie policzonymi offsetami bajtowymi, więc test nie polega na zachowaniu odtwórczym parsera wobec uszkodzonych plików
uses
System.SysUtils, PDFlibrary;
function BuildPdf(const Objects: array of AnsiString): AnsiString;
var
Offsets: array of Integer;
I, XRefPos: Integer;
begin
Result := '%PDF-1.4'#10;
SetLength(Offsets, Length(Objects));
for I := 0 to High(Objects) do
begin
Offsets[I] := Length(Result); // offset bajtowy liczony od zera napisu "N 0 obj"
Result := Result + AnsiString(IntToStr(I + 1)) + ' 0 obj'#10 +
Objects[I] + #10'endobj'#10;
end;
XRefPos := Length(Result);
Result := Result + 'xref'#10'0 ' + AnsiString(IntToStr(Length(Objects) + 1)) +
#10'0000000000 65535 f '#10;
for I := 0 to High(Offsets) do // każdy wpis ma dokładnie 20 bajtów
Result := Result + AnsiString(Format('%.10d 00000 n ', [Offsets[I]])) + #10;
Result := Result + 'trailer'#10'<< /Size ' +
AnsiString(IntToStr(Length(Objects) + 1)) + ' /Root 1 0 R >>'#10 +
'startxref'#10 + AnsiString(IntToStr(XRefPos)) + #10'%%EOF'#10;
end;
function StreamObj(const Content: AnsiString): AnsiString;
begin
Result := '<< /Length ' + AnsiString(IntToStr(Length(Content))) +
' >>'#10'stream'#10 + Content + #10'endstream';
end;
Dokument testowy ma dwa pośrednie węzły Pages. Węzeł 3 niesie pośredni MediaBox (obiekt 11, 400 na 300 punktów), bezpośredni CropBox i bezpośredni słownik Resources oraz posiada dwie strony. Węzeł 4 ma MediaBox w rozmiarze Letter i posiada trzecią stronę. Przeniesienie strony 1 na pozycję 3 przepina ją pod węzeł 4, czyli dokładnie ten ruch, który wymaga materializacji: bez niej strona zamieniłaby się w stronę Letter
procedure Check(Condition: Boolean; const Msg: string);
begin
if not Condition then
raise Exception.Create(Msg);
end;
procedure CheckMovedPageIsIsolated;
var
Lib: TPDFlib;
FontID: Integer;
begin
Lib := TPDFlib.Create;
try
Check(Lib.LoadFromString(BuildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 3 >>',
'<< /Type /Pages /Parent 2 0 R /Kids [5 0 R 6 0 R] /Count 2 ' +
'/MediaBox 11 0 R /CropBox [10 20 390 280] /Resources << >> >>',
'<< /Type /Pages /Parent 2 0 R /Kids [7 0 R] /Count 1 ' +
'/MediaBox [0 0 612 792] >>',
'<< /Type /Page /Parent 3 0 R /Contents 8 0 R >>',
'<< /Type /Page /Parent 3 0 R /Contents 9 0 R >>',
'<< /Type /Page /Parent 4 0 R /Contents 10 0 R >>',
StreamObj('1 w'), StreamObj('2 w'), StreamObj('3 w'),
'[0 0 400 300]']), '') = 1, 'load failed');
Lib.SelectPage(1);
Check(Lib.MovePage(3) = 1, 'MovePage failed');
Lib.SelectPage(3); // strona, którą przed chwilą przeniesliśmy
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'inherited MediaBox lost');
Lib.SetPageBox(1, 0, 200, 200, 200); // MediaBox 200 x 200
Lib.SetPageBox(2, 0, 100, 100, 100); // CropBox 100 x 100
FontID := Lib.AddStandardFont(4); // Helvetica
Lib.SelectFont(FontID);
Lib.SetTextSize(12);
Lib.DrawText(20, 20, 'MOVED');
// Obejrzyj starego rodzica PRZED wybraniem innej strony (patrz niżej)
Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
'font registered in the old Pages node');
Lib.SelectPage(1); // dawna strona 2, wciąż pod węzłem 3
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling MediaBox changed');
Check(Abs(Lib.GetPageBox(2, 2) - 380) < 0.001, 'sibling CropBox changed');
Check(Pos(AnsiString('400'), Lib.GetObjectToString(11)) > 0,
'shared object 11 was rewritten');
finally
Lib.Free;
end;
end;
GetPageBox(BoxType, Dimension) przyjmuje typ boxa 1 dla MediaBoxa i 2 dla CropBoxa oraz wymiar 2 dla szerokości. Przy domyślnym początku w lewym dolnym rogu SetPageBox(1, 0, 200, 200, 200) znaczy: lewa 0, góra 200, szerokość 200 i wysokość 200. Na kompilacjach między v3.539.27 a v3.539.35 testy sióstr padają: edycja CropBoxa ląduje w tablicy bezpośredniej węzła 3, a edycja MediaBoxa przepisuje obiekt 11 przez referencję
Czy CopyPageRanges zmienia dokument źródłowy?
Od v3.539.36 CopyPageRanges wciąż zapisuje na stronach źródłowych, ale każda zapisywana wartość to osobna kopia, więc późniejsze edycje źródła zostają lokalne w stronie, którą edytujesz. Sam zapis jest zamierzony: strona źródłowa potrzebuje jawnych MediaBox, CropBox, Rotate i Resources, zanim jej słownik zostanie sklonowany do celu, inaczej kopia straciłaby wszystko, co odziedziczyła. Renumerację i kopiowanie strony do celu opisuje głęboka kopia obiektów między dokumentami w PDFlibPas; ten błąd siedział po stronie źródłowej, którą większość ludzi uznaje za tylko czytaną przy kopii
Wyjście nigdy tego nie pokazało. Współdzielone albo skopiowane, zmaterializowane wartości serializują się identycznie, więc oba dokumenty zapisywały się bajt w bajt tak samo przed i po poprawce. Alias obnażyła dopiero edycja dokumentu źródłowego po skopiowaniu:
procedure CheckSourceSurvivesCopy;
var
Lib: TPDFlib;
SourceID, TargetID: Integer;
begin
Lib := TPDFlib.Create;
try
Check(Lib.LoadFromString(BuildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 ' +
'/MediaBox [0 0 400 300] /Resources << >> >>',
'<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>',
'<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>',
StreamObj('1 w'), StreamObj('2 w')]), '') = 1, 'load failed');
SourceID := Lib.SelectedDocument;
TargetID := Lib.NewDocument; // staje się wybranym dokumentem
Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');
Lib.SelectDocument(SourceID);
Lib.SelectPage(1);
Lib.SetPageBox(2, 50, 250, 100, 100); // zwęź tylko CropBox
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'MediaBox followed CropBox');
Lib.SetPageBox(1, 0, 200, 200, 200);
Lib.SelectPage(2);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling page resized');
Lib.SelectDocument(TargetID); // kopia zachowuje swój pierwotny rozmiar
Lib.SelectPage(Lib.PageCount);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
finally
Lib.Free;
end;
end;
Przed v3.539.36 obie strony dziedziczyły tu bezpośredni MediaBox węzła głównego, kopia przypinała tę instancję do strony źródłowej 1 i przypinała ją ponownie jako CropBox strony 1. Zwężenie CropBoxa zwężało więc MediaBox, a przeskalowanie MediaBoxa przeskalowywało stronę 2 przez węzeł główny. To tutaj się objawiało w workflow, które kopiują strony na zewnątrz i dalej edytują źródło, jak składanie skanów dupleksowych w jeden PDF przed przycięciem oryginałów
Dlaczego aliasing instancji jest tak trudny do przetestowania?
Aliasing instancji jest trudny do przetestowania, bo obserwowalny efekt wymaga trzech kroków w konkretnej kolejności: utwórz alias, zmień jedną stronę, potem obejrzyj drugą, zanim cokolwiek innego ją dotknie. Większość testów robi tylko krok pierwszy i porównuje zapisane wyjście, które jest identyczne niezależnie od tego, czy alias istnieje
Pułapka kolejnościowa w PDFlibPas to SelectPage. Wybranie strony nanosi ponownie bieżący font przez SelectFont, co rejestruje ten font w zasobach strony. Strona bez własnego /Resources rozwiązuje się do słownika rodzica, więc samo wybranie takiej strony legalnie dodaje /Font do węzła Pages. W teście MovePage z góry wybranie dawnej strony 2 dodaje wpis Helvetica do węzła 3, co jest zachowaniem poprawnym, a nie wyciekiem. Dlatego sprawdzenie GetObjectToString(3) biegnie przed SelectPage(1); zamień je kolejnością, a test padnie na naprawionej kompilacji
Ta reguła zaznacza też, co v3.539.36 celowo zostawia w spokoju. Zapis zasobu do strony, która dziedziczy swój słownik Resources, pisze do słownika przodka, a każda siostra widzi nowy wpis. To dziedziczenie działające zgodnie ze specyfikacją, nie współdzielenie instancji, i jest nieszkodliwe, bo dodanie nazwy fontu albo obrazu do współdzielonego słownika nie zmienia renderowania pozostałych stron. Jeśli potrzebujesz, żeby strona przestała dziedziczyć, daj jej najpierw własny słownik Resources
Lista kontrolna dla kodu modelu obiektów PDF
Wnioski uogólniają się na każdy model obiektów PDF zbudowany na puli i kontenerach wskaźnikowych, w Delphi i gdziekolwiek indziej:
- Materializując dziedziczone atrybuty według ISO 32000-1 §7.7.3.4, kopiuj głęboko wartości bezpośrednie i zostawiaj referencje pośrednie jako nowe referencje na ten sam obiekt
- Nigdy nie dodawaj istniejącej instancji przez
Adddo drugiego kontenera, chyba że współdzielenie jest zamierzone i udokumentowane; własność puli znaczy, że runtime nigdy nie zaprotestuje - Edytuj w miejscu tylko to, co bieżący węzeł posiada jako obiekt bezpośredni; wartości pośrednie albo odziedziczone zastępuj świeżym obiektem bezpośrednim (copy-on-write)
- Wartości domyślne wyprowadzone z innego wpisu, jak CropBox z MediaBoxa, potrzebują własnej instancji
- Testuj aliasing sekwencjami zmień-potem-obejrzyj na drugim posiadaczu i sprawdzaj kolejność wywołań, które mogłyby legalnie zapisać po drodze
- Porównywanie zapisanego wyjścia niczego tu nie dowodzi: współdzielone i skopiowane wartości serializują się identycznie do pierwszej edycji
- W PDFlibPas zaktualizuj do v3.539.36 lub nowszego, jeśli wołasz
MovePage,CollateDocumentsEx,BalancePageTreealboCopyPageRanges, a potem edytujesz boxy stron albo rysujesz po stronach
PDFlibPas wystawia edycję drzewa stron, kopiowanie między dokumentami i kontrolę boxów stron przez jedną klasę TPDFlib dla Delphi, C++Builder i Free Pascal. Wydania, platformy i pełną referencję API znajdziesz na stronie produktu PDFlibPas Delphi PDF library