Ghép hai tệp PDF bằng tay, chuyển một đối tượng trang đơn lẻ sang tài liệu đích, và ngay lập tức phần sao chép lao thẳng vào access violation. PDFlibPas xử lý chuyện này trong CopyForeignObject: nó sao chép sâu một đối tượng indirect cùng toàn bộ closure tham chiếu của nó, và phân giải các tham chiếu ngược có chu kỳ như /Parent thành null thay vì đệ quy
Vì sao sao chép một trang giữa các tài liệu lại crash?
Vì cây trang PDF chỉ là cây nếu bạn đọc nó theo chiều đi xuống. Đi theo cách một bộ sao chép đệ quy làm, lần theo mọi giá trị trong mọi dictionary, thì dictionary trang đưa cho bạn /Parent, vốn trỏ ngược về nút /Pages mà bạn vừa đến từ đó, và nút đó đưa cho bạn /Kids, lại trỏ ngược về trang. ISO 32000-1 §7.7.3 bắt buộc /Parent phải có trên mọi nút cây trang trừ gốc, nên đây không phải một tệp dị dạng nào bạn có thể từ chối — đó là hình dạng bình thường của mọi tài liệu bạn từng được đưa
Nửa còn lại của vấn đề là đánh số. Đối tượng indirect được nhận diện bằng một số đối tượng chỉ có giá trị nội bộ trong một tệp (ISO 32000-1 §7.3.10), nên một đối tượng kéo từ tài liệu A sang tài liệu B phải được đánh số lại, và mọi tham chiếu tới nó bên trong closure được sao chép phải đánh số lại theo cùng một cách, nếu không hai tham chiếu vốn cùng trỏ vào một font dùng chung giờ trỏ vào hai thứ chẳng liên quan. Việc đánh số lại đó chính là công việc mà một merge nhanh làm ở cấp byte, và đáng đọc cả hai cạnh nhau: dịch tham chiếu cấp byte cho merge PDF nhanh giải nó bằng cách dịch cả tệp, còn sao chép cấp đối tượng phải giải từng cạnh một
CopyForeignObject của PDFlibPas thực sự sao chép gì
TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) nhân bản một đối tượng indirect cùng mọi thứ với tới được từ nó — các dictionary lồng nhau, mảng, chuỗi, name, số, và stream kèm dictionary nguyên vẹn — vào tài liệu đang được chọn, và trả về một handle khác 0 trỏ tới tham chiếu indirect mới. Số đối tượng nguồn được ánh xạ lại qua một map sống trong suốt thời gian gọi, nên một đối tượng được tới hai lần trong closure sẽ được nhân bản một lần và dùng chung hai lần. Nó trả về 0 mà không raise khi ID tài liệu nguồn không tồn tại, khi nguồn chính là tài liệu đang chọn, hoặc khi ObjectNumber nhỏ hơn 1
var
Lib: TPDFlib;
SourceDoc, TargetDoc, Handle: Integer;
begin
Lib := TPDFlib.Create;
try
TargetDoc := Lib.NewDocument;
if Lib.LoadFromFile('source.pdf', '') <> 1 then
Exit; // LoadFromFile trả về 1 khi thành công
SourceDoc := Lib.SelectedDocument; // thao tác load đã chọn chính thứ nó vừa nạp
Lib.SelectDocument(TargetDoc); // sao chép nhắm vào tài liệu đang được chọn
Handle := Lib.CopyForeignObject(SourceDoc, 12);
if Handle = 0 then
raise Exception.Create('cross-document copy rejected');
finally
Lib.Free;
end;
end;
Hai chi tiết cắn người ngay lần chạy đầu. LoadFromFile trả lời 1 hoặc 0, không phải một ID tài liệu, nên handle bạn cần lấy từ SelectedDocument ngay sau khi nạp; và bản sao luôn ghi vào thứ SelectDocument lần cuối đặt làm hiện hành, không bao giờ ghi vào tài liệu mà bạn đã nạp từ đó. Bên trong, đệ quy còn mang một trần độ sâu cứng là 64, đây là tấm đệm chống lồng ghép bệnh lý, không phải cơ chế xử lý chu kỳ — xử lý chu kỳ là một phần riêng và có chủ đích
Vì sao đặt trước một mapping Nil không cắt được vòng?
Vì Nil trong bảng mapping mang cùng lúc hai ý nghĩa khác nhau, và code không thể phân biệt chúng. Phòng vệ hiển nhiên chống chu kỳ là thêm entry vào map trước khi đệ quy vào đối tượng, để bất cứ thứ gì vòng ngược lại đều tìm thấy entry và dừng. Nhưng entry chưa thể giữ đích thật — đích chưa tồn tại cho đến khi closure bên dưới nó được ghi xong — nên nó giữ Nil, và phép tra cứu đáng lẽ phải bắt được cạnh ngược lại đọc thấy Nil và kết luận đối tượng chưa từng được mapping
// Sai: một đích Nil đặt trước không phân biệt được với "chưa mapping"
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
SetLength(Map, Length(Map) + 1);
Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
Map[High(Map)].Target := nil; // đặt trước, vẫn là Nil
NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
Map[High(Map)].Target := NewRef; // chỉ được điền ngược lúc rút lui
end;
Đi theo điều đó qua vòng lặp trang. Bản nhân bản của trang với tới /Parent, đệ quy vào nút /Pages, nút này với tới /Kids, rồi đệ quy ngược lại vào trang — mà entry đặt trước của nó vẫn đọc là Nil, nên nó bị nhân bản lần thứ hai, lần thứ ba, mỗi tầng đẩy thêm một frame mới và một đối tượng dở dang mới. Điều bạn quan sát được cũng không phải một stack overflow gọn gàng: các frame ngoài đang ngồi trên những tham chiếu mà đích chưa bao giờ được gán, nên lần ghi đầu tiên qua một slot như vậy là một access violation ở một chỗ chẳng giống gì bản sao trang gây ra nó
Cách sửa: một trạng thái in-progress tường minh
Cách vá là ngừng chứa Nil nhiều nghĩa và hỏi thẳng câu hỏi đó. Một entry map mà đích vẫn chưa được gán nghĩa là đối tượng này đang được nhân bản dở, và một predicate InProgress kiểm tra đúng điều đó trước khi phép tra cứu thường chạy. Khi nó true, cạnh đó là một vòng quay ngược về tổ tiên của bản nhân bản hiện tại, và PDFlibPas phát một đối tượng null cho nó thay vì lần theo
// Entry map với đích Nil đánh dấu một bản nhân bản đang dở
function InProgress(Num: Integer): Boolean;
var
I: Integer;
begin
Result := False;
for I := 0 to High(Map) do
if (Map[I].SourceObjNum = Num) and (not Assigned(Map[I].Target)) then
Exit(True);
end;
// ... bên trong CloneObject, với một tham chiếu indirect:
if InProgress(SrcRef.ObjNum) then
Exit(FStructure.NewNull); // cạnh ngược có chu kỳ, không đệ quy
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
SrcInd := SourceDoc.FindObj(SrcRef.ObjNum, SrcRef.GenNum);
if (not Assigned(SrcInd)) or (not Assigned(SrcInd.Obj)) then
Exit(FStructure.NewNull); // tham chiếu nguồn bị treo
SetLength(Map, Length(Map) + 1);
Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
Map[High(Map)].Target := nil; // đặt trước, rồi mới đệ quy
NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
Map[High(Map)].Target := NewRef; // điền ngược
end;
Exit(NewRef);
Việc khái quát điều này an toàn chỉ nhờ một sự thật cấu trúc của PDF: chu kỳ trong đồ thị đối tượng xuất hiện trên các liên kết ngược, không phải trên các cạnh nội dung. /Parent trong cây trang và /Prev trong chuỗi outline trỏ lên trên hoặc ngược về thứ đã từng ghé; closure của một font, một image XObject, hay một form XObject chạy xuống và kết thúc. Vì thế, bản sao của một font descriptor, một color space, hay một shading dictionary không bị thay thế null ảnh hưởng — chẳng thứ gì trong các closure đó chạm InProgress cả. Cái giá, nói thẳng, là cạnh có chu kỳ không sống sót qua bản sao. Một dictionary trang được nhân bản theo cách này tới tay bạn với /Parent là một đối tượng null, mà ISO 32000-1 §7.3.9 coi là tương đương entry vắng mặt, nên trang được sao chép là một đối tượng hợp lệ nhưng không thuộc cây trang nào cho đến khi bạn tự nối nó vào nút /Pages đích và tự sửa /Count. Một mục outline được sao chép mất /Prev theo đúng cách đó và cần dựng lại chuỗi anh em. Đó là sự đánh đổi trung thực: CopyForeignObject đưa cho bạn một closure đúng và để việc gắn lại cấu trúc cha-con cho caller, cùng một ranh giới mà thay trang nhưng giữ nguyên số đối tượng hoạt động trong đó
Vì sao entry map phải được đặt trước khi gọi NewObjRef
Một phương án hiển nhiên khác sẽ né cả màn múa in-progress: cấp phát trước một đối tượng vỏ rỗng, đăng ký số thật của nó vào map, rồi đổ đầy cái vỏ khi các con đã được nhân bản. Cách đó không dùng được ở đây, vì TPDFIndObj.Obj là read-only và nội dung không thể thay sau khi dựng — không có cái vỏ để đổ. Số và nội dung được quyết định cùng nhau bởi NewObjRef, nghĩa là entry map phải được tạo trước lời gọi đệ quy và hoàn tất sau đó, và khoảng giữa hai thời điểm đó chính là thứ InProgress phải che phủ. Một hệ quả đáng biết trước khi bạn diff đầu ra: vì NewObjRef chạy sau khi closure con đã được ghi, đánh số trong tài liệu đích ra theo kiểu từ dưới lên, và số đối tượng sẽ không soi gương theo thứ tự nguồn. Định dạng tệp không quan tâm điều đó, nhưng một phép so sánh byte với kết quả mong đợi dệt tay thì có. Nếu một lượt chạy để lại những đối tượng bạn quyết định không nối vào đâu, chúng là không được tham chiếu chứ không hỏng, và thu gom mark-and-sweep các đối tượng PDF không tới được là công cụ dọn chúng trước khi lưu
Bài regression che phủ điều này cần một chi tiết khiến những ai viết test cho TPDFlib ngạc nhiên: constructor đã giữ sẵn một tài liệu mặc định, nên DocumentCount khởi đầu là 1 và một fixture hai tài liệu phải assert >= 2 chứ không phải = 2. Cạnh bản sao thành công, test ghim chặt ba dạng từ chối — ID nguồn không tồn tại, tài liệu đang chọn làm nguồn của chính nó, và số đối tượng bằng 0 — đều trả về 0 thay vì raise, vì một vòng lặp merge là chỗ tồi tệ để phát hiện ra guard clause văng exception
Chỗ của nó trong một pipeline merge
Sao chép cấp đối tượng là thao tác gốc bạn chạm tới khi merge cả tệp thô ráp quá: nhấc một chương trình font ra khỏi một template, kéo một form XObject duy nhất vào tài liệu đóng dấu, hoặc đưa một annotation cùng các appearance stream của nó sang tệp khác mà không lôi theo phần còn lại của trang. PDFlibPas mở nó ra như một lời gọi duy nhất trên các tài liệu đã nạp, và bạn có thể thấy nó đứng cạnh phần còn lại của API đối tượng bậc thấp trong tài liệu tham khảo PDFlibPas Delphi PDF Library