Trong PDFlibPas, thư viện PDF cho Delphi, một trang chuyển bằng MovePage từng nhận đúng những object MediaBox, CropBox và Resources mà node Pages cũ của nó đang giữ, nên một SetPageBox hay DrawText về sau trên trang được chuyển đã lặng lẽ ghi đè node đó cùng mọi trang anh em vẫn đang kế thừa từ nó. Kể từ v3.539.36, trang được chuyển có bản sao riêng, và một tham chiếu indirect vẫn là tham chiếu. Cùng đợt phát hành ấy chốt luôn hai lối đi liên quan: SetPageBox trên một box indirect mà nhiều trang cùng dùng, và CopyPageRanges để các trang của tài liệu nguồn bị trói vào node Pages của chúng, với CropBox bị trói vào MediaBox
Những báo cáo dẫn đến đây chẳng bao giờ nhắc đến định danh object. Người ta nói những câu như "tôi crop trang 7 mà trang 8 tới 12 cũng bị crop theo", hay "tôi thu hẹp CropBox mà MediaBox lại dịch theo", hoặc, rối rắm nhất, "tôi chép một trang sang tài liệu mới mà tệp gốc lại đổi". Chẳng gì sập, chẳng gì rò rỉ, và tệp lưu ra là PDF hợp lệ hoàn toàn. Nó chỉ đơn giản chứa hình học mà chẳng ai yêu cầu
Vì sao SetPageBox trên một trang lại đổi kích thước các trang anh em?
SetPageBox đổi kích thước trang anh em vì hai entry trong page tree trỏ vào cùng một mảng trong bộ nhớ, còn SetPageBox sửa mảng đích của nó tại chỗ. Bất kỳ trang hay node Pages nào giữ cùng instance đều thấy thay đổi. Ba đường code trong PDFlibPas đã tạo ra kiểu dùng chung ấy trước v3.539.36:
MovePagematerialize các thuộc tính kế thừa được lên trang trước khi tách nó khỏi parent, và nó gắn các object của tổ tiên thay vì bản sao, nên trang được chuyển và các anh em cũ của nó dùng chung một mảng box và một dictionary ResourcesSetPageBoxbám theo tham chiếu indirect và sửa mảng được tham chiếu, nên một tệp mà nhiều trang cùng trỏ vào một object/MediaBox 11 0 Rcó tất cả các trang ấy bị đổi kích thước chỉ bằng một lời gọi, bất kểMovePagecó dính dáng gì hay khôngCopyPageRangesmaterialize các giá trị kế thừa lên trang nguồn trước khi clone nó sang tài liệu đích, và nó gắn các instance của node Pages lên trang nguồn, kèm luôn instance MediaBox làm CropBox mặc định
Trường hợp MovePage có một lịch sử ngắn. Trước v3.539.27, MovePage chỉ mang theo /Resources, nên một trang chuyển sang dưới một parent khác lặng lẽ mang kích thước và góc xoay của parent ấy. v3.539.27 sửa phần MediaBox, CropBox và Rotate còn thiếu, cũng chính là điều CollateDocumentsEx dựa vào khi xáo lại thứ tự trang, nhưng nó gắn các giá trị của tổ tiên thành instance dùng chung. Cửa sổ lỗi ấy là thứ v3.539.36 đóng lại. Các đường SetPageBox và CopyPageRanges cũ hơn; mọi bản build trước v3.539.36 đều có chúng
Giá trị direct, tham chiếu indirect và kế thừa thuộc tính trang
Một bản sao đúng của thuộc tính trang kế thừa phải nhân đôi giá trị direct và giữ tham chiếu indirect nguyên là tham chiếu, vì đó chính là sự phân biệt mà ISO 32000-1 tự nó vẽ ra. Một object direct như [0 0 400 300] viết trong một dictionary chỉ thuộc về dictionary đó. Một object indirect, định nghĩa một lần là 11 0 obj và được nhắc đến như 11 0 R, được thiết kế để dùng chung: ISO 32000-1 §7.3.10 cho phép đánh địa chỉ tới nó từ bất kỳ đâu trong tệp, và mọi 11 0 R đều chỉ cùng một object
Kế thừa thuộc tính trang, ISO 32000-1 §7.7.3.4, thêm vào một trường hợp thứ ba. Resources, MediaBox, CropBox và Rotate có thể nằm trên một node Pages và áp dụng cho mọi trang con cháu không tự định nghĩa riêng. Trang không giữ giá trị; nó tra giá trị qua /Parent. Chuỗi tra cứu ấy đứt ngay khoảnh khắc trang đổi parent, vì thế MovePage và BalancePageTree phải ghi các giá trị hiệu lực lên chính trang trước đã. Câu hỏi chỉ còn là ghi bằng cách nào
Vì sao object pool che giấu sai lầm
Trong PDFlibPas, mọi object PDF được parse hay tạo ra đều thuộc sở hữu của pool TPDFStructure của tài liệu, còn dictionary và array lưu con trỏ thuần tới các entry của mình. TPDFDictionary.Add ghi lại con trỏ và chẳng gì khác. Thêm một instance vào hai container cha vì thế hợp lệ ở mọi mức mà runtime có thể kiểm tra: không double free lúc dọn dẹp, không reference count để hỏng, không exception. Serialization cũng dễ dãi không kém, vì mỗi container viết giá trị hiện tại của instance dùng chung dạng inline, và trước mọi thay đổi, đầu ra giống hệt từng byte cái mà một bản sao đúng sẽ cho ra
Aliasing chỉ bộc lộ khi có người mutate instance dùng chung tại chỗ. SetPageBox làm đúng điều đó qua một wrapper hình chữ nhật phủ trên mảng sẵn có, còn việc vẽ trên trang thì làm điều tương tự với dictionary Resources khi một font hay ảnh được đăng ký. Thay đổi lọt vào, lặng lẽ, mọi container khác đang giữ con trỏ
Cách PDFlibPas v3.539.36 sao chép thay vì dùng chung
PDFlibPas v3.539.36 sửa bài ở cả hai đầu: materialization giờ gắn bản sao, còn ghi box giờ chỉ sửa mảng mà trang sở hữu. Mỗi bản sửa phủ một trường hợp mà cái kia không chạm tới
Helper materialization, PLInheritPageAttributes, giờ gắn Page.Owner.Decode(Value.Output) thay vì Value. Đi một vòng qua serializer là cách thô nhưng chuẩn xác để nhận lấy ngữ nghĩa PDF miễn phí. Một array hay dictionary direct serialize thành văn bản nguyên dạng của nó rồi decode thành một instance mới, độc lập. Một tham chiếu indirect serialize thành 11 0 R và decode thành một object tham chiếu mới trỏ vào đúng object 11 cũ, nên trang vẫn tham chiếu object dùng chung thay vì nhận một bản sao inline, giữ đúng hành vi tham chiếu đưa vào từ v3.539.27. Bản sao sâu đúng bằng cấu trúc direct: bất cứ thứ gì chạm tới qua tham chiếu bên trong một dictionary được sao chép vẫn dùng chung, đúng ý định của định dạng tệp. BalancePageTree gọi cùng helper ấy cho mọi trang nó gắn lại parent, nên các trang được materialize tại đó cũng có instance riêng
Chỉ sao chép thôi chưa đủ, vì trường hợp tham chiếu vẫn trỏ vào một object dùng chung. Nếu SetPageBox bám theo tham chiếu ấy và sửa object 11, trang được chuyển lại làm đổi kích thước parent cũ cùng các con khác của nó. Vì thế phần ghi box giờ áp copy-on-write: nó chỉ sửa tại chỗ khi entry riêng của trang là một mảng direct, và thay một box indirect hay bị thiếu bằng một mảng direct mới. Object 11 được giữ nguyên cho mọi trang khác đang nhắc tới nó
| Đường code | Trước v3.539.36 | Kể từ v3.539.36 |
|---|---|---|
Materialization của MovePage | Trang giữ các instance direct của chính tổ tiên | Trang giữ bản sao đã decode; tham chiếu vẫn là tham chiếu |
SetPageBox | Bám theo tham chiếu và sửa mảng dùng chung | Chỉ sửa mảng direct trên trang, nếu không thì ghi mảng mới |
Trang nguồn của CopyPageRanges | Dùng chung box của node Pages; CropBox là instance MediaBox | Mọi giá trị materialize trên trang nguồn đều là bản sao |
| Box mặc định khi clone tài nguyên trang | CropBox, BleedBox, TrimBox và ArtBox dùng chung một mảng | Mỗi box mặc định có mảng riêng |
Dòng cuối là cái tiềm ẩn. Khi thư viện clone tài nguyên của một trang để chụp trang hay gộp tài liệu, nó điền vào các entry CropBox, BleedBox, TrimBox và ArtBox còn thiếu, và những entry ấy từng là cùng một instance mảng. Chưa caller nào hiện tại để cái alias ấy sống đủ lâu để bị sửa, nhưng caller kế tiếp thì đã có thể. Cách chọn các giá trị box mặc định ấy là một đề tài riêng, đã trình bày trong hướng dẫn về TrimBox, BleedBox và CropBox mặc định của PDFlibPas
Tái hiện aliasing của MovePage bằng một PDF dựng tay
Cách nhanh nhất để kiểm một bản build PDFlibPas nào đó là một tệp PDF viết tay nhỏ nạp bằng LoadFromString, nơi mọi số object đều biết trước. Helper dưới đây viết một bảng cross-reference cổ điển với các byte offset tính đúng, nên phép thử không phải dựa vào hành vi phục hồi của parser với tệp hỏng
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 byte 0-based của "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 // mỗi entry đúng 20 byte
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;
Tài liệu thử có hai node Pages trung gian. Node 3 mang một MediaBox indirect (object 11, 400 nhân 300 point), một CropBox direct và một dictionary Resources direct, và sở hữu hai trang. Node 4 có MediaBox cỡ Letter và sở hữu trang thứ ba. Chuyển trang 1 tới vị trí 3 sẽ gắn lại parent của nó dưới node 4, đúng là phép chuyển cần materialization: thiếu nó, trang sẽ biến thành trang 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); // trang vừa chuyển đi
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');
// Xem nút cha cũ TRƯỚC khi chọn trang khác (xem dưới)
Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
'font registered in the old Pages node');
Lib.SelectPage(1); // trang 2 cũ, vẫn dưới node 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) nhận loại box 1 cho MediaBox và 2 cho CropBox, chiều 2 là bề rộng. Với gốc dưới-trái mặc định, SetPageBox(1, 0, 200, 200, 200) nghĩa là trái 0, trên 200, rộng 200 và cao 200. Trên các bản build giữa v3.539.27 và v3.539.35, các phép kiểm tra anh em thất bại: thay đổi CropBox rơi vào mảng direct của node 3, và thay đổi MediaBox ghi đè object 11 qua tham chiếu
CopyPageRanges có đổi tài liệu nguồn không?
Kể từ v3.539.36, CopyPageRanges vẫn ghi lên các trang nguồn, nhưng mọi giá trị nó ghi đều là bản sao riêng, nên các thay đổi về sau trên nguồn chỉ nằm gọn trong trang bạn sửa. Việc ghi ấy là cố ý: trang nguồn cần MediaBox, CropBox, Rotate và Resources tường minh trước khi dictionary của nó được clone sang đích, nếu không bản sao sẽ mất tất cả những gì nó đã kế thừa. Việc đánh lại số thứ tự và chép trang sang đích được trình bày trong deep copy object liên tài liệu trong PDFlibPas; bug này nằm ở phía nguồn, chỗ mà đa số ai cũng cho là bản sao chỉ đọc thôi
Đầu ra chưa bao giờ lộ chuyện này. Dù dùng chung hay sao chép, các giá trị materialize serialize giống hệt nhau, nên cả hai tài liệu lưu ra giống nhau từng byte trước và sau bản sửa. Chỉ có một thay đổi lên tài liệu nguồn sau khi chép mới làm lộ 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; // trở thành document đang được chọn
Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');
Lib.SelectDocument(SourceID);
Lib.SelectPage(1);
Lib.SetPageBox(2, 50, 250, 100, 100); // chỉ thu hẹp 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); // bản sao giữ nguyên kích thước gốc
Lib.SelectPage(Lib.PageCount);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
finally
Lib.Free;
end;
end;
Trước v3.539.36, cả hai trang ở đây kế thừa MediaBox direct của node gốc, bản sao gắn instance ấy lên trang nguồn 1, rồi gắn lại nó lần nữa làm CropBox của trang 1. Thu hẹp CropBox vì thế thu hẹp luôn MediaBox, và đổi kích thước MediaBox thì đổi kích thước trang 2 qua node gốc. Các workflow chép trang ra rồi tiếp tục sửa nguồn, như xáo scan duplex thành một PDF trước khi cắt gọn bản gốc, là nơi chuyện này lộ ra
Vì sao instance aliasing khó kiểm thử đến thế?
Instance aliasing khó kiểm thử vì hiệu ứng quan sát được cần ba bước theo đúng thứ tự: tạo alias, mutate một bên, rồi soát bên kia trước khi bất cứ thứ gì khác chạm vào nó. Đa số test chỉ làm bước đầu rồi so đầu ra đã lưu, thứ giống hệt nhau bất kể alias có tồn tại hay không
Cái bẫy thứ tự trong PDFlibPas là SelectPage. Chọn một trang sẽ áp lại font hiện hành qua SelectFont, thứ đăng ký font đó vào tài nguyên của trang. Một trang không có /Resources riêng sẽ phân giải về dictionary của parent, nên chỉ cần chọn trang như vậy là /Font được thêm vào node Pages một cách chính đáng. Trong test MovePage ở trên, việc chọn trang 2 cũ thêm entry Helvetica vào node 3, là hành vi đúng chứ không phải rò rỉ. Đó là lý do phép kiểm GetObjectToString(3) chạy trước SelectPage(1); đảo hai cái cho nhau là test sập ngay trên bản đã sửa
Quy tắc ấy cũng chỉ ra những gì v3.539.36 cố tình không đụng tới. Ghi một tài nguyên vào trang đang kế thừa dictionary Resources sẽ ghi vào dictionary của tổ tiên, và mọi trang anh em đều thấy entry mới. Đó là kế thừa vận hành đúng đặc tả, không phải dùng chung instance, và nó vô hại vì thêm tên font hay ảnh vào một dictionary dùng chung chẳng đổi cách render của các trang khác. Nếu cần một trang ngừng kế thừa, hãy cho nó dictionary Resources riêng trước đã
Danh mục kiểm cho code mô hình object PDF
Những bài học này khái quát được cho bất kỳ mô hình object PDF nào dựng trên pool và các container con trỏ, trong Delphi hay nơi khác:
- Khi materialize thuộc tính kế thừa theo ISO 32000-1 §7.7.3.4, hãy deep-copy giá trị direct và giữ tham chiếu indirect thành tham chiếu mới tới cùng object ấy
- Đừng bao giờ
Addmột instance sẵn có vào container thứ hai trừ phi việc dùng chung là có chủ đích và được ghi chép; sở hữu bởi pool nghĩa là runtime sẽ chẳng bao giờ than phiền - Chỉ sửa tại chỗ những gì node hiện tại sở hữu như object direct; thay các giá trị indirect hay kế thừa bằng một object direct mới tinh (copy-on-write)
- Giá trị mặc định suy ra từ entry khác, như CropBox từ MediaBox, cần instance riêng của mình
- Kiểm thử aliasing bằng chuỗi mutate-rồi-soát trên bên giữ kia, và rà thứ tự các lời gọi có thể ghi chính đáng xen giữa
- So đầu ra đã lưu chẳng chứng minh được gì ở đây: giá trị dùng chung và giá trị chép serialize giống hệt nhau cho tới lần sửa đầu tiên
- Trên PDFlibPas, nâng lên v3.539.36 trở lên nếu bạn gọi
MovePage,CollateDocumentsEx,BalancePageTreehayCopyPageRangesrồi lại sửa page box hay vẽ trên trang
PDFlibPas phơi bày việc sửa page tree, sao chép liên tài liệu và điều khiển page box qua một class TPDFlib duy nhất cho Delphi, C++Builder và Free Pascal. Xem trang sản phẩm thư viện PDFlibPas Delphi PDF để biết các phiên bản, nền tảng và toàn bộ tài liệu tham khảo API