Bài viết kỹ thuật

Sao chép đối tượng PDF giữa tài liệu trong Delphi: lỗi vòng

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

Vì sao sao chép PDF giữa tài liệu trong Delphi cần cẩn thận: dictionary trang và nút /Pages của nó khép một vòng qua /Parent và /Kids, closure của font chạy xuống và kết thúc, và PDFlibPas đánh lại số mọi đối tượng có giá trị nội bộ tệp
Cây trang khép một vòng qua /Parent và /Kids trong khi các closure nội dung kết thúc được, và mọi số đối tượng được sao chép phải được ánh xạ lại trên đường đi

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?

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ó

Vì sao đặt trước một đích map Nil không chặn được chu kỳ trong bản sao giữa tài liệu của PDFlibPas: phép tra cứu không phân biệt được entry đặt trước với entry chưa map, nên bộ sao chép đi xuống qua những frame dở dang ngày càng sâu cho đến khi một phép ghi crash
Vì một đích Nil trả lời cùng lúc hai câu hỏi khác nhau, cạnh ngược không bao giờ được nhận diện và trang bị nhân bản lại ở mỗi lượt

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 đó

Cách sửa trong CopyForeignObject của PDFlibPas cho Delphi: phép kiểm tra InProgress tường minh chạy trước phép tra cứu map, một cạnh ngược có chu kỳ trở thành đối tượng null, và caller nối lại trang đã sao chép vào cây trang đích về sau
Một trạng thái in-progress tường minh thay thế Nil nhiều nghĩa, nên cạnh ngược được phân giải thành null và caller còn lại đúng một chỗ sửa cấu trúc phải làm

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