Bài viết kỹ thuật

Chỉ số Widget so với chỉ số Annotation trong form PDFium Delphi

Trong PDFium Component, thành phần VCL/LCL dựa trên PDFium cho Delphi, C++Builder, và Lazarus, một chỉ số trường form không phải là một chỉ số annotation. Một trang mang các annotation Link, Text, và Ink cạnh các widget của nó, nên việc liệt kê trường phải lọc theo FPDFAnnot_GetSubtype và lộ ra một chỉ số logic đánh từ không, được ánh xạ trở lại một vị trí annotation thật chỉ tại lệnh gọi native

Lỗi phơi bày điều này không thể nhầm lẫn được một khi bạn đã thấy nó. Một người kiểm thử nhấn Tab trong một biểu mẫu hóa đơn đã điền và con trỏ biến mất, vì focus đã đi tới một hyperlink ở footer. Hay tệ hơn, không có gì xảy ra cả: mã của bạn ghi nhận field 3 đang được focus, panel UI cập nhật, và FORM_SetFocusedAnnot âm thầm trả về false suốt thời gian đó. Cả hai triệu chứng đều tới từ cùng một sai lầm thiết kế, và một trong hai có một nguyên nhân gốc thứ hai ẩn bên dưới

Hai không gian chỉ số mà PDFium trao cho bạn

PDFium bộc lộ hai lược đồ đánh số trên cùng một trang, và chúng chỉ trùng khớp trên các tài liệu tình cờ không chứa gì ngoài widget form. Cái thứ nhất là chỉ số annotation: một vị trí trong mảng /Annots của trang, đây là thứ mà FPDFPage_GetAnnotCount đếm và FPDFPage_GetAnnot nhận vào (ISO 32000-1 §12.5.2). Cái thứ hai là chỉ số trường logic mà một API ở mức ứng dụng nên cung cấp, chạy từ không trên các trường tương tác mà một người dùng thực sự có thể tới được. ISO 32000-1 §12.5.6.19 định nghĩa các annotation widget là biểu diễn trực quan của các trường form tương tác, và §12.7 định nghĩa chính bản thân form đó. Mọi thứ khác trên trang là một subtype khác với ngữ nghĩa khác: một annotation Link có một đích đến, một annotation Ink có một danh sách nét vẽ, một annotation Text là một ghi chú dán. Không cái nào trong số đó thuộc về một phép đếm trường, và không cái nào trong số đó có thể nhận focus form. Ấy vậy mà trong mảng /Annots, chúng nằm xen kẽ với các widget theo bất kỳ thứ tự nào mà ứng dụng tạo ra đã ghi chúng, thứ tự này thường không phải là thứ tự mà bất cứ điều gì khác về tài liệu gợi ý

Vì sao Tab lại rơi vào một hyperlink thay vì trường tiếp theo?

Vì phép đếm trường thực chất là một phép đếm annotation. Cách cài đặt gốc trả về trực tiếp FPDFPage_GetAnnotCount từ FormFieldCount, trong khi bộ truy cập thông tin trường, hàm trợ giúp thứ tự tab, và hàm trợ giúp focus đều coi cùng số nguyên đó là một vị trí widget. Trên một trang AcroForm sạch với sáu widget và không gì khác, sáu bằng sáu và mọi test đều qua. Thêm một hyperlink ở footer và một comment review ở lề, và phép đếm báo cáo tám trường, chỉ số 6 và 7 giải quyết ra các đối tượng không phải form, và Tab đi thẳng vào chúng

Cách sửa ở đầu liệt kê là đếm theo subtype thay vì đếm annotation. Mở từng annotation, hỏi subtype của nó, giữ lại các widget, và đóng handle trong một khối finally, vì FPDFPage_GetAnnot trả về một handle được sở hữu phải được trả lại qua FPDFPage_CloseAnnot

function WidgetCountForPage(Page: FPDF_PAGE): Integer;
var
  Count, I: Integer;
  Annot: FPDF_ANNOTATION;
begin
  Result := 0;
  if Page = nil then
    Exit;
  Count := FPDFPage_GetAnnotCount(Page);   // every annotation, not just fields
  for I := 0 to Count - 1 do
  begin
    Annot := FPDFPage_GetAnnot(Page, I);
    if Annot = nil then
      Continue;
    try
      if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
        Inc(Result);
    finally
      FPDFPage_CloseAnnot(Annot);
    end;
  end;
end;

Hãy chú ý điều này cố tình không làm. Nó không hỏi môi trường điền form bất cứ điều gì, và nó không cần một handle form, vì subtype sống trong dictionary annotation và có thể đọc được chỉ từ trang mà thôi. Điều đó quan trọng đối với thứ tự: phép đếm khả dụng trước khi bạn quyết định liệu tài liệu có xứng đáng với một môi trường điền form hay không, đây là điều mà bài viết về JavaScript AcroForm và sự kiện host trình bày như một quyết định bảo mật chứ không phải một tiện ích

Ánh xạ chỉ số logic trở lại tại ranh giới native

Quy tắc giữ cho hai không gian này không rò rỉ vào nhau rất đơn giản: chỉ số logic là con số duy nhất vượt qua API công khai của bạn, và nó được chuyển đổi thành một chỉ số annotation trong hàm cuối cùng trước lệnh gọi native. Một hàm trợ giúp ánh xạ duy nhất, được dùng bởi cả thông tin trường, focus, các bộ đặt cờ, và thứ tự tab, chính là thứ khiến quy tắc đó có thể được thực thi

function AnnotationIndexForField(Page: FPDF_PAGE;
  FieldIndex: Integer): Integer;
var
  Count, I, Current: Integer;
  Annot: FPDF_ANNOTATION;
begin
  Result := -1;
  if (Page = nil) or (FieldIndex < 0) then
    Exit;
  Count := FPDFPage_GetAnnotCount(Page);
  Current := 0;
  for I := 0 to Count - 1 do
  begin
    Annot := FPDFPage_GetAnnot(Page, I);
    if Annot = nil then
      Continue;
    try
      if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
      begin
        if Current = FieldIndex then
          Exit(I);        // real /Annots position: native calls only
        Inc(Current);
      end;
    finally
      FPDFPage_CloseAnnot(Annot);
    end;
  end;
end;

Hai đặc tính của hàm trợ giúp này đáng nói rõ. Nó là một lượt quét tuyến tính, nên một vòng lặp ngây thơ qua mọi trường tốn một số lượng phép mở annotation bậc hai trên một trang có hàng trăm widget; nếu bạn đang liệt kê toàn bộ trang, hãy duyệt các annotation một lần và thu thập các handle widget khi bạn đi qua, thay vì gọi bộ ánh xạ cho từng trường. Và nó trả về -1 thay vì ném lỗi, điều này để bên gọi quyết định liệu một chỉ số lỗi thời có phải là một lỗi lập trình đáng một ngoại lệ hay một tình huống race đáng bỏ qua, ví dụ sau khi một chỉnh sửa loại bỏ một annotation mà một danh sách UI đã cache vẫn còn tham chiếu tới

Vì sao FORM_SetFocusedAnnot thất bại trên một trang headless?

Vì PDFium từ chối focus một widget mà page view của nó chưa từng được đánh dấu hợp lệ. FORM_SetFocusedAnnot giải quyết annotation về một page view bên trong môi trường điền form, và nếu page view đó không tồn tại, nó trả về false mà không có bất kỳ chẩn đoán nào. Vì vậy chỉ sửa việc ánh xạ chỉ số sẽ sửa được việc Tab rơi vào một hyperlink nhưng để nguyên triệu chứng thứ hai: bản ghi focus logic của bạn nói field 3, widget được focus native vẫn không là gì cả, và mọi bộ truy cập được xây trên focus native, văn bản đã focus, giá trị đã focus, trạng thái lựa chọn choice, đều tiếp tục trả về rỗng. Page view được tạo ra bởi FORM_OnAfterLoadPage và bị hủy bởi FORM_OnBeforeClosePage. Trong một viewer được xây quanh một control trực quan, những lệnh gọi đó xảy ra như một phần của việc hiển thị một trang, đó là lý do vì sao lỗi này thường trông giống một lỗi chỉ-có-ở-headless: cùng đoạn mã hoạt động trong demo GUI lại thất bại trong công cụ batch. Vòng đời đó thuộc về đối tượng tài liệu, không phải viewer, nên PDFium Component giờ phát ra cả hai lệnh gọi bất cứ khi nào một trang được tải hay giải phóng với một handle form đang hiện diện. Chữ ký C nhận trang trước và handle form sau, điều này dễ bị đảo ngược khi viết binding bằng tay

procedure ReportFirstField(const FileName: string);
var
  Pdf: TPdf;
  Idx: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FormFill := True;      // form-fill environment, before Active
    Pdf.FileName := FileName;
    Pdf.Active := True;
    Pdf.PageNumber := 1;       // page load also runs FORM_OnAfterLoadPage

    Idx := Pdf.FocusNextFormField;   // logical index, 0-based over widgets
    if Idx < 0 then
      Exit;                    // page holds no widget annotations

    Writeln(string(Pdf.FormFieldInfo[Idx].Name), ' = ',
      string(Pdf.FocusedFormFieldValue));   // reads the native focused widget
  finally
    Pdf.Free;                  // page unload runs FORM_OnBeforeClosePage
  end;
end;

Phép kiểm tra chứng minh bản sửa đúng chính là phép kiểm tra so sánh hai phía. Gọi FocusFormField với một chỉ số logic, rồi đọc một giá trị qua một bộ truy cập đi qua widget được focus native thay vì qua bản ghi của riêng bạn, chẳng hạn FocusedFormFieldValue hay FocusedFormOptionSelected. Nếu chỉ số logic đi vòng trở lại đúng nhưng bộ truy cập native trả về rỗng, page view đang thiếu, không phải phép ánh xạ

Chỉ số trường logic không hứa hẹn điều gì

Một chỉ số trường đánh từ không là một tiện ích, không phải một danh tính ngữ nghĩa, và bốn giới hạn theo sau từ đó. Nó thuộc phạm vi từng trang, không phải từng tài liệu, nên chỉ số 0 trên trang 2 là một widget khác với chỉ số 0 trên trang 1 và so sánh chúng là vô nghĩa. Nó mang tính vị trí, nên chèn hay xóa một annotation làm mất hiệu lực mọi chỉ số đã cache ở trên điểm thay đổi; chỉ coi một chỉ số đã lưu là hợp lệ trong khi trang vẫn còn được tải và chưa bị chỉnh sửa

Giới hạn thứ ba là cái khiến người review một danh sách trường bất ngờ. Chỉ số này liệt kê widget, không phải field. Một radio group là một field với nhiều widget con, nên một nhóm ba nút đóng góp ba chỉ số liên tiếp đều báo cáo cùng Name. Bản ghi TPdfFormFieldInfo mang GroupCountGroupIndex chính cho trường hợp này, và một UI danh sách bỏ qua chúng sẽ hiển thị cùng một field ba lần. Giới hạn thứ tư liên quan tới thứ tự duyệt: thứ tự tab được bộc lộ ở đây là thứ tự liệt kê widget, theo mảng /Annots, không phải mục /Tabs của trang (ISO 32000-1 §7.7.3.3) và không phải cây field AcroForm. Với hầu hết các bên tạo, hai cái đó đồng thuận; với một biểu mẫu được bố trí thành hai cột bởi một bộ tạo đã phát ra cột phải trước, chúng không đồng thuận, và đường phím tắt được mô tả trong bài viết về điều hướng trường form sẽ cảm thấy sai dù mọi chỉ số đều đúng. Khi một tệp khách hàng hành xử kỳ lạ, hãy in ra cả hai không gian chỉ số cạnh nhau trước khi đưa ra giả thuyết: khung nhìn annotation và khung nhìn field của cùng một trang, in cùng nhau, thường khiến nguyên nhân hiển hiện rõ chỉ trong một cái liếc mắt

procedure DumpIndexSpaces(Pdf: TPdf);
var
  I: Integer;
  Info: TPdfFormFieldInfo;
begin
  for I := 0 to Pdf.AnnotationCount - 1 do
    Writeln('annot ', I, ': subtype ', Ord(Pdf.Annotation[I].Subtype));

  for I := 0 to Pdf.FormFieldCount - 1 do
  begin
    Info := Pdf.FormFieldInfo[I];
    Writeln('field ', I, ': ', string(Info.Name),
      ' widget ', Info.GroupIndex, ' of ', Info.GroupCount);
  end;
end;

Một số lượng annotation cao hơn hẳn số lượng field nghĩa là trang này trộn lẫn nhiều subtype, đây là điều bình thường trong các tài liệu đã được review và chính xác là tình huống mà phép ánh xạ này tồn tại để xử lý; bài viết về quy trình review annotation nhìn cùng trang đó từ phía markup. Mặt khác, số lượng bằng nhau trên mọi tệp test nghĩa là các fixture của bạn hoàn toàn không thể phát hiện loại lỗi này, và phản ứng trung thực là thêm một fixture form mang một link và một ghi chú dán

Các API liệt kê trường, focus, và annotation được mô tả ở đây được cung cấp cùng PDFium Component cho Delphi, C++Builder, và Lazarus, trang sản phẩm của thành phần này có đầy đủ tài liệu tham khảo trường form bao gồm cả bản ghi thông tin trường và các bộ truy cập focus