Bài viết kỹ thuật

Đọc hành động bookmark và chú thích PDF trong Delphi

Bạn thừa hưởng một thư mục PDF từ đâu đó trong chuỗi xử lý, và nhiệm vụ nghe có vẻ rất đơn giản: cho biết bookmark nào nhảy tới một URL bên ngoài, bookmark nào chạy JavaScript, và các bookmark nội bộ thực sự đi tới đâu. Đến khi mở tài liệu API, bạn mới phát hiện thư viện có thể tạo ra mọi loại hành động đó nhưng lại không cho đọc ngược lại. Sự lệch pha này xuất hiện khắp nơi trong công cụ PDF. Viết một bookmark mở https://example.com chỉ là một dòng; hỏi một bookmark hiện có "bạn làm gì, và trỏ tới đâu" thường đồng nghĩa phải lần theo cây đối tượng thô qua /A, /S, /Dest và cả một loạt biến thể fit-type mà hầu như chẳng ai làm đúng ngay lần đầu

PDFlibPas là thư viện PDF Object Pascal gốc cho Delphi và C++Builder, và trong một thời gian dài nó cũng có cùng khoảng trống: bộ setter phía ghi rất đầy đủ, còn getter thì chỉ trả về một TPDFObject trần rồi để bạn tự mò. Bản phát hành v3.77.0 đã khép bớt khoảng trống đó bằng một nhóm lời gọi introspection có kiểu rõ ràng, trả về loại hành động, payload của hành động và hình học của destination dưới dạng các record thuần. Bài viết này nói về cách những lời gọi đó ánh xạ sang mô hình action và destination của ISO 32000-1, cùng ba cái bẫy rất thực tế khiến việc tự viết lại phần này dễ âm thầm sai lệch

Vì sao đọc action khó hơn viết chúng

Một action trong PDF là một dictionary có key /S để đặt tên cho subtype của nó: GoTo, GoToR, URI, Launch, Named, JavaScript, và một danh sách dài hơn mà bạn ít khi gặp (ISO 32000-1 §12.6.4). Vấn đề là payload nằm ở một key khác nhau cho từng subtype, và không có một ô chung kiểu "hãy cho tôi target". Action URI giữ địa chỉ trong /URI. Action GoToR hoặc Launch giữ file specification trong /F. Action JavaScript giữ script trong /JS, và nó có thể là chuỗi hoặc stream. Action GoTo lại không mang payload riêng nào cả; target của nó là một destination nằm ở /D, rồi sau đó bạn phải tự resolve riêng

Khi bạn ghi một action, bạn biết sẵn loại của nó, nên tất cả những thứ này không đáng kể. Khi bạn đọc một action, trước hết phải rẽ nhánh theo /S, sau đó đi vào đúng key, rồi xử lý thực tế là cùng một khái niệm logic "thứ mà action này trỏ tới" lại được mã hóa theo ba cách không tương thích với nhau. Chính nhánh rẽ đó là thứ các getter có kiểu hấp thụ. GetOutlineActionInfoGetAnnotActionInfo đều trả về một record TPDFlibActionInfo:

type
  TPDFlibActionKind = (akNone, akGoTo, akGoToR, akURI,
                       akLaunch, akNamed, akJavaScript);

  TPDFlibActionInfo = record
    Kind: TPDFlibActionKind;
    URI: AnsiString;          // populated for akURI
    JavaScript: WideString;   // populated for akJavaScript
    FileName: AnsiString;     // populated for akGoToR / akLaunch
    OpenInNewWindow: Boolean; // akGoToR / akLaunch
  end;

Record cho bạn biết field nào thực sự có ý nghĩa thông qua Kind. Nếu Kind trả về akURI, hãy đọc URI và bỏ qua phần còn lại. Nếu nó trả về akGoTo, các field payload đều không áp dụng và bạn chuyển sang destination, là một lời gọi riêng sẽ nói tiếp ở dưới. akNone là câu trả lời thẳng thắn khi bookmark hoặc annotation không có action nào cả, thay vì một giá trị 0 để bạn phải đoán ý nghĩa

Đi dọc cây outline để tìm bookmark

Trước khi introspect một bookmark, bạn cần handle của nó. PDFlibPas nhận diện các outline node bằng một ID số nguyên, và FindOutlineByTitle tìm node theo phần văn bản hiển thị với quyền kiểm soát rõ ràng về phạm vi tìm kiếm:

type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

function FindOutlineByTitle(const Title: WideString;
  StartOutlineID: Integer;
  Depth: TPDFlibOutlineSearchDepth): Integer;

Phần đáng dừng lại ở đây là đối số Depth. osdSiblingsOnly quét chuỗi anh em cùng cấp ở mức của node bắt đầu rồi dừng lại, nó có thể tìm được một bookmark ngang hàng nhưng sẽ không bao giờ đi xuống con của nó. osdChildrenOnly nhìn xuống một tầng, vào các con trực tiếp của node bắt đầu. osdFullSubTree đệ quy qua toàn bộ nhánh. Chọn sai không báo lỗi mà chỉ lặng lẽ không thấy gì: tìm theo siblings-only cho một tiêu đề nằm sâu hai tầng đơn giản sẽ trả về zero, và bạn tưởng bookmark không tồn tại trong khi nó vẫn ở đó từ đầu. Hãy truyền GetFirstOutline làm start ID để tìm từ gốc tài liệu

var
  Lib: TPDFlib;
  FoundID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') = 1 then
    begin
      // Search the whole tree from the root for a nested bookmark.
      FoundID := Lib.FindOutlineByTitle('Appendix B',
        Lib.GetFirstOutline, osdFullSubTree);
      if FoundID <> 0 then
        // FoundID is now a handle you can pass to the action and
        // destination getters below.
        ;
    end;
  finally
    Lib.Free;
  end;
end;

Việc khớp là theo chuỗi tiêu đề chính xác, được so sánh dưới dạng WideString, nên có phân biệt hoa thường và giữ nguyên chính xác văn bản Unicode như đã lưu. Nếu PDF nguồn của bạn đến từ nhiều bộ sinh khác nhau, hãy chuẩn hóa tiêu đề bạn tìm theo cùng cách tài liệu đã lưu nó, nếu không bạn sẽ đuổi theo những lần không thấy giả

Giải quyết action và đích của bookmark

Khi đã có handle, GetOutlineActionInfo cho bạn cái nhìn có kiểu. Mẫu sử dụng là: gọi nó, rẽ theo Kind, rồi đọc field mà kiểu đó điền vào

var
  Info: TPDFlibActionInfo;
begin
  Info := Lib.GetOutlineActionInfo(FoundID);
  case Info.Kind of
    akURI:
      Writeln('Opens URL: ', Info.URI);
    akGoToR, akLaunch:
      Writeln('Opens file: ', Info.FileName,
        ' (new window: ', Info.OpenInNewWindow, ')');
    akJavaScript:
      Writeln('Runs script: ', string(Info.JavaScript));
    akGoTo:
      Writeln('Jumps within this document');  // see destination below
    akNamed:
      Writeln('Named action (NextPage, Print, etc.)');
    akNone:
      Writeln('Bookmark has no action');
  end;
end;

Đây là nơi cái bẫy đầu tiên thực sự xuất hiện, và cũng là bẫy mà phản hồi từ test đã lộ ra trong lúc triển khai. Có một getter cũ hơn là GetActionURL, và với tay lấy nó để đọc action URI là lỗi rất dễ mắc. GetActionURL resolve một file specification thông qua key /F. Đó là điều đúng cho GoToRLaunch, vì đích của chúng thật sự là file, nhưng lại hoàn toàn sai key đối với action URI. Địa chỉ của action URI là một chuỗi thuần trên key /URI của chính action, không phải file spec. Đưa một action URI vào đường file-spec sẽ cho ra kết quả rỗng hoặc vô nghĩa. Getter có kiểu xử lý nội bộ chuyện này bằng cách đọc trực tiếp /URI cho akURI và chỉ gọi bộ resolve file-spec cho akGoToRakLaunch, đúng là ranh giới mà bản tự viết rất hay làm mờ đi

Loại fit của destination và hình học phía sau chúng

Action akGoTo có nghĩa là "điều hướng trong tài liệu này", nhưng nó không nói gì về đâu hay bằng cách nào. Đó là việc của destination, và destination chứa nhiều sắc thái hơn mọi người thường nghĩ. Destination PDF không chỉ là số trang, mà là một trang cộng với một đặc tả "fit" nói cho trình xem biết nên dựng trang đó như thế nào (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo trả nó về dưới dạng một record:

type
  TPDFlibDestinationKind = (dkNone, dkXYZ, dkFit, dkFitH,
    dkFitV, dkFitR, dkFitB, dkFitBH, dkFitBV);

  TPDFlibDestinationInfo = record
    Kind: TPDFlibDestinationKind;
    Page: Integer;   // 1-based; 0 when unresolved
    Left, Top, Right, Bottom, Zoom: Double;
  end;

Tám loại fit trả lời các câu hỏi dựng khung khác nhau. dkXYZ đặt một điểm cụ thể ở góc trên bên trái với mức zoom rõ ràng, nên nó dùng Left, TopZoom. dkFit fit toàn bộ trang vào cửa sổ và bỏ qua tọa độ. dkFitHdkFitV fit theo chiều ngang hoặc dọc của trang với một tọa độ liên quan duy nhất, là cạnh trên hoặc cạnh trái. dkFitR là loại thú vị hơn, vì nó fit một hình chữ nhật cụ thể nên cả bốn cạnh đều có ý nghĩa. Nhóm dkFitB* làm cùng các việc đó nhưng theo hộp bao của nội dung hiển thị thay vì toàn trang. Biết field nào thực sự hoạt động cho từng loại là ranh giới giữa việc đọc đúng destination và in ra tọa độ rác chỉ vì chúng tình cờ bằng zero

PDF reader bookmark navigation panel showing a nested outline tree
Mỗi bookmark trong bảng điều hướng này sẽ resolve tới một action và, với các lần nhảy nội bộ, tới một destination có kiểu fit và tọa độ riêng

Ở tầng dưới, phần triển khai dựa trên một sự căn chỉnh có chủ đích rất đáng biết, vì nó giải thích tại sao mapping này đáng tin. Hàm nội bộ GetDestType trả về số nguyên 1..8 cho tám loại fit theo đúng thứ tự XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. TPDFlibDestinationKind được khai báo sao cho ordinal khớp một-một: dkXYZ là ordinal 1, dkFitBV là ordinal 8, còn dkNone nằm ở zero. Vì vậy việc chuyển đổi là một phép cast ordinal trực tiếp kèm kiểm tra phạm vi, chứ không phải một bảng tra cứu có thể lệch dần khi enum lớn lên. Đó là chi tiết nhỏ, nhưng là kiểu chi tiết nếu làm theo cách ngây thơ sẽ thành lỗi lệch một đơn vị ngay khi ai đó sắp xếp lại thứ tự của enumeration

var
  Dest: TPDFlibDestinationInfo;
begin
  Dest := Lib.GetOutlineDestinationInfo(FoundID);
  if Dest.Page = 0 then
    Exit;  // destination did not resolve
  case Dest.Kind of
    dkXYZ:
      Writeln(Format('Page %d at (%.0f, %.0f), zoom %.2f',
        [Dest.Page, Dest.Left, Dest.Top, Dest.Zoom]));
    dkFitR:
      Writeln(Format('Page %d, rect L%.0f T%.0f R%.0f B%.0f',
        [Dest.Page, Dest.Left, Dest.Top, Dest.Right, Dest.Bottom]));
    dkFit, dkFitB:
      Writeln(Format('Page %d, fit whole page', [Dest.Page]));
  else
    Writeln(Format('Page %d, fit kind %d',
      [Dest.Page, Ord(Dest.Kind)]));
  end;
end;

Giá trị Page bằng zero là tín hiệu cho thấy destination chưa resolve được, thường là vì action không mang destination hoặc named destination không được tìm thấy. Hãy kiểm tra nó trước khi tin bất kỳ tọa độ nào. Cũng lưu ý rằng GetOutlineDestinationInfo kiểm tra cả hai chỗ destination có thể tồn tại: trực tiếp trên /Dest của bookmark, và bên trong /D của một action GoTo được nhúng. Bạn không cần biết producer đã dùng dạng nào

Action của annotation và bẫy SelectPage

Link annotation mang action đúng như bookmark, và GetAnnotActionInfo trả về cùng record TPDFlibActionInfo với cùng mẫu kiểu trước rồi mới đến payload. Nhưng ở đây có một ràng buộc theo trạng thái không áp dụng cho outline, và đó là cái bẫy thứ ba

Annotation thuộc về page, và PDFlibPas chỉ mở trạng thái annotation của trang hiện tại sau khi bạn chọn trang đó. Gọi GetAnnotActionInfo mà chưa gọi SelectPage(N) thì handle của annotation là zero; lời gọi sẽ trả về akNone và bạn sẽ kết luận sai rằng trang không có annotation nào có thể xử lý. Cách sửa chỉ một dòng, nhưng rất dễ quên khi đang lặp qua nhiều trang:

var
  P: Integer;
  Info: TPDFlibActionInfo;
begin
  for P := 1 to Lib.PageCount do
  begin
    Lib.SelectPage(P);   // mandatory before touching annotations
    // GetAnnotActionID(1) <> 0 is the reliable "has an action"
    // test. CheckPageAnnots returns a boolean-style flag, not a
    // count, so it is the weaker signal here.
    if Lib.GetAnnotActionID(1) <> 0 then
    begin
      Info := Lib.GetAnnotActionInfo(1);
      if Info.Kind = akURI then
        Writeln(Format('Page %d link -> %s', [P, Info.URI]));
    end;
  end;
end;

Hai điều trong vòng lặp đó là có chủ ý. Thứ nhất, SelectPage(P) luôn đứng trước mọi lần truy cập annotation ở mỗi lượt lặp, vì trạng thái annotation theo trang không tự mang sang trang khác. Thứ hai, phép kiểm tra tồn tại dùng GetAnnotActionID(1) <> 0 thay vì CheckPageAnnots. Hàm sau chỉ báo sự hiện diện bằng cờ kiểu boolean chứ không phải số đếm, nên một action ID khác zero là cách chính xác hơn để hỏi "có annotation đầu tiên nào ở đây, và nó có mang action mình đọc được không" Một điểm tinh tế nữa đáng lưu ý: với annotation, script của action JavaScript được đọc trực tiếp từ /JS, giải mã stream khi script được lưu theo cách đó và đọc chuỗi khi không, nên nó vẫn xử lý tốt cả hai kiểu mã hóa thường gặp

Introspection phía đọc phù hợp ở đâu

Những getter này được cố ý giữ rất hẹp. Chúng là các lượt đọc thuần túy được xây trên các lớp action và destination đã có sẵn dùng integer handle của thư viện, nên không đụng vào đường ghi và không thêm rủi ro cho các tài liệu bạn cũng đang chỉnh sửa. Chúng báo cho bạn biết những gì nằm trong file; chúng không kiểm tra chính sách hay viết lại thứ gì. Nếu mục tiêu của bạn là chiều ngược lại, tức là tạo bookmark và link annotation mang các action này ngay từ đầu, phần đó nằm ở phía ghi, và bài đi kèm về hành động biểu mẫu tương tác và JavaScript trong Delphi sẽ đi qua cách tạo chúng. Nếu muốn lấy phần nội dung nhìn thấy và cấu trúc ra khỏi PDF thay vì đồ thị điều hướng của nó, hãy xem trích xuất văn bản, hình ảnh và phông chữ bằng PDFlibPas

Ranh giới cần nhớ thật thà là này: introspection chỉ thấy những gì producer thực sự đã ghi. Một bookmark mà generator để hỏng action, hoặc một destination trỏ tới named target chưa từng được định nghĩa, sẽ lộ ra thành akNone hoặc page zero thay vì một exception. Đó là hành vi đúng cho một read API đang kiểm tra các file không tin cậy, nhưng cũng có nghĩa code của bạn nên coi các kết quả zero đó là "không có hoặc chưa resolve được", chứ không phải bằng chứng rằng input đã chuẩn chỉnh. Phần typed action và destination introspection được trình bày ở đây là một phần của PDFlibPas, thư viện PDF gốc cho Delphi và C++Builder