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