Bài viết kỹ thuật

Xóa trang PDF trong Delphi không để lại tham chiếu treo

HotPDF Delphi Component xóa một trang khỏi PDF đã nạp qua THotPDF.DeletePage, và từ phiên bản 2.751.0, lệnh gọi đó còn dọn sạch mọi tham chiếu ở tầng tài liệu vẫn còn trỏ vào trang đó: named destination trong cây /Names /Dests, dictionary /Dests cũ trong catalog, các hành động /GoTo của bookmark, structure element dưới /StructTreeRoot, ParentTree, các entry OBJR cho annotation, và link annotation trên những trang còn lại. Cây trang được dựng lại cuối cùng, sau khi không còn thứ gì chạm tới được object đã xóa

Kiểu hỏng hóc mà điều này ngăn được thì dễ tái hiện mà khó chẩn đoán. Xóa trang bìa của một báo cáo có tag, lưu lại, rồi mở kết quả: Acrobat hiển thị đúng số trang, nhưng bookmark “Contents” giờ chẳng dẫn tới đâu, trình kiểm tra khả năng truy cập báo một structure element không có trang, và một validator khắt khe liệt kê một tham chiếu tới object đã free. Không có gì trong cây trang sai cả. Vấn đề là một trang PDF không chỉ là một lá của /Pages; nó là một đích mà nửa catalog trỏ vào, và bỏ chiếc lá đó đi sẽ để mọi con trỏ kia treo lơ lửng

Vì sao chỉ bỏ trang khỏi /Kids là chưa đủ?

Vì ISO 32000-1 cho phép ít nhất bảy cấu trúc độc lập cùng giữ tham chiếu tới một object trang, và chỉ một trong số đó là cây trang. Bỏ trang khỏi /Kids rồi giảm /Count là thỏa §7.7.3, còn mọi tham chiếu khác trở thành con trỏ tới một object hoặc đã bị free trong xref hoặc đơn giản là không còn trong tệp được ghi lại. Một trình xem đi theo một trong những con trỏ đó sẽ nhận null, và nó làm gì với giá trị null ấy là chuyện của trình xem

  • Cây tên dưới /Names /Dests (§7.7.4, §12.3.2.3) ánh xạ tên sang các mảng destination mà phần tử đầu tiên là trang
  • Dictionary /Dests kiểu trước 1.2 nằm ngay trong catalog giữ cùng loại mảng đó với khóa là tên
  • Các mục outline (§12.3.3) tới được một trang hoặc qua /Dest nội tuyến hoặc qua hành động /A với /S /GoTo và một mảng /D
  • Các structure element (§14.7.2) mang khóa /Pg nêu trang mà marked content của chúng nằm trên đó, và các con /K của chúng có thể là marked-content reference cùng object reference (§14.7.4.3) gắn với trang đó
  • ParentTree (§14.7.4.4) ánh xạ các số /StructParents của trang và annotation ngược về structure element, và một element có thể nằm ở đó mà không hề xuất hiện trên chuỗi /K từ root
  • Link annotation trên những trang khác (§12.5.6.5) mang /Dest hoặc hành động /GoTo nhắm vào trang đó, và /OpenAction của catalog cũng có thể làm điều tương tự
Vì sao bỏ một trang HotPDF khỏi /Kids là chưa đủ: ISO 32000-1 cho phép cây tên /Names /Dests, dictionary /Dests cũ trong catalog, các mục outline, structure element mang /Pg, ParentTree, link annotation và /OpenAction cùng giữ tham chiếu tới một object trang, trong khi chỉ cây trang được dựng lại
Một trang PDF là đích mà nửa catalog trỏ vào: bỏ chiếc lá đó làm hài lòng cây trang trong khi mọi con trỏ khác phân giải thành null, nên một báo cáo đã cắt bớt trang sẽ mất bookmark Contents và trượt phép kiểm tra khả năng truy cập

THotPDF.DeletePage dọn gì trước khi chạm vào cây trang?

THotPDF.DeletePage(PageIndex) trên một tài liệu đã nạp chạy toàn bộ lượt quét tham chiếu trước, rồi đánh dấu object trang là đã xóa bằng DeleteObj, gỡ mọi widget annotation khỏi cây trường AcroForm, dịch mảng trang nội bộ, và cuối cùng gọi RebuildLoadedPageTree để ghi lại /Kids, /Count cùng /Parent của từng trang còn lại. Lượt quét đi qua catalog theo một thứ tự cố định: cây tên /Names /Dests, dictionary /Dests kiểu cũ, /OpenAction, cây outline, /StructTreeRoot cùng ParentTree của nó, và cuối cùng là mảng /Annots của mọi trang còn lại. Mỗi bước quyết định một tham chiếu bị gỡ, bị trỏ sang chỗ khác, hay được để yên, tùy theo đặc tả cho phép cấu trúc đó làm gì khi thiếu trang. Hai chốt canh được áp trước khi bất cứ bước nào chạy: DeletePage raise Invalid page number với chỉ số ngoài khoảng và từ chối xóa trang cuối cùng, vì một node /Pages không có con nào là PDF không hợp lệ; còn DeletePages nhận đúng cú pháp "1,3-5,7-" gốc một như các thao tác trang khác trên tài liệu đã nạp, và duyệt từ chỉ số được chọn cao nhất xuống để những chỉ số bạn viết vẫn còn đúng trong lúc nó làm việc

Lượt quét tham chiếu cố định mà THotPDF.DeletePage chạy trước khi chạm vào cây trang: các chốt canh từ chối chỉ số ngoài khoảng hoặc trang cuối cùng, rồi /Names /Dests và /Dests kiểu cũ bị dọn, /OpenAction bị bỏ, outline được trỏ lại về NearestRetainedPage, StructTreeRoot và ParentTree bị dọn, link trên các trang còn lại bị gỡ, và RebuildLoadedPageTree chạy cuối cùng
Mỗi cấu trúc nhận đúng cách xử lý mà đặc tả cho phép: tên biến mất, bookmark đáp xuống trang còn lại gần nhất, structure element mất /Pg hoặc biến mất, và việc ghi lại /Kids chỉ diễn ra sau khi không còn thứ gì chạm tới được object đã xóa
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
    begin
      // Gốc không: bỏ trang bìa. Named destination,
      // bookmark, structure tree, ParentTree và các link
      // annotation từng trỏ vào nó đều bị dọn trước khi
      // cây /Pages được dựng lại.
      Pdf.DeletePage(0);
      // Cú pháp khoảng gốc một cho xử lý hàng loạt, chỉ số cao nhất trước
      // ở bên trong để các chỉ số nhỏ hơn vẫn còn đúng.
      Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Named destination và bookmark được xử lý khác nhau thế nào?

Named destination bị gỡ còn bookmark được trỏ sang chỗ khác, vì một cái tên không còn tồn tại là kết cục chấp nhận được, trong khi một bookmark không có đích đến là khuyết điểm nhìn thấy được. Trong cây /Names /Dests, HotPDF duyệt mọi node, đối chiếu từng destination — cả ở dạng mảng trần lẫn dạng dictionary có khóa /D — với trang đã xóa, và gỡ cặp tên/giá trị khi phần tử đầu tiên của mảng chính là trang đó. Một node mà cả /Names lẫn /Kids đều rỗng sẽ được đánh dấu đã xóa và gỡ khỏi cha của nó, nên cây không bao giờ giữ lại những chiếc lá rỗng ruột. Phép kiểm tra tương tự cũng chạy trên dictionary /Dests kiểu cũ trong catalog, còn /OpenAction của catalog chỉ đơn giản bị bỏ nếu nó mở đúng trang đã xóa. Có một ranh giới ở đây: khi một node của cây tên mất entry, HotPDF xóa cặp /Limits của node đó thay vì tính lại khóa thấp nhất và cao nhất mới, và dù các trình xem phân giải tên vẫn tốt khi thiếu nó, một trình kiểm tra tuân thủ khắt khe đọc ISO 32000-1 §7.9.6 có thể đánh dấu một node không phải root mà thiếu /Limits

Các mục outline thì đi theo hướng ngược lại. RetargetOutlineDestinations duyệt /First và /Next từ root của outline, kèm một danh sách đã ghé thăm và giới hạn độ sâu 128 để một cây có chu trình hỏng không treo được lệnh gọi, và với mọi mảng /Dest hay mảng /D của hành động /GoTo nhắm vào trang đó, nó thay phần tử đầu tiên bằng NearestRetainedPage: trang đứng sau trang đã xóa, hoặc trang đứng trước nó khi trang bị xóa là trang cuối. Các tham số khung nhìn đứng sau tham chiếu trang được giữ nguyên. Thành ra một bookmark từng trỏ vào trang mở đầu chương đã bị xóa sẽ đáp xuống trang đầu tiên của phần còn lại thay vì biến mất khỏi thanh bên, và đó là hành vi mà người soát xét kỳ vọng ở một tài liệu đã cắt bớt trang. Tuy vậy, phép kiểm tra destination chỉ khớp các mảng tường minh: một mục outline có /Dest là chuỗi tên vốn phân giải tới trang đã xóa sẽ không được trỏ sang chỗ khác, vì entry trong cây tên đã biến mất và tham chiếu giờ phân giải thành không có gì chứ không phải thành một object đã free, nên trình xem coi nó là bookmark chết. Cơ chế của chính cây outline, tức /First, /Next, và ngữ nghĩa /Count không hiển nhiên chút nào, được trình bày trong hướng dẫn thêm bookmark và named destination vào PDF đã nạp

// Kiểm chứng lượt quét thay vì tin vào nó.
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  ShowMessage('Named destination "cover" was pruned');
// Một bookmark từng nhắm vào trang bìa giờ phân giải tới
// trang đứng sau nó (chỉ số gốc không là 0 sau khi xóa).
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
  ShowMessage('Bookmark retargeted to the nearest retained page');

Điều gì xảy ra với structure tree và ParentTree?

Những structure element tồn tại chỉ vì trang đã xóa sẽ bị gỡ, còn những element trải trên nhiều trang thì mất khóa /Pg nhưng giữ nguyên các con. PruneStructureElement đi xuống theo chuỗi /K từ /StructTreeRoot tới độ sâu 128, xử lý cả dạng mảng lẫn dạng dictionary đơn của /K mà §14.7.2 cho phép. Với từng element, nó dọn các con trước, rồi đánh giá chính element đó: nếu việc dọn làm /K rỗng thì element bị đánh dấu đã xóa và cha của nó bỏ nó đi. Nếu /Pg của chính element nêu trang đã xóa mà element vẫn còn con cùng một cha /P, thì chỉ /Pg bị gỡ, vì /Pg trên một element là trang mặc định cho các con marked content của nó và những đứa con ấy có thể tham chiếu tới trang khác một cách tường minh. Chỉ element nào có /Pg là trang đã xóa và không còn gì bên dưới mới bị gỡ hẳn

ParentTree cũng được xử lý y như vậy, và lý do chính là chuyện đã cắn trong lúc phát triển: một structure element có thể tới được từ ParentTree mà không tới được từ chỗ nào khác. Cây number ánh xạ các số nguyên /StructParents sang hoặc một element duy nhất hoặc một mảng element, và PruneParentTreeNode chạy PruneStructureElement trên mọi giá trị nó tìm thấy, gỡ những giá trị đã bị dọn đi, xóa một cặp /Nums khi mảng giá trị của nó rỗng, và tháo liên kết một node mà cả /Nums lẫn /Kids đều đã biến mất. Nếu chỉ dọn các hậu duệ của /K thì những element mồ côi ấy sẽ còn trỏ vào một trang đã free qua /Pg và vào những marked-content reference đã free qua các con /MCR của chúng. Nếu bạn trích văn bản theo thứ tự cấu trúc thì điều này ảnh hưởng trực tiếp: trích văn bản theo thứ tự cấu trúc đi đúng qua những cây này, và một element có /Pg null chính là một đoạn văn âm thầm rơi khỏi thứ tự đọc

Những link annotation nào trên các trang còn lại bị gỡ?

Mọi link annotation trên một trang còn lại mà mảng /Dest hay hành động /GoTo của nó trỏ vào trang đã xóa đều bị gỡ cùng với quyền sở hữu của nó trong structure tree. RemoveRetainedPageDestinationAnnotations duyệt mảng /Annots của mọi trang không phải đích, áp đúng phép kiểm tra destination dùng cho outline, đánh dấu annotation khớp là đã xóa, bỏ nó khỏi mảng, rồi gọi PruneAnnotationReferencesInStructureTree để dictionary OBJR có /Obj nêu annotation đó bị gỡ khỏi structure element của nó, và bản thân element cũng bị gỡ nếu OBJR là đứa con duy nhất. Để nguyên OBJR tại chỗ sẽ vi phạm §14.7.4.3, điều khoản buộc /Obj phải tham chiếu một object đang tồn tại, và sẽ hiện ra trong một phép kiểm tra PDF/UA như một liên kết có tag mà không có annotation nào đằng sau. Hãy để ý sự bất đối xứng với bookmark: link bị gỡ chứ không được trỏ sang chỗ khác. Một câu tham chiếu chéo trong thân văn bản kiểu “xem trang 3” là sai một khi trang 3 không còn, và trỏ nó vào trang 4 sẽ là một lời nói dối theo cách mà một bookmark đáp xuống chương gần nhất thì không, nên nếu quy trình của bạn cần giữ những link đó, hãy tự trỏ chúng sang chỗ khác trước khi gọi DeletePage

Vì sao một /MCR hay /OBJR đã bị gỡ không bao giờ được đăng ký là free?

Vì marked-content reference và object reference thường là dictionary trực tiếp nằm trong mảng /K của element cha, còn sổ đăng ký thay đổi tăng dần lại phân giải một object trực tiếp về object gián tiếp gần nhất chứa nó. Khi RemoveArrayItem bỏ một con khỏi mảng /K, nó chỉ free object trong bộ nhớ nếu đó là một THPDFLink hoặc một giá trị không gián tiếp, và MarkRemovedObject chỉ đăng ký một object vào danh sách free khi số object của nó lớn hơn không. Phiên bản đầu của lượt quét này không phân biệt như vậy, và hậu quả trong một lần lưu tăng dần đúng là thứ mà sổ đăng ký được thiết kế để làm: RegisterIncrementalChange đi từ /MCR trực tiếp lên tới root giao dịch đồ thị của nó, tức structure element còn lại đang sở hữu nó, và ghi element đó ra thành null. Một tài liệu mất đi một trang quay về với nội dung có tag trên các trang khác bị mất tag trong im lặng. Nước đi đúng duy nhất với một con trực tiếp là đánh dấu container của nó là bẩn qua TouchContainer để container được ghi lại, và để yên danh sách free

Vì sao một con /MCR hay OBJR đã bị gỡ không bao giờ được đăng ký là free trong HotPDF: sổ đăng ký thay đổi tăng dần phân giải một dictionary trực tiếp về container gián tiếp gần nhất, nên phiên bản đầu ghi structure element còn lại ra thành null và làm mất tag các trang sống sót trong im lặng, còn TouchContainer giờ ghi lại container và để yên danh sách free
Việc free đứa con trong bộ nhớ được dành riêng cho THPDFLink hoặc các giá trị không gián tiếp và cho những số object lớn hơn không, nên một lần lưu tăng dần chỉ nối thêm các container bị đụng tới và object trang đã được free
// Cập nhật tăng dần: chỉ các container bị đụng tới và
// object trang đã free rơi vào phần được nối thêm.
Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // Các structure element còn lại mà /K mất một /MCR trực tiếp
  // được ghi lại tại chỗ, không bao giờ bị ghi thành null.
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

Chính sự thận trọng đó định hình những gì DeletePage cố ý không free trên một tài liệu đã nạp. Content stream, XObject và các annotation không phải widget của trang đã xóa đều được để lại như những object, vì một tệp đã nạp có thể chia sẻ bất kỳ thứ nào trong số đó với một trang còn ở lại và không có cách nào rẻ để chứng minh điều ngược lại ở thời điểm xóa. Gỡ tham chiếu trong cây trang là đủ cho tính đúng đắn; số byte mà những object đó còn chiếm là một câu hỏi riêng, và đồ thị phụ thuộc object cùng phân tích số byte còn giữ chính là công cụ để đo xem một tài liệu đã cắt bớt trang còn mang theo những gì

DeletePage so với DeleteLoadedPage: nên gọi cái nào?

Hãy gọi DeletePage cho mọi thao tác xóa trang hướng tới người dùng, và để dành DeleteLoadedPage cho trường hợp cả tài liệu đang được dàn lại và không có tham chiếu nào ở tầng tài liệu đáng giữ. THotPDF.DeleteLoadedPage(PageIndex), được thêm từ phiên bản 2.508.0, là biến thể nhẹ: nó dịch mảng trang nội bộ, gọi RebuildLoadedKidsArray để ghi lại /Kids cùng /Count, vô hiệu hóa cache trang đã render, và phát OnLoadedDocumentModified. Nó không duyệt cây tên, outline, structure tree hay annotation của các trang khác, và không đánh dấu object trang là đã xóa. Đó mới là công cụ đúng bên trong việc ghép trang kiểu N-up, nơi HotPDF thêm vào những tờ vừa dàn xong rồi bỏ mọi trang gốc bằng DeleteLoadedPage(0): các trang nguồn đang bị thay thế toàn bộ, và nội dung của tờ mới tham chiếu tới resource của chúng chứ không tham chiếu tới các object trang. Với công việc thường ngày kiểu “bỏ trang 7 khỏi hợp đồng này”, DeletePage là lệnh gọi duy nhất để lại một tài liệu có tag, có bookmark, có tham chiếu chéo đủ nhất quán để vượt qua một validator, cả khi ghi lại toàn bộ qua SaveLoadedDocument lẫn khi cập nhật tăng dần qua SaveIncrementalUpdate. Cả hai phương thức đều có trong HotPDF Delphi Component cho Delphi và C++Builder, không cần runtime trình xem bên ngoài hay phụ thuộc nào khác