Техническа статия

PDFlibPas MovePage: наследени кутии в споделени инстанции

В PDFlibPas, Delphi PDF библиотеката, страница, преместена с MovePage, получаваше същите самите MediaBox, CropBox и Resources обекти, които старият ѝ Pages възел държеше, така че по-късен SetPageBox или DrawText върху преместената страница тихо презаписваше този възел и всеки събрат, който пак наследява от него. От v3.539.36 преместената страница получава собствени копия, а непряка референция си остава референция. Същата версия затваря и два свързани пътя: SetPageBox върху непряка кутия, споделяна от няколко страници, и CopyPageRanges, оставящ страниците на изходния документ вързани за своя Pages възел, с CropBox вързан за MediaBox

Докладите, които водят дотук, никога не споменават идентичност на обекти. Те казват неща като „отрязах страница 7 и страниците 8 до 12 също се отрязаха“, или „стесних CropBox и MediaBox се измести с него“, или, най-объркващото, „копирах страница в нов документ и оригиналният файл се промени“. Нищо не се срива, нищо не тече, а записаният файл е съвсем валиден PDF. Просто съдържа геометрия, която никой не е искал

Защо SetPageBox върху една страница преоразмерява съседите ѝ?

SetPageBox преоразмеряваше съседите, защото два записа в дървото от страници сочеха към един масив в паметта, а SetPageBox редактира целевия си масив на място. Всяка страница или Pages възел, държащ същата инстанция, виждаше редакцията. Три кодови пътя в PDFlibPas произвеждаха това споделяне преди v3.539.36:

  • MovePage материализира наследяемите атрибути върху страницата, преди да я откъсне от родителя ѝ, и прикачваше собствените обекти на предшественика вместо копия, така че преместената страница и бившите ѝ събрати споделяха масив за кутия и Resources речник
  • SetPageBox следваше непреки референции и редактираше реферирания масив, така че файл, в който няколко страници сочеха към един /MediaBox 11 0 R обект, имаше всички тези страници преоразмерени с едно извикване, независимо дали MovePage изобщо е участва
  • CopyPageRanges материализира наследените стойности върху изходната страница, преди да я клонира в целевия документ, и прикачваше инстанциите на Pages възела към изходната страница, плюс самата инстанция на MediaBox като подразбиращ се CropBox
Алиасинг при MovePage в PDFlibPas, при който преместена страница и бивш ѝ събрат държаха и двамата собствената инстанция на MediaBox масива на предшественика, така че SetPageBox редактира една страница и преоразмерява другата; от v3.539.36 материализацията прикачва декодирани копия и редакциите остават локални за страницата, която пипате
Два записа в дървото от страници, сочещи към един масив в паметта, пращаха всяка редакция при всеки притежател, а записаният PDF оставаше валиден през цялото време

Случаят с MovePage има кратка история. Преди v3.539.27 MovePage пренасяше само /Resources, така че страница, преместена под друг родител, тихо поемаше размера и ротацията на този родител. v3.539.27 поправи липсващите MediaBox, CropBox и Rotate — на това разчита и CollateDocumentsEx, когато преподрежда страници — но прикачи стойностите на предшественика като споделени инстанции. Това е прозорецът, който v3.539.36 затваря. Пътищата на SetPageBox и CopyPageRanges са по-стари; всяка компилация преди v3.539.36 ги има

Директни стойности, непреки референции и наследяване на атрибути на страница

Коректно копие на наследен атрибут на страница дублира директните стойности и пази непреките референции като референции, защото това е разликата, която самият ISO 32000-1 прави. Директен обект като [0 0 400 300], записан вътре в речник, принадлежи само на този речник. Непряк обект, дефиниран веднъж като 11 0 obj и цитиран като 11 0 R, е споделен по замисъл: ISO 32000-1 §7.3.10 го прави адресируем отвсякъде във файла, а всяко 11 0 R значи същия обект

Наследяването на атрибути на страница, ISO 32000-1 §7.7.3.4, добавя трети случай. Resources, MediaBox, CropBox и Rotate могат да седят на Pages възел и да важат за всяка потомъчна страница, която не дефинира свои. Страницата не държи стойността; я търси през /Parent. Тази верига от търсене се чупва в момента, в който страницата смени родител, затова MovePage и BalancePageTree първо трябва да запишат ефективните стойности върху самата страница. Въпросът е само как да ги запишат

Защо pool от обекти скрива грешката

В PDFlibPas всеки парснат или създаден PDF обект е притежание на TPDFStructure pool-а на документа, а речниците и масивите пазят обикновени указатели към записите си. TPDFDictionary.Add записва указателя и нищо друго. Добавянето на една инстанция към два родителски контейнера затова е законно на всяко ниво, което runtime-ът може да провери: никакъв double free при разпазване, никакъв брояч на референции, който да се обърка, никакво exception. Сериализацията е еднакво снизходителна, защото всеки контейнер записва текущата стойност на споделената инстанция inline, а преди всяка редакция изходът е байт по байт това, което коректно копие би произвело

Алиасингът излиза наяве само когато някой промени споделената инстанция на място. SetPageBox прави точно това чрез правоъгълник обвивка върху съществуващия масив, а рисуването върху страница го прави на Resources речника, когато шрифт или изображение се регистрира. Редакцията отива, тихо, във всеки друг контейнер, държащ указателя

Как PDFlibPas v3.539.36 копира вместо да споделя

PDFlibPas v3.539.36 оправя проблема и на двата края: материализацията вече прикачва копия, а записите на кутии редактират само масив, притежаван от страницата. Всяка поправка покрива случай, който другата не може

Помощникът за материализация, PLInheritPageAttributes, вече прикачва Page.Owner.Decode(Value.Output) вместо Value. Обратният път през сериализатора е груб, но точен начин да получите PDF семантика безплатно. Директен масив или речник се сериализира до буквалния си текст и се декодира в свежа, независима инстанция. Непряка референция се сериализира до 11 0 R и се декодира в нов reference обект, сочещ към същия обект 11, така че страницата пак сочи споделения обект, вместо да получи вмъкнато копие — това пази reference поведението, въведено в v3.539.27. Копието е точно толкова дълбоко, колкото е директната структура: всичко, достигнато чрез референция вътре в копиран речник, си остава споделено, както форматът на файла го замисля. BalancePageTree вика същия помощник за всяка страница, която преприсъединява, така че страниците, материализирани там, също получават отделни инстанции

Обратен път на материализация в PDFlibPas, при който PLInheritPageAttributes прикачва Page.Owner.Decode(Value.Output): директен масив се сериализира до буквален текст и се декодира в свежа инстанция, а непряко 11 0 R се сериализира и декодира в нова референция, която пак сочи споделения обект 11
Сериализиране и повторен парсинг дават обектната семантика на PDF безплатно: директните стойности се копират, референциите си остават референции, точно както ISO 32000-1 го замисля

Само копирането не стига, защото случаят с референция пак сочи споделен обект. Ако SetPageBox следваше тази референция и редактираше обект 11, преместената страница пак щеше да преоразмерява стария родител и останалите му деца. Затова писачът на кутии вече прилага copy-on-write: редактира на място само когато собственият запис на страницата е директен масив, а непряка или липсваща кутия заменя с нов директен масив. Обект 11 си остава недокоснат за всяка друга страница, която го цитира

Copy-on-write решение на SetPageBox в PDFlibPas: когато собственият запис на страницата е директен масив, той се редактира на място, а когато е непряка референция или липсва, писачът го заменя с нов директен масив, така че споделеният обект 11 пази стойността си за всяка друга страница, която го цитира
Копирането при материализация не стига, докато референции пак сочат споделени обекти, затова писачът на кутии редактира само това, което страницата притежава
Кодов пътПреди v3.539.36От v3.539.36
MovePage материализацияСтраницата държи собствените директни инстанции на предшественикаСтраницата държи декодирани копия; референции си остават референции
SetPageBoxСледва референция и редактира споделения масивРедактира само директен масив на страницата, иначе записва нов
CopyPageRanges изходна страницаСподеля кутиите на Pages възела; CropBox е инстанцията на MediaBoxВсяка материализирана стойност на изходната страница е копие
Подразбиращи се кутии при клониране на ресурси на страницаCropBox, BleedBox, TrimBox и ArtBox споделят един масивВсяка подразбираща се кутия получава собствен масив

Последният ред е латентният. Когато библиотеката клонира ресурсите на страница за прихващане на страница или сливане, тя попълва липсващите записи CropBox, BleedBox, TrimBox и ArtBox, а те преди бяха една и съща инстанция на масив. Никой сегашен извикващ не остави този алиас да оцелее достатъчно дълго, за да бъде редактиран, но следващият щеше. Как се избират тези подразбиращи се стойности на кутии е отделна тема, разгледана в ръководството на PDFlibPas за подразбиращите се TrimBox, BleedBox и CropBox

Възпроизвеждане на MovePage алиасинга с ръчно сглобен PDF

Най-бързият начин да проверите всяка компилация на PDFlibPas е малък ръчно написан PDF, зареден с LoadFromString, където всеки номер на обект е известен предварително. Помощникът по-долу записва класическа cross-reference таблица с правилно сметнати байтови отмествания, така че тестът не разчита на recovery поведението на парсера за повредени файлове

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);   // 0-базирано байтово отместване на "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      // всеки запис е точно 20 байта
    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;

Тестовият документ има два междинни Pages възела. Възел 3 носи непряк MediaBox (обект 11, 400 на 300 пункта), директен CropBox и директен Resources речник и притежава две страници. Възел 4 има MediaBox с размер Letter и притежава третата страница. Преместването на страница 1 на позиция 3 я преприсъединява под възел 4 — точно това е преместването, което има нужда от материализация: без нея страницата щеше да се превърне в 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);                       // страницата, която току-що преместихме
    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');

    // Огледайте стария родител ПРЕДИ да изберете друга страница (виж по-долу)
    Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
      'font registered in the old Pages node');

    Lib.SelectPage(1);                       // бивша страница 2, все още под възел 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) приема тип кутия 1 за MediaBox и 2 за CropBox и размерност 2 за ширина. С подразбиращото се начало долу-вляво SetPageBox(1, 0, 200, 200, 200) значи ляво 0, горе 200, 200 широчина и 200 височина. На компилации между v3.539.27 и v3.539.35 проверките за събрати се провалят: редакцията на CropBox отива в директния масив на възел 3, а редакцията на MediaBox презаписва обект 11 през референцията

Променя ли CopyPageRanges изходния документ?

От v3.539.36 CopyPageRanges пак записва върху изходните страници, но всяка стойност, която записва, е отделно копие, така че по-късни редакции на изхода остават локални за страницата, която редактирате. Самото записване е нарочно: изходната страница се нуждае от явни MediaBox, CropBox, Rotate и Resources, преди речникът ѝ да бъде клониран в целевия, иначе копието щеше да загуби всичко, което е наследила. Преномерирането и копирането на страницата в целевата е разгледано в междудокументно deep copy на обекти в PDFlibPas; този бъг седеше на изходната страна, за която повечето хора предполагат, че копие само я чете

Изходът никога не го показаваше. Споделени или копирани, материализираните стойности се сериализират еднакво, така че и двата документа се записваха байт по байт еднакво преди и след поправката. Само редакция на изходния документ след копирането разкри алиаса:

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;             // става избраният документ
    Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');

    Lib.SelectDocument(SourceID);
    Lib.SelectPage(1);
    Lib.SetPageBox(2, 50, 250, 100, 100);    // стесняваме само 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);            // копието пази оригиналния си размер
    Lib.SelectPage(Lib.PageCount);
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
  finally
    Lib.Free;
  end;
end;

Преди v3.539.36 и двете страници тук наследяваха директния MediaBox на кореновия възел, копието прикачи тази инстанция към изходна страница 1 и я прикачи пак като CropBox на страница 1. Стесняването на CropBox затова стесняваше MediaBox, а преоразмеряването на MediaBox преоразмеряваше страница 2 през кореновия възел. Работни процеси, които изваждат страници и после продължават да редактират изхода — като подреждане на duplex сканирания в един PDF преди отрязване на оригиналите — са мястото, където това излизаше наяве

Защо инстанционният алиасинг е толкова труден за тестване?

Инстанционният алиасинг е труден за тестване, защото наблюдаемият ефект се нуждае от три стъпки в определен ред: създайте алиаса, променете едната страна, после огледайте другата, преди нещо друго да я докосне. Повечето тестове правят само първата стъпка и сравняват записания изход, който е идентичен независимо дали алиасът съществува

Капанът с реда в PDFlibPas е SelectPage. Избирането на страница прилага наново текущия шрифт чрез SelectFont, което регистрира шрифта в ресурсите на страницата. Страница без собствен /Resources се разреши до речника на родителя си, така че просто избирането на такава страница законно добавя /Font към Pages възела. В теста MovePage по-горе избирането на бивша страница 2 добавя записа Helvetica към възел 3, което е коректно поведение, а не теч. Затова проверката GetObjectToString(3) върви преди SelectPage(1); разменете ги и тестът се проваля на оправена компилация

Това правило маркира и това, което v3.539.36 нарочно оставя на мира. Записването на ресурс към страница, която наследява своя Resources речник, пише в речника на предшественика, а всеки събрат вижда новия запис. Това е наследяването, работещо както е описано, а не инстанционно споделяне, и е безобидно, защото добавянето на име на шрифт или изображение към споделен речник не променя как другите страници се рендират. Ако ви трябва страница да спре да наследява, първо ѝ дайте собствен Resources речник

Списък за проверка за код върху обектния модел на PDF

Уроците се обобщават за всеки обектен модел на PDF, изграден върху pool и пойнтерни контейнери, в Delphi или другаде:

  • При материализиране на наследени атрибути по ISO 32000-1 §7.7.3.4 правете deep copy на директните стойности и пазете непреките референции като нови референции към същия обект
  • Никога не Add-вайте съществуваща инстанция към втори контейнер, освен ако споделянето е нарочно и документирано; притежанието от pool значи, че runtime-ът никога няма да се оплаче
  • Редактирайте на място само това, което текущият възел притежава като директен обект; заменяйте непреки или наследени стойности със свеж директен обект (copy-on-write)
  • Подразбиращите се стойности, изведени от друг запис — като CropBox от MediaBox — се нуждаят от собствена инстанция
  • Тествайте алиасинга с последователности промяна-после-оглед върху другия притежател и проверете реда на извиквания, които може да пишат законно по средата
  • Сравнението на записан изход не доказва нищо тук: споделени и копирани стойности се сериализират еднакво до първата редакция
  • В PDFlibPas надградете към v3.539.36 или по-нова, ако викате MovePage, CollateDocumentsEx, BalancePageTree или CopyPageRanges и после редактирате кутии на страници или рисувате върху страници

PDFlibPas излага редактирането на дървото от страници, междудокументното копиране и управлението на кутии на страници през един клас TPDFlib за Delphi, C++Builder и Free Pascal. Вижте продуктовата страница на PDFlibPas Delphi PDF library за издания, платформи и пълната API референция