در PDFlibPas، کتابخانهٔ PDF در Delphi، صفحهای که با MovePage جابهجا میشد همان خودِ اشیای MediaBox و CropBox و Resources را میگرفت که گرهٔ Pages قدیمیاش نگه داشته بود، پس یک SetPageBox یا DrawText بعدی روی صفحهٔ جابهجاشده بیسروصدا همان گره و هر خواهری را که هنوز از آن ارث میبرد بازنویسی میکرد. از v3.539.36 صفحهٔ جابهجاشده کپیهای خودش را میگیرد و یک ارجاع غیرمستقیم ارجاع میماند. همان release دو مسیر مرتبط دیگر را هم بست: SetPageBox روی یک جعبهٔ غیرمستقیم که چند صفحه شریکاند، و CopyPageRanges که صفحات سند مبدأ را به گرهٔ Pages شان گره میگذاشت با CropBox گرهخورده به MediaBox
گزارشهایی که به اینجا میرسند هرگز از identity اشیاء حرف نمیزنند. میگویند «صفحهٔ 7 را بریدم و صفحات 8 تا 12 هم بریده شدند»، یا «CropBox را باریک کردم و MediaBox هم با آن جابهجا شد»، یا گیجکنندهترینشان، «یک صفحه را به سند جدید کپی کردم و فایل اصلی تغییر کرد». هیچچیز crash نمیکند، چیزی نشت نمیکند، و فایل ذخیرهشده یک PDF کاملاً معتبر است. فقط هندسهای داخلش هست که هیچکس نخواسته
چرا SetPageBox روی یک صفحه صفحات خواهر را تغییر اندازه میدهد؟
SetPageBox صفحات خواهر را تغییر اندازه میداد چون دو مدخل درخت صفحه به یک آرایهٔ درونحافظهای اشاره میکردند و SetPageBox آرایهٔ هدفش را درجا ویرایش میکند. هر صفحه یا گرهٔ Pages ای که همان instance را داشت ویرایش را میدید. سه مسیر کد در PDFlibPas قبل از v3.539.36 آن اشتراک را تولید میکردند:
MovePageقبل از جدا کردن صفحه از پدرش، attributeهای ارثی را روی صفحه مادی میکند، و بهجای کپی اشیای خودِ جد را میچسباند، پس صفحهٔ جابهجاشده و خواهرهای قبلیاش یک آرایهٔ جعبه و یک dictionary از Resources مشترک داشتندSetPageBoxارجاعهای غیرمستقیم را دنبال میکرد و آرایهٔ ارجاعشده را ویرایش میکرد، پس فایلی که چند صفحهاش به یک شیء/MediaBox 11 0 Rاشاره میکردند با یک فراخوانی همهٔ آن صفحاتش تغییر اندازه میدادند، فارغ از اینکهMovePageاصلاً در کار بوده یا نهCopyPageRangesقبل از کلون کردن صفحهٔ مبدأ در سند مقصد، مقادیر ارثی را روی صفحهٔ مبدأ مادی میکند، و instanceهای گرهٔ Pages را به صفحهٔ مبدأ میچسباند، بهعلاوهٔ خود instance MediaBox را بهعنوان CropBox پیشفرض
مورد MovePage تاریخچهٔ کوتاهی دارد. قبل از v3.539.27، MovePage فقط /Resources را با خود میبرد، پس صفحهای که زیر پدر متفاوتی میرفت بیسروصدا اندازه و چرخش آن پدر را میگرفت. v3.539.27 MediaBox و CropBox و Rotate گمشده را فیکس کرد، که همان چیزی است که CollateDocumentsEx موقع جابهجایی ترتیب صفحات به آن تکیه میکند، اما مقادیر جد را بهشکل instanceهای مشترک میچسباند. این همان پنجرهای است که v3.539.36 میبندد. مسیرهای SetPageBox و CopyPageRanges قدیمیترند؛ هر build ای قبل از v3.539.36 آنها را دارد
مقادیر مستقیم، ارجاعهای غیرمستقیم و ارثبری attribute صفحه
یک کپی درست از یک attribute صفحهٔ ارثی مقادیر مستقیم را دوبله میکند و ارجاعهای غیرمستقیم را ارجاع نگه میدارد، چون این همان تمایزی است که خود ISO 32000-1 میکشد. یک شیء مستقیم مثل [0 0 400 300] که داخل یک dictionary نوشته شده فقط به همان dictionary تعلق دارد. یک شیء غیرمستقیم که یک بار بهعنوان 11 0 obj تعریف شده و بهشکل 11 0 R ارجاع داده میشود، از روی طراحی مشترک است: ISO 32000-1 §7.3.10 آن را از هر جای فایل آدرسپذیر میکند و هر 11 0 R یعنی همان شیء
ارثبری attribute صفحه، ISO 32000-1 §7.7.3.4، حالت سومی اضافه میکند. Resources و MediaBox و CropBox و Rotate میتوانند روی گرهٔ Pages بنشینند و به هر صفحهٔ نزولی که مقدار خودش را تعریف نکرده اعمال شوند. صفحه مقدار را نگه نمیدارد؛ مقدار را از طریق /Parent پیدا میکند. آن زنجیرهٔ جستوجو لحظهای که صفحه پدرش را عوض میکند میشکند، و برای همین MovePage و BalancePageTree باید اول مقادیر مؤثر را روی خود صفحه بنویسند. سؤال فقط این است که چطور بنویسند
چرا یک object pool اشتباه را پنهان میکند
در PDFlibPas هر شیء PDF parseشده یا ساختهشده متعلق به pool یعنی TPDFStructure سند است و dictionaryها و آرایهها اشارهگر خام به درایههایشان ذخیره میکنند. TPDFDictionary.Add اشارهگر را ثبت میکند و هیچ چیز دیگری. پس اضافه کردن یک instance به دو کانتینر پدر در هر سطحی که runtime میتواند چک کند قانونی است: نه double free در برچیدن، نه شمارندهٔ ارجاع برای خراب شدن، نه exception. سریالایز کردن هم به همان اندازه بخشنده است، چون هر کانتینر مقدار فعلی instance مشترک را درونخطی مینویسد و قبل از هر ویرایشی خروجی بایتبهبایت همان چیزی است که یک کپی درست تولید میکرد
aliasing فقط وقتی خودش را نشان میدهد که کسی instance مشترک را درجا جهش دهد. SetPageBox دقیقاً همین کار را از طریق یک wrapper مستطیلی روی آرایهٔ موجود میکند، و کشیدن روی صفحه هم همین کار را با dictionary Resources موقع ثبت یک فونت یا تصویر. ویرایش، بیسروصدا، در هر کانتینر دیگری که اشارهگر را دارد فرود میآید
چگونه PDFlibPas v3.539.36 بهجای اشتراک کپی میکند
PDFlibPas v3.539.36 مشکل را از دو سر فیکس میکند: مادی کردن حالا کپی میچسباند و نوشتن جعبه فقط آرایهای را ویرایش میکند که صفحه مالکش است. هر فیکس حالتی را پوشش میدهد که دیگری نمیتواند
هلپر مادیسازی یعنی PLInheritPageAttributes حالا بهجای Value Page.Owner.Decode(Value.Output) را میچسباند. رفتوبرگشت از سریالایزر راهی خام اما دقیق است که معناشناسی PDF را مجانی میگیرد. یک آرایه یا dictionary مستقیم به متن literal خودش سریالایز میشود و به یک instance تازه و مستقل decode میشود. یک ارجاع غیرمستقیم به 11 0 R سریالایز میشود و به یک شیء ارجاع جدید decode میشود که به همان شیء 11 اشاره میکند، پس صفحه همچنان به شیء مشترک ارجاع میدهد بهجای اینکه کپی درونخطیشده بگیرد، و رفتار ارجاع معرفیشده در v3.539.27 حفظ میشود. کپی دقیقاً به عمق ساختار مستقیم است: هر چیزی که از طریق یک ارجاع داخل dictionary کپیشده برسد مشترک میماند، همانطور که فرمت فایل قصدش را دارد. BalancePageTree برای هر صفحهای که پدرش را عوض میکند همان هلپر را صدا میزند، پس صفحاتی که آنجا مادی میشوند هم instanceهای جدا میگیرند
فقط کپی کردن کافی نیست، چون حالت ارجاع هنوز به یک شیء مشترک اشاره میکند. اگر SetPageBox آن ارجاع را دنبال میکرد و شیء 11 را ویرایش میکرد، صفحهٔ جابهجاشده باز پدر قدیمی و بقیهٔ فرزندانش را تغییر اندازه میداد. پس نویسندهٔ جعبه حالا copy-on-write را اعمال میکند: فقط وقتی درجا ویرایش میکند که مدخل خود صفحه یک آرایهٔ مستقیم باشد، و یک جعبهٔ غیرمستقیم یا غایب را با یک آرایهٔ مستقیم جدید جایگزین میکند. شیء 11 برای هر صفحهٔ دیگری که به آن ارجاع میدهد دستنخورده میماند
| مسیر کد | قبل از v3.539.36 | از v3.539.36 |
|---|---|---|
مادیسازی MovePage | صفحه instanceهای مستقیم خودِ جد را نگه میدارد | صفحه کپیهای decodeشده را نگه میدارد؛ ارجاعها ارجاع میمانند |
SetPageBox | ارجاع را دنبال میکند و آرایهٔ مشترک را ویرایش میکند | فقط یک آرایهٔ مستقیم روی صفحه را ویرایش میکند، وگرنه یکی جدید مینویسد |
صفحهٔ مبدأ در CopyPageRanges | جعبههای گرهٔ Pages را شریک میکند؛ CropBox همان instance MediaBox است | هر مقدار مادیشده روی صفحهٔ مبدأ یک کپی است |
| جعبههای پیشفرض موقع کلون کردن منابع صفحه | CropBox و BleedBox و TrimBox و ArtBox یک آرایه را شریکاند | هر جعبهٔ پیشفرض آرایهٔ خودش را میگیرد |
ردیف آخر پنهان است. وقتی کتابخانه منابع یک صفحه را برای برداشت صفحه یا ادغام کلون میکند، درایههای غایب CropBox و BleedBox و TrimBox و ArtBox را پر میکند و آنها قبلاً همان یک instance آرایه بودند. هیچ فراخوانندهٔ فعلی نگذاشت آن alias به اندازهٔ کافی زنده بماند که ویرایش شود، اما فراخوانندهٔ بعدی میگذاشت. اینکه مقادیر جعبهٔ پیشفرض چطور انتخاب میشوند موضوع خودش است، پوشش دادهشده در راهنمای PDFlibPas برای پیشفرضهای TrimBox و BleedBox و CropBox
بازتولید aliasing در MovePage با یک PDF دستساز
سریعترین راه برای چک کردن هر build از PDFlibPas یک PDF کوچک دستنویس است که با LoadFromString load میشود، جایی که شمارهٔ هر شیء از قبل معلوم است. هلپر زیر یک جدول cross-reference کلاسیک با بایت آفستهای درستمحاسبه مینویسد، پس تست به رفتار بازیابی parser برای فایلهای خراب تکیه نمیکند
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); // بایت آفست مبتنی بر صفرِ "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 point)، یک CropBox مستقیم و یک dictionary مستقیم از 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) برای MediaBox نوع جعبهٔ 1 و برای CropBox نوع 2 میگیرد و برای عرض dimension 2. با مبدأ پیشفرض پایین-چپ، SetPageBox(1, 0, 200, 200, 200) یعنی چپ 0، بالا 200، عرض 200 و ارتفاع 200. روی buildهای بین v3.539.27 و v3.539.35 چکهای خواهر شکست میخورند: ویرایش CropBox در آرایهٔ مستقیم گرهٔ 3 فرود میآید و ویرایش MediaBox شیء 11 را از طریق ارجاع بازنویسی میکند
آیا CopyPageRanges سند مبدأ را تغییر میدهد؟
از v3.539.36 CopyPageRanges همچنان روی صفحات مبدأ مینویسد، اما هر مقداری که مینویسد یک کپی جدا است، پس ویرایشهای بعدی روی مبدأ محلی به صفحهای میماند که ویرایش میکنی. خود نوشتن عمدی است: صفحهٔ مبدأ قبل از اینکه dictionary اش در مقصد کلون شود به MediaBox و CropBox و Rotate و Resources صریح نیاز دارد، وگرنه کپی همهٔ چیزی را که ارث برده بود گم میکرد. شمارهگذاری دوباره و کپی کردن صفحه به مقصد در کپی عمیق اشیای بینسندی در PDFlibPas پوشش داده شده؛ این باگ روی سمت مبدأ بود، جایی که اکثر آدمها فرض میکنند یک کپی فقط میخواند
خروجی هرگز نشانش نمیداد. مشترک یا کپیشده، مقادیر مادیشده یکسان سریالایز میشوند، پس هر دو سند قبل و بعد از فیکس بایتبهبایت همان ذخیره میشدند. فقط یک ویرایش روی سند مبدأ بعد از کپی alias را لو میداد:
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 مستقیم گرهٔ ریشه را ارث میبردند، کپی آن instance را به صفحهٔ 1 مبدأ میچسباند، و دوباره بهعنوان CropBox صفحهٔ 1 میچسباند. پس باریک کردن CropBox باریک کردن MediaBox بود، و تغییر اندازهٔ MediaBox صفحهٔ 2 را از طریق گرهٔ ریشه تغییر اندازه میداد. گردشکارهایی که صفحاتی را کپی بیرون میکشند و بعد مبدأ را ادامه میدهند، مثل دور هم چیدن اسکنهای دوبازوی دورو در یک PDF قبل از بریدن اصلها، جایی هستند که این خودش را نشان داد
چرا aliasing کردن instance اینقدر سخت تست میشود؟
aliasing instance سخت تست میشود چون اثر مشاهدهپذیرش سه گام در یک ترتیب مشخص لازم دارد: alias را بساز، یک سمت را جهش بده، بعد سمت دیگر را قبل از اینکه هر چیز دیگری به آن دست بزند بازرسی کن. بیشتر تستها فقط گام اول را انجام میدهند و خروجی ذخیرهشده را مقایسه میکنند، که چه alias باشد چه نه یکسان است
تلهٔ ترتیب در PDFlibPas SelectPage است. انتخاب یک صفحه فونت فعلی را از طریق SelectFont دوباره اعمال میکند، که آن فونت را در منابع صفحه ثبت میکند. صفحهای که /Resources خودش را ندارد به dictionary پدرش حل میشود، پس فقط انتخاب چنین صفحهای بهطور مشروع /Font را به گرهٔ Pages اضافه میکند. در تست MovePage بالا، انتخاب صفحهٔ 2 سابق مدخل Helvetica را به گرهٔ 3 اضافه میکند، که رفتار درست است و نشت نیست. برای همین چک GetObjectToString(3) قبل از SelectPage(1) اجرا میشود؛ جایشان را عوض کن و تست روی build فیکسشده شکست میخورد
همین قاعده مشخص میکند v3.539.36 عمداً چه چیزی را به حال خود میگذارد. نوشتن یک منبع روی صفحهای که dictionary Resources اش را ارث میبرد داخل dictionary جد مینویسد و هر خواهر مدخل جدید را میبیند. آن ارثبری است همانطور که مشخصه کار میکند، نه اشتراک instance، و بیضرر است چون اضافه کردن نام فونت یا تصویر به یک dictionary مشترک نحوهٔ رندر شدن صفحات دیگر را تغییر نمیدهد. اگر لازم است صفحهای ارثبری را قطع کند، اول dictionary Resources خودش را به آن بده
چکلیست برای کد مدل شیء PDF
این درسها به هر مدل شیء PDF ای که روی pool و کانتینرهای اشارهگری ساخته شده، در Delphi یا هر جای دیگر، تعمیم مییابند:
- موقع مادی کردن attributeهای ارثی طبق ISO 32000-1 §7.7.3.4، مقادیر مستقیم را عمیق کپی کن و ارجاعهای غیرمستقیم را بهشکل ارجاعهای جدید به همان شیء نگه دار
- هرگز یک instance موجود را با
Addبه کانتینر دوم اضافه نکن مگر اینکه اشتراک عمدی و مستند باشد؛ مالکیت توسط pool یعنی runtime هرگز گله نمیکند - فقط چیزی را درجا ویرایش کن که گرهٔ فعلی بهعنوان شیء مستقیم مالکش است؛ مقادیر غیرمستقیم یا ارثی را با یک شیء مستقیم تازه جایگزین کن (copy-on-write)
- مقادیر پیشفرض مشتق از یک درایهٔ دیگر، مثل یک CropBox از MediaBox، به instance خودشان نیاز دارند
- aliasing را با توالیهای جهش-بعد-بازرسی روی نگهدارندهٔ دیگر تست کن و ترتیب فراخوانیهایی را که ممکن است در بین بهطور مشروع بنویسند چک کن
- مقایسهٔ خروجی ذخیرهشده اینجا هیچ چیزی را ثابت نمیکند: مقادیر مشترک و کپیشده تا اولین ویرایش یکسان سریالایز میشوند
- روی PDFlibPas اگر
MovePageیاCollateDocumentsExیاBalancePageTreeیاCopyPageRangesرا صدا میزنی و بعد جعبههای صفحه را ویرایش یا روی صفحات میکشی، به v3.539.36 یا بعدتر ارتقا بده
PDFlibPas ویرایش درخت صفحه، کپی بینسندی و کنترل جعبهٔ صفحه را از طریق یک کلاس TPDFlib برای Delphi و C++Builder و Free Pascal عرضه میکند. برای ویرایشها و پلتفرمها و مرجع کامل API صفحهٔ محصول PDFlibPas Delphi PDF library را ببین