Bài viết kỹ thuật

Chỉnh sửa đại cương PDF và ánh xạ lại trang trong Delphi

Bỏ bảy trang khỏi một cuốn cẩm nang 200 trang và mọi dấu trang sẽ rơi vào chỗ sai. Cách khắc phục không phải dựng lại đại cương từ một danh sách tiêu đề phẳng. PDFiumPas cung cấp TPdfOutlineEditor, thứ nạp cây đại cương thật, cho bạn di chuyển và đổi đích các mục, rồi chạy ApplyPageMap để chuyển mọi đích tường minh qua kế hoạch trang của bạn

Vì sao xóa trang lại làm hỏng mọi dấu trang?

Vì mục đại cương không lưu số trang. Nó lưu một tham chiếu tới page object, và khi các page object đổi, tham chiếu hoặc trỏ vào một trang đã dời chỗ hoặc trỏ vào không gì cả. ISO 32000-1 §12.3.2.2 định nghĩa đích tường minh là một mảng mà phần tử đầu là tham chiếu gián tiếp tới page dictionary, theo sau là một fit name như /Fit hay /XYZ. Xóa trang đi, bạn còn lại một tham chiếu đuối; đổi thứ tự trang, tham chiếu vẫn hợp lệ nhưng giờ mô tả một chương khác. PDFiumPas phân giải mảng đó về số trang lúc nạp, nên TPdfOutlineItem.PageNumber cho bạn chỉ số trang bắt đầu từ 1 khớp với API công khai TPdf thay vì số object. Đó là toàn bộ ý nghĩa của lớp trừu tượng này: logic ánh xạ lại của bạn làm việc trong cùng hệ tọa độ với kế hoạch trang bạn đã dựng khi tách, đổi thứ tự hay cấn trang tài liệu. Nếu bạn đang dựng kế hoạch đó, quy ước bắt đầu từ 1 này chạy xuyên suốt tách tài liệu PDF thành nhiều tệpcấn trang n-up và đổi thứ tự trang

Đại cương là một cây liên kết đôi, không phải một danh sách

Lý do bạn không thể cứ thế serialize một mảng tiêu đề phẳng là ISO 32000-1 §12.3.3 mắc mỗi mục đại cương vào năm liên kết riêng: /Parent, /Prev, /Next, /First/Last. Dời một subtree duy nhất vì thế phải viết lại parent cũ, parent mới, cả hai sibling láng giềng ở mỗi bên của điểm cắt và điểm chèn, cùng con trỏ parent của chính node được dời. Sai một trong số đó, các reader tuân thủ sẽ hiện cây bị cụt, hoặc lặp vòng. PDFiumPas giữ trạng thái chỉnh sửa như một mảng duyệt depth-first gồm các bản ghi TPdfOutlineItem với Id nguyên ổn định, nên một subtree là một lát liền kề và chuỗi sibling được suy ra chứ không giữ tay. TPdfOutlineEditor.Move nhấc lát đó lên, chèn lại dưới parent mới tại vị trí sibling được yêu cầu, và gán lại chỉ root của khối. Nó cũng từ chối hai kiểu di chuyển sẽ làm hỏng đồ thị: dời một mục vào chính subtree của nó, và khai báo một parent không tồn tại

Chỉnh sửa đại cương PDFiumPas trong Delphi: dời Chương 3 ra khỏi Phần I và về dưới root tài liệu viết lại con trỏ /Parent của node được dời cùng các liên kết /First và sibling /Prev, /Next xung quanh cả điểm cắt lẫn điểm chèn
Một lời gọi Move viết lại con trỏ parent của subtree được nhấc lên và các liên kết sibling ở cả hai bên của điểm cắt và điểm chèn

Vì sao /Count có dấu?

Vì dấu mang trạng thái mở rộng, không phải kích thước. /Count dương nghĩa là mục đang mở và con số là bao nhiêu hậu duệ hiện đang hiển thị; /Count âm nghĩa là mục đã thu gọn. PDFiumPas ghi số hậu duệ cho mọi mục có con và đảo dấu khi IsOpenFalse, và lúc nạp đọc trạng thái ngược lại thành IsOpen := HasCount and (CountValue > 0). Đây là bug tự viết tay phổ biến nhất trong các bộ viết đại cương: phát một count không dấu và lặng lẽ ép cả cây mở ra

PDFiumPas mã hóa trạng thái mở rộng đại cương trong Delphi thế nào: /Count dương nghĩa là mục đang mở và đếm các hậu duệ hiển thị, /Count âm nghĩa là thu gọn, còn count không dấu ép mọi reader mở cả cây
Dấu của /Count là trạng thái mở rộng và độ lớn là số hậu duệ hiển thị, nên count không dấu lặng lẽ ép cả cây mở ra
var
  Source, Dest: TMemoryStream;
  Editor: TPdfOutlineEditor;
  Options: TPdfOutlineEditOptions;
  Report: TPdfOutlineValidationReport;
  RootId, ChapterId: Integer;
begin
  Source := TMemoryStream.Create;
  Dest := TMemoryStream.Create;
  Editor := nil;
  try
    Source.LoadFromFile('handbook.pdf');
    Options := TPdfOutlineEditOptions.Default;   // MaxItems 100000, MaxDepth 64
    if not TPdfOutlineEditor.TryLoad(Source, Options, Editor, Report) then
      raise Exception.Create(Report.ErrorMessage);

    RootId := Editor[0].Id;
    ChapterId := Editor[2].Id;

    Editor.Move(ChapterId, RootId, 1);           // trở thành con thứ hai của root
    Editor.SetTitle(ChapterId, 'Appendix B');
    Editor.SetStyle(ChapterId, [posBold, posItalic]);
    Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
    Editor.SetExpanded(RootId, False);           // ghi một /Count âm
    Editor.Retarget(ChapterId, 12, '/XYZ 10 20 1');

    if not Editor.SaveIncremental(Source, Dest, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Dest.SaveToFile('handbook-edited.pdf');
  finally
    Editor.Free;
    Dest.Free;
    Source.Free;
  end;
end;

Retarget xử lý cả hai hình dạng mà đặc tả cho phép. Truyền DestinationInActionFalse, PDFiumPas ghi một mảng /Dest trực tiếp; truyền True, nó ghi một action Go-To, /A << /S /GoTo /D [ page ref suffix ] >>, theo ISO 32000-1 §12.6.4.2. Dù kiểu nào, nó cũng xóa trước mọi /Dest/A tồn tại khỏi mục để hai thứ không thể cùng tồn tại rồi mâu thuẫn. Hậu tố mặc định là /Fit và phải bắt đầu bằng một PDF name, vì vậy hậu tố rỗng hay dị dạng sẽ raise ngay lập tức thay vì tạo ra một mảng đích mà không reader nào parse nổi

ApplyPageMap tiêu thụ một kế hoạch trang thế nào?

ApplyPageMap nhận đúng cái mảng mà kế hoạch trang của bạn đã xác thực: NewPageNumbers, đánh chỉ số theo trang cũ trừ một, giữ số trang mới bắt đầu từ 1 hoặc số 0 khi trang đó không sống sót. Nó duyệt mảng mục ngược từ cuối để việc xóa một subtree không bao giờ làm vô hiệu một chỉ số nó chưa đến, và nó báo cáo những gì đã làm qua RemappedDestinationCountRemovedDanglingItemCount

var
  NewPageNumbers: array of Integer;
  Report: TPdfOutlineValidationReport;
  I: Integer;
begin
  // Một entry cho mỗi trang của tài liệu GỐC
  SetLength(NewPageNumbers, OriginalPageCount);
  for I := 0 to OriginalPageCount - 1 do
    NewPageNumbers[I] := 0;              // 0 == trang này đã bị bỏ

  NewPageNumbers[0] := 1;                // trang cũ 1 -> trang mới 1
  NewPageNumbers[1] := 2;
  NewPageNumbers[9] := 3;                // trang cũ 10 -> trang mới 3

  // True: xóa cả subtree đuối. False: giữ mục, tước đích của nó
  if not Editor.ApplyPageMap(NewPageNumbers, True, Report) then
    raise Exception.Create(Report.ErrorMessage);

  WriteLn(Format('%d remapped, %d dangling items removed',
    [Report.RemappedDestinationCount, Report.RemovedDanglingItemCount]));
end;

Cờ DeleteDangling quyết định chính sách cho một đích được map về số 0, và cả hai nhánh đều là lựa chọn chủ đích. Với True, PDFiumPas xóa mục cùng toàn bộ subtree của nó, vì một node đại cương mà đích đã biến mất thường dẫn đầu một chương biến mất theo. Với False, mục sống sót với tiêu đề và phân cấp nguyên vẹn nhưng /Dest/A bị bỏ, đúng thứ bạn cần khi một người sẽ retarget nó trong khâu rà soát. Đầu vào thực sự dị dạng vẫn fail ầm ĩ thay vì được vá: một phần tử âm hay một đích trỏ quá cuối map đã cung cấp trả về False với IssueKind đặt thành poviInvalidPageMap

ApplyPageMap của PDFiumPas chuyển hướng dấu trang PDF trong Delphi thế nào: page map đánh chỉ số theo trang cũ trừ một đưa các đích sống sót tới số trang mới, còn các phần tử map về số 0 hoặc bị xóa cùng subtree hoặc bị tước đích
Page map được đánh chỉ số theo trang cũ trừ một, và phần tử 0 hoặc xóa subtree đuối hoặc để lại mục với đích đã bị tước

Các mục mờ đục, và sự đánh đổi được nói thẳng

Không phải mục đại cương nào cũng có số trang mà PDFiumPas suy luận được. Ba loại được giữ nguyên trạng đưa qua: named destination, action không phải /S /GoTo, và các dictionary key không rõ do bất cứ thứ gì sinh ra tệp thêm vào. Chúng được nạp với PageNumber bằng 0, giữ nguyên byte gốc trong mục, và được ghi lại nguyên văn trừ khi bạn gọi rõ ràng Retarget lên chúng

  • Named destination là một key vào name tree của tài liệu, nên ánh xạ lại đúng nghĩa là phân giải cây đó và viết lại entry đích, không phải đoán ở cấp đại cương
  • Action /URI, /Launch hay JavaScript không có ngữ nghĩa trang nào cả và không được lặng lẽ chuyển thành Go-To
  • Các key đặc thù của nhà cung cấp và structure destination được bảo toàn, vì việc vứt bỏ thứ bạn không hiểu chính là cách round-trip làm mất dữ liệu

Cái giá là thật và đáng nói thẳng: ApplyPageMap bỏ qua hoàn toàn các mục đó, nên một tài liệu mà mọi dấu trang đều dùng named destination sẽ đi qua một lần xóa trang với đại cương hợp lệ về cấu trúc nhưng lỗi thời về ngữ nghĩa. Đó là lựa chọn chủ đích — một liên kết lỗi thời mà người rà soát bắt được còn hơn một liên kết sai đầy tự tin mà không ai để ý. Nếu bạn phân loại tệp đến trước khi chỉnh sửa, một lượt kiểm kê trong PDF intake review workbench sẽ cho bạn biết tài liệu nào rơi vào nhóm đó

Lưu: revision tăng dần, rồi một lần nạp lại độc lập

TPdfOutlineEditor.SaveIncremental nối thêm một revision tăng dần thưa thay vì viết lại tệp. Các mục đã được nạp giữ nguyên tham chiếu indirect object gốc kể cả generation chính xác, nên các cross-reference hiện hữu vẫn hợp lệ; chỉ những mục bạn thêm mới lấy số mới, cấp phát từ một đơn vị sau số object lớn nhất của revision. Catalogue được cập nhật trong cùng revision đó, và entry /Outlines thiếu sẽ được thêm vào khi nguồn vốn không có đại cương

Chuyện gì xảy ra sau khi ghi mới là phần đáng copy. PDFiumPas mở lại stream đích bằng một editor hoàn toàn độc lập và so cây nạp lại với cây trong bộ nhớ — số mục, tiêu đề, số trang, hậu tố đích, dạng action hay đích trực tiếp, style, trạng thái mở rộng, và quan hệ parent. Bất kỳ lệch nào, hay bất kỳ lỗi nạp nào, đều xóa sạch stream đích và trả về poviVerificationFailure thay vì đưa bạn một tệp nhìn có vẻ hợp lý. Nguồn mã hóa bị từ chối ngay từ đầu với poviEncryptedInput, vì tiêu đề và đích mới tạo nội dung string mà không thể tạo ra bằng cách copy /Encrypt trailer về phía trước

if not Editor.SaveIncremental(Source, Dest, Report) then
  case Report.IssueKind of
    poviEncryptedInput:
      Log('Source is encrypted; outline editing needs an unprotected copy');
    poviInvalidDestination:
      Log(Format('Item %d %d targets a missing page',
        [Report.ObjectNumber, Report.Generation]));
    poviVerificationFailure:
      Log('Reload check rejected the written revision: ' + Report.ErrorMessage);
  else
    Log(Report.ErrorMessage);
  end;

Hãy đối xử với đại cương như nó vốn vậy — một đồ thị object liên kết với các bất biến riêng — và việc xóa trang thôi là thảm họa dấu trang, trở thành một page map bạn đưa cho một lời gọi method. TPdfOutlineEditor, ApplyPageMap, và bộ ghi tăng dần đã kiểm chứng có trong PDFiumPas từ v3.98.0 cho Delphi, C++Builder và Lazarus; bạn có thể xem toàn bộ API và tải bản dùng thử trên trang sản phẩm PDFium Delphi Component