Bài viết kỹ thuật

Round trip appearance annotation trong Delphi với PDFium

Trong PDFium Component trước v3.121.1, việc đọc một annotation qua TPdf.Annotation[] rồi gán record ngược lại có thể thêm các entry /R và /D rỗng vào dictionary appearance /AP của nó, kể cả khi bản gốc chỉ mang /N. Các validator PDF/A từ chối dictionary đó. Từ v3.121.1, getter chỉ báo cáo appearance mà nó thực sự đọc được, nên một round trip không thay đổi gì sẽ chẳng ghi thêm gì mới. Cái lỗi này đáng được hiểu chi tiết, vì cái cò thông thường lại là một bản sửa nhằm cho file tuân thủ hơn, chứ không phải kém đi

Sơ đồ round trip annotation của PDFium Component, nơi việc thêm afPrint qua TPdf.Annotation[] và SetAnnotationData còn ghi luôn các stream /R và /D rỗng qua FPDFAnnot_SetAP, biến một dictionary appearance sạch PDF A thành thứ mà veraPDF từ chối cho tới v3.121.1 chỉ báo cáo các appearance mà nó thực sự đọc được
Đọc một annotation rồi ghi lại nguyên trạng từng thêm các appearance stream rollover và down rỗng, và thứ làm gãy PDF/A là cái đó, chứ không phải cờ Print mà bạn định thêm

Chuyện gì sai khi bạn ghi một annotation lại nguyên trạng?

Câu trả lời ngắn: annotation có thêm các appearance stream mà nó chưa từng có, và một file đã pass validation PDF/A trước khi bạn sửa thì gãy sau đó. Kịch bản điển hình diễn ra thế này. Một kho lưu trữ của khách hàng đến với các annotation square và text thiếu cờ Print, PDF/A đòi mọi annotation phải in được, nên bạn loop qua các trang, thêm afPrint, rồi gán từng record ngược lại. Chẳng gì trong đoạn code đó đụng tới appearance. Record từ TPdf.Annotation[] là một TPdfAnnotation, và SetAnnotationData ghi mọi field mà sentinel Has* được đặt, đúng cách mà các cặp HasContents / ContentsText được thiết kế để vận hành. Vấn đề là getter đặt HasAppearanceRollover và HasAppearanceDown thành True với chuỗi rỗng cho các mode không tồn tại, và setter chăm chỉ ghi ra hai stream rỗng:

procedure MarkAnnotationsPrintable(const FileName: string);
var
  Pdf: TPdf;
  PageNo, I: Integer;
  A: TPdfAnnotation;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    for PageNo := 1 to Pdf.PageCount do
    begin
      Pdf.PageNumber := PageNo;
      for I := 0 to Pdf.AnnotationCount - 1 do
      begin
        A := Pdf.Annotation[I];
        if not (afPrint in A.Flags) then
        begin
          A.Flags := A.Flags + [afPrint] - [afHidden, afInvisible, afNoView];
          // Trước v3.121.1, phép gán này còn ghi luôn các stream /AP/R và
          // /AP/D rỗng khi annotation nguồn chỉ có /AP/N
          Pdf.Annotation[I] := A;
        end;
      end;
    end;
    Pdf.SaveAs(ChangeFileExt(FileName, '.printable.pdf'));
  finally
    Pdf.Free;
  end;
end;

ISO 32000-1 §12.5.5 định nghĩa dictionary appearance với ba entry: /N cho appearance thường, /R cho rollover, và /D cho down. /R và /D là tùy chọn, và khi chúng vắng mặt, viewer rơi về /N. Nhưng một stream /R rỗng thì không phải vắng mặt. Nó là một stream hợp lệ mà chẳng vẽ gì cả, nên một viewer tôn trọng appearance rollover sẽ hiện một hình chữ nhật trống ngay khoảnh khắc con trỏ đi ngang qua annotation. PDF/A còn khắt khe hơn nữa: ISO 19005-1 (kèm Corrigendum 2) và ISO 19005-2 / 19005-3 chỉ cho phép /N trong dictionary appearance của annotation. veraPDF báo file đã round-trip dưới luật 6.5.3-4 cho PDF/A-1 và luật 6.3.3-2 cho PDF/A-2 và PDF/A-3, còn TPdf.ValidatePdfA dựng sẵn liệt kê nó là pvaiAnnotationApDictViolation. Phép sửa thêm cờ Print để thỏa một điều khoản của chuẩn lại gãy một điều khoản khác

Dictionary appearance của annotation từ ISO 32000-1 với các entry normal, rollover và down: PDFium trả 2 byte cho cả stream vắng mặt lẫn stream rỗng có sẵn, nên cả hai đọc ngược về TPdf đều là không có nội dung, trong khi chỉ một phép kiểm cấp byte như TPdf.ValidatePdfA mới tìm ra stream rỗng mà PDF/A không cho phép
Một /R vắng mặt rơi về /N; một /R rỗng vẽ một hình chữ nhật trống mà vẫn gãy PDF/A, và qua record thì hai thứ này không thể phân biệt

Vì sao FPDFAnnot_GetAP trả về 2 cho một appearance vắng mặt?

PDFium không bao giờ trả về 0 từ FPDFAnnot_GetAP, kể cả khi appearance stream được yêu cầu không tồn tại. Hàm theo đúng pattern hai-lần-gọi quen thuộc của PDFium: truyền buffer nil để lấy kích thước cần thiết tính bằng byte, cấp phát, rồi gọi lại để copy văn bản UTF-16LE. Kích thước luôn bao gồm terminator UTF-16, nên một stream vắng mặt báo 2 byte, một chuỗi rỗng cộng terminator của nó. Getter trước v3.121.1 kiểm tra ByteLength >= SizeOf(FPDF_WCHAR), một phép kiểm mà mọi lần gọi đều pass, nên cả ba cờ HasAppearance* đều về True với bất kỳ annotation nào có appearance nói chung. Một round trip qua record rồi yêu cầu FPDFAnnot_SetAP lưu một chuỗi rỗng cho từng mode, và PDFium tạo stream để chứa nó. Không exception, không cảnh báo, và trang nhìn thấy được trông hệt như cũ, đó là lý do khuyết tật chỉ lộ diện trong một fixture veraPDF chứ không phải trong một viewer

FPDFAnnot_GetAP báo một appearance stream vắng mặt trong PDFium thế nào: pattern hai-lần-gọi luôn trả ít nhất hai byte cho terminator UTF-16, cổng cũ so với SizeOf(FPDF_WCHAR) pass mọi lần gọi và đặt mọi sentinel HasAppearance thành true, còn cổng v3.121.1 đòi nhiều hơn terminator cộng với số byte chẵn
Hai byte là chuỗi rỗng đã mã hóa, không phải bằng chứng appearance tồn tại; getter đã sửa coi mọi thứ ở hoặc dưới độ dài terminator là không có nội dung và việc ghi lại giữ im lặng

v3.121.1 quyết định một appearance tồn tại thế nào

ReadAppearance, helper bên trong GetPageAnnotation đổ đầy AppearanceNormal, AppearanceRollover và AppearanceDown, giờ chỉ coi một kết quả là nội dung khi nó mang ít nhất một ký tự vượt qua terminator. Lần gọi đầu phải trả nhiều hơn SizeOf(FPDF_WCHAR) byte và số byte phải chẵn, vì một độ dài lẻ không thể là UTF-16. Lần gọi thứ hai, thứ thực sự copy văn bản, lại được validate lần nữa: độ dài trả về bằng 2 hay ít hơn, hoặc lớn hơn buffer đã cấp phát, sẽ reset HasValue về False và để chuỗi rỗng. Phía ghi chẳng có gì thay đổi. SetAnnotationData vẫn chỉ gọi FPDFAnnot_SetAP cho các mode mà cờ HasAppearance* là True, nên một record đọc từ annotation chỉ có /N giờ ghi lại đúng mỗi /N. Fixture regression phủ cả hai chiều: một annotation square với appearance thường, đọc rồi ghi lại nguyên trạng, pass PDF/A-1b, PDF/A-2b và PDF/A-3b, trong khi cùng annotation đó khi bỏ cờ Print thì gãy đúng luật cờ kỳ vọng và chẳng gãy gì khác

Stream vắng mặt và stream rỗng nhìn giống hệt nhau, nên getter giữ thái độ bảo thủ

API native không thể phân biệt một appearance stream vắng mặt với một stream có mà rỗng, và PDFium Component không hứa gì quá khả năng của mình. Cả hai ca đều trả cùng 2 byte từ FPDFAnnot_GetAP, nên cả hai đọc ngược về HasAppearanceRollover = False với AppearanceRollover rỗng. Điều đó kéo theo hai hệ quả bạn nên thiết kế xoay quanh. Thứ nhất, một sentinel False nghĩa là “chẳng đọc được nội dung nào, nên write-back sẽ để nguyên mode này”, chứ không phải “khóa /R vắng mặt trong dictionary”. Thứ hai, record không thể phát hiện một stream rỗng đã có sẵn trong file: một tài liệu bị tổn thương bởi một build cũ hay bởi một công cụ khác đọc ngược về sạch bong, và gán record lại không sửa được cũng chẳng làm tệ thêm. Muốn tìm những file đó bạn cần một phép kiểm cấp byte, chính là việc của TPdf.ValidatePdfA và workflow validation preflight PDF/A với PDFium Component

Bạn xóa một appearance một cách chủ ý thế nào?

Bạn đặt sentinel một cách tường minh rồi truyền một chuỗi rỗng; setter sẽ ghi nó. Chặn chuỗi rỗng trong SetAnnotationData sẽ là bản sửa thô cho bug này, nhưng nó cũng làm gãy các caller chủ ý xóa một appearance, cùng hợp đồng mà HasContents và HasAuthor tuân theo cho văn bản. Vì thế bản sửa nằm hoàn toàn ở getter, còn setter tiếp tục vâng lời bất cứ gì caller yêu cầu:

// Thay appearance rollover, rồi xóa nó lần nữa
A := Pdf.Annotation[0];
A.HasAppearanceRollover := True;
A.AppearanceRollover := 'q Q';
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// A.HasAppearanceRollover là True và văn bản round-trip về 'q Q'
A.HasAppearanceRollover := True;   // xác nhận lại ý định một cách tường minh
A.AppearanceRollover := '';        // chủ ý ghi một stream rỗng
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// Đọc ngược về HasAppearanceRollover = False với chuỗi rỗng:
// một stream rỗng và một stream vắng mặt không thể phân biệt ở đây

Hãy nhớ rằng một /R hay /D bị dọn rỗng một cách tường minh vẫn tính là một khóa thừa dưới các luật PDF/A trích ở trên. Nếu đích đến là một archive profile, ghi một /N không rỗng và để nguyên hai mode còn lại là hình dạng duy nhất validate được. Bất kỳ workflow nào di chuyển annotation giữa các tài liệu, như XFDF export và import với PDFium Component, nên theo cùng luật đó: copy đúng các mode mà nguồn thực sự có và để mọi sentinel còn lại là False

Một pattern read-modify-write giữ an toàn PDF/A

Hãy nâng cấp lên v3.121.1 hay mới hơn, để nguyên các sentinel appearance đúng như getter trả về, và validate file đã lưu trước khi giao nó. Vì một stream rỗng tồn dư đọc ngược về như vắng mặt, bước xác minh phải nhìn vào tài liệu đã serialize thay vì nhìn vào record, và nó đủ rẻ để chạy sau mỗi batch:

uses
  PDFium, FPdfPdfa;  // FPdfPdfa khai báo TPdfAValidationIssue

function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
  Report: TPdfAValidationResult;
begin
  // Validate tài liệu đang nạp trong Pdf, bao gồm các phép sửa
  // đã làm qua Pdf.Annotation[] kể từ khi nó được mở
  Report := Pdf.ValidatePdfA;
  Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;

Kỷ luật tương tự áp dụng cho bất kỳ panel nào tô lại màu hay chú thích trang phục vụ việc rà soát, một workflow đã trình bày trong dựng workflow review annotation Delphi với PDFium Component: record là một ảnh chụp của những gì engine đọc được, và một sentinel mà bạn không tự đặt nên đi ngược lại nguyên trạng. Toàn bộ annotation API, PDF/A preflight và engine PDFium native được giao cùng nhau trong PDFium Component for Delphi, C++Builder and Lazarus