Artykuł techniczny

PDFlibPas MovePage: gdy dziedziczone boxy dzielą instancje

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:

  • MovePage materializuje 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 Resources
  • SetPageBox podąż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, czy MovePage w ogóle brał w tym udział
  • CopyPageRanges materializuje 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
Aliasing w MovePage w PDFlibPas: przeniesiona strona i jej dawna siostra trzymały obie instancję tablicy MediaBox przodka, więc SetPageBox edytował jedną stronę, a przeskalowywał drugą; od v3.539.36 materializacja przypina zdekodowane kopie, a edycje zostają lokalne w stronie, której dotykasz
Dwa wpisy drzewa stron wskazujące na jedną tablicę w pamięci sprawiały, że każda edycja lądowała u każdego posiadacza, a zapisany PDF cały czas pozostawał poprawny

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

Podróż materializacji w obie strony w PDFlibPas, gdzie PLInheritPageAttributes przypina Page.Owner.Decode(Value.Output): tablica bezpośrednia serializuje się do dosłownego tekstu i dekoduje do świeżej instancji, a pośrednie 11 0 R serializuje się i dekoduje do nowej referencji, która wciąż wskazuje współdzielony obiekt 11
Serializacja i ponowne sparsowanie daje semantykę obiektów PDF gratis: wartości bezpośrednie się kopiują, referencje zostają referencjami, dokładnie tak, jak zamierza ISO 32000-1

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

Decyzja copy-on-write w SetPageBox w PDFlibPas: gdy własny wpis strony to tablica bezpośrednia, jest edytowany w miejscu, a gdy to referencja pośrednia albo wpis brakujący, pisarz zastępuje go nową tablicą bezpośrednią, więc współdzielony obiekt 11 zachowuje swoją wartość dla każdej innej cytującej go strony
Kopiowanie przy materializacji nie wystarczy, dopóki referencje wciąż wskazują na współdzielone obiekty, więc pisarz boxów edytuje tylko to, co jest własnością strony
Ścieżka koduPrzed v3.539.36Od v3.539.36
Materializacja w MovePageStrona trzyma bezpośrednie instancje przodka same w sobieStrona trzyma zdekodowane kopie; referencje zostają referencjami
SetPageBoxPodąż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 CopyPageRangesDzieli boxy węzła Pages; CropBox to instancja MediaBoxaKażda zmaterializowana wartość na stronie źródłowej to kopia
Domyślne boxy przy klonowaniu zasobów stronyCropBox, 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 Add do 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, BalancePageTree albo CopyPageRanges, 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