Bài viết kỹ thuật

Gán giá trị form field trong PDF đã nạp bằng Delphi

HotPDF Delphi Component điền vào một field AcroForm sẵn có trên PDF đã nạp qua THotPDF.SetFormFieldValue, gọi theo chỉ số field gốc không hoặc theo tên field đầy đủ. Ghi entry /V mới là phần dễ; thứ khiến lệnh gọi này đáng tin trên những form thực tế là cùng phương thức đó còn giữ ba mảnh trạng thái luôn đồng bộ, những thứ vô hình cho tới khi chúng sai: danh tính đã giải mã của field để một cái tên phi ASCII có thể được tìm thấy, trạng thái hiển thị /AS trên widget checkbox và radio, và mảng chỉ số chọn /I trên choice field. Còn appearance stream nhìn thấy được là một bước riêng, tường minh, qua EnsureLoadedFieldAppearanceStream

Tình huống ở đây rất đời thường: khách hàng gửi cho bạn form của chính họ, một tờ khai thuế, một đơn yêu cầu bảo hiểm, một đơn đặt hàng ai đó dựng trong Acrobat từ nhiều năm trước, và ứng dụng Delphi của bạn phải điền nó từ cơ sở dữ liệu rồi trả lại một tệp mở đúng ở mọi nơi. Bạn không kiểm soát được cách form đó được tạo ra. Tên field có thể được mã hóa UTF-16, giá trị export của checkbox có thể là 2 chứ không phải Yes, và combo box có thể dùng các cặp tùy chọn [export display]. Mỗi chi tiết đó đều có một quy tắc trong ISO 32000-1, và mỗi quy tắc là một thứ mà SetFormFieldValue giờ xử lý thay bạn. Bài này nói về việc nó làm gì, vì sao, và dừng ở đâu. Với bài toán anh em là tạo ra những field chưa tồn tại, xem thêm field AcroForm vào PDF đã nạp trong Delphi

Vì sao SetFormFieldValue không tìm được field mang tên phi ASCII?

Trước v2.752.1, câu trả lời là chuyện mã hóa: field nằm trong tệp dưới một cái tên UTF-16BE dạng thập lục phân, còn cache tên lưu cách viết hex thay vì lưu văn bản. ISO 32000-1 §12.7.3.1 định nghĩa tên field bộ phận /T là một text string, và §7.9.2.2 nói rằng một text string có thể là UTF-16BE với byte order mark FE FF ở đầu. Các công cụ tạo form thường tuần tự hóa những cái tên như vậy thành chuỗi hex theo §7.3.4.3, nên một field tên Straße đến dưới dạng <FEFF005300740072006100DF0065>. Bên trong HotPDF, THPDFStringObject.Value giữ văn bản thập lục phân thô mỗi khi IsHexadecimal được bật, và đó đúng là thứ bạn muốn cho một vòng round trip không mất mát của dictionary gốc, đồng thời cũng đúng là thứ bạn không muốn dùng làm khóa tra cứu. HPDFLoadedFormTextName tách hai mối quan tâm đó ra. Khi cache quan hệ được dựng, mọi giá trị /T đều đi qua nó: nếu object string là thập lục phân, HPDFHexToBytes khôi phục chuỗi byte; nếu các byte bắt đầu bằng FE FF và có độ dài chẵn, phần payload được giải mã thành UTF-16BE rồi mã hóa lại thành UTF-8; kết quả sau đó được nối với tên cha bằng một dấu chấm để thành tên đầy đủ mà §12.7.3.1 mô tả, nên một field con tên City nằm dưới cha tên Address được đăng ký là Address.City. Khóa cache được chuẩn hóa về chữ thường, nhờ đó SetFormFieldValue('address.city', ...) cũng chạy được; đây là tiện ích vượt ngoài chuẩn, vì đặc tả coi tên là phân biệt hoa thường. Điều then chốt là chỉ khóa cache thay đổi. Object /T trong dictionary của field vẫn giữ cách mã hóa thập lục phân, nên việc lưu tài liệu không ghi lại danh tính của một field mà bạn chỉ vừa điền

Cách HotPDF phân giải những tên AcroForm phi ASCII: HPDFHexToBytes khôi phục payload UTF-16BE nằm sau một chuỗi /T thập lục phân, byte order mark FE FF được giải mã rồi mã hóa lại thành UTF-8, và tên đầy đủ nối thêm tên cha nên Applicant.FullName lẫn một field tên Straße đều vào được cache tra cứu
Chỉ khóa cache thay đổi: dictionary của field giữ nguyên cách mã hóa thập lục phân, việc tra cứu chuẩn hóa về chữ thường như một tiện ích vượt ngoài chuẩn, và lưu tài liệu không bao giờ ghi lại danh tính của một field mà bạn chỉ vừa điền
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('claim-form.pdf') <= 0 then Exit;

    // Tên đầy đủ được giải mã từ các chuỗi /T UTF-16BE và
    // nối bằng dấu chấm, nên tên lồng cấp và phi ASCII đều phân giải được
    Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
    Pdf.SetFormFieldValue('Applicant.Straße', 'Hauptstraße 12');

    // Giá trị không thuộc Latin-1 đi dưới dạng hex UTF-16BE có tiền tố FEFF
    // và được ghi thành chuỗi thập lục phân của PDF
    Pdf.SetFormFieldValue('Applicant.City', 'FEFF004D00FC006E006300680065006E');

    Pdf.SaveLoadedDocument('claim-form-filled.pdf');
  finally
    Pdf.Free;
  end;
end;

SetFormFieldValue thật ra ghi những gì?

Cả hai overload chạy cùng năm bước: định vị dictionary của field, ghi /V qua HPDFSetDictFormValue, đồng bộ các chỉ số chọn của choice, đánh dấu dictionary là bẩn, đồng bộ trạng thái hiển thị của button, và cuối cùng ghi lại chỉ số field qua NoteLoadedFormFieldDirty. Bước cuối đó quan trọng nếu form mang script tính toán, vì tập bẩn chính là thứ mà overload không tham số của RecalculateLoadedFormFieldsIncremental tiêu thụ để chỉ chạy lại những phép tính có đọc tới một field đã thay đổi, kể cả gián tiếp. Bản thân HPDFSetDictFormValue rất chăm chút về kiểu object mà nó thay thế. Nếu /V hiện có là một object name, đúng kiểu mà field checkbox và radio dùng cho giá trị export của chúng, thì giá trị mới được ghi dưới dạng name chứ không bao giờ dưới dạng string, vì name của PDF theo cấu trúc chỉ chứa ASCII. Ngược lại, nó ghi một object string rồi soi giá trị bạn truyền vào: một chuỗi bắt đầu bằng FEFF, có độ dài chẵn, và chỉ gồm các chữ số thập lục phân sẽ được coi là dạng truyền tải UTF-16BE ở §7.9.2.2 và được lưu với IsHexadecimal được bật, nên nó tuần tự hóa thành <FEFF...> thay vì thành literal (FEFF...). Đó chính là cơ chế mà dòng City ở trên dựa vào; mọi chuỗi khác đều được lưu thành literal string với đúng các byte bạn đưa, nên với văn bản Latin thuần bạn cứ truyền văn bản thuần

Vì sao checkbox vẫn giữ dấu tick cũ sau khi giá trị đã đổi?

Vì với một field kiểu button, chỉ riêng giá trị không quyết định thứ được vẽ ra. ISO 32000-1 §12.7.4.2.3 quy định rằng một widget checkbox mang trạng thái hiển thị /AS nêu stream nào trong /AP /N đang được hiển thị, và các trình xem vẽ theo /AS chứ không theo /V. Nếu bạn đổi /V thành Yes mà để /AS ở Off, tệp mâu thuẫn nội bộ, và việc flatten sẽ vui vẻ nướng cái hiển thị chưa tick cũ vào trang trong khi dữ liệu form nói rằng đã tick. ReconcileLoadedButtonAppearanceStates tồn tại để bịt khoảng cách đó: với một field có /FT là Btn, nó ghé thăm chính dictionary của field cùng mọi entry trong mảng /Kids của nó, đọc tên trạng thái bật từ /AP /N, rồi ghi lại /AS thành tên đó khi tên này khớp với giá trị của field, hoặc thành Off khi không khớp

Vì sao checkbox của HotPDF vẫn giữ dấu tick cũ khi chỉ /V đổi: các trình xem vẽ theo trạng thái hiển thị /AS trong /AP /N, nên ReconcileLoadedButtonAppearanceStates ghé thăm field cùng mọi con, đọc tên trạng thái bật là khóa đầu tiên khác Off, rồi ghi lại /AS khi khớp hoặc ghi Off khi không
Nhóm radio so từng con với giá trị của cha mà InheritedButtonValue khôi phục được bằng cách đi ngược chuỗi /Parent, nên đặt nhóm về một giá trị export sẽ bật đúng widget đó và tắt mọi widget anh em

Hai chi tiết từ những form thật đã định hình bản sửa v2.752.3. Thứ nhất, một dictionary hiển thị normal được phép chỉ chứa trạng thái bật; §12.7.4.2.3 gọi hiển thị tắt là Off nhưng các công cụ tạo form thường bỏ luôn stream của nó và để trình xem vẽ ra không gì cả. Đoạn mã trước kia bỏ cuộc khi dictionary có ít hơn hai entry, nên những checkbox chỉ có một trạng thái ấy âm thầm giữ nguyên dấu tick cũ. Điều kiện kiểm giờ chỉ đơn giản là dictionary khác rỗng, và tên trạng thái bật được lấy là khóa đầu tiên không phải Off. Thứ hai, tên trạng thái bật là bất cứ thứ gì tác giả chọn. Form thật dùng 2, Yes, On, hay một từ đã bản địa hóa, nên phép so sánh được thực hiện với khóa thật, không phân biệt hoa thường, chứ không bao giờ với một Yes cứng nhắc. Radio button thêm một nếp gấp nữa, được mô tả ở §12.7.4.2.4: lựa chọn nằm trong /V trên field cha, trong khi các con riêng lẻ sở hữu các widget và thường không có /V của riêng chúng. Vì thế helper lồng nhau InheritedButtonValue đi ngược chuỗi /Parent, tối đa 64 tầng, cho tới khi tìm được một giá trị khác rỗng, để mỗi con được so với giá trị của nhóm mà nó thuộc về. Đặt cha về giá trị export của một con sẽ bật đúng con đó và tắt mọi anh em của nó

// Checkbox: giá trị export phải khớp khóa trạng thái bật trong /AP /N
// (thường là 'Yes', nhưng form thật dùng '2', 'On', hay bất cứ thứ gì khác)
Pdf.SetFormFieldValue('Consent', 'Yes');

// Nhóm radio: /V được ghi trên cha; mọi widget con đều được
// đặt /AS thành tên export của chính nó hoặc thành Off
Pdf.SetFormFieldValue('PaymentMethod', 'Card');

// Xóa tick của checkbox: giá trị không khớp trạng thái bật nào sẽ cho /AS Off
Pdf.SetFormFieldValue('Newsletter', 'Off');

Choice field: giữ /I đồng bộ với /V

Với một combo box hay list box, /V không phải nơi duy nhất ghi lại lựa chọn. Bảng 231 ở §12.7.4.4 định nghĩa /I là một mảng các chỉ số gốc không trỏ vào /Opt, dùng để xác định những mục đã chọn, và một trình xem thấy /I trỏ vào tùy chọn 0 trong khi /V nêu tùy chọn 3 có thể sẽ tô sáng nhầm dòng. Từ v2.754.1, HPDFReconcileChoiceSelection chạy bên trong mọi lệnh gọi SetFormFieldValue và, khi /FT thừa hưởng là Ch, dựng lại /I từ giá trị mới. Thứ tự các thao tác là có chủ đích. Entry /I cục bộ bị xóa trước, mà không đụng gì tới nội dung của nó: nếu mảng cũ là một object gián tiếp được chia sẻ với field khác thì sửa nó tại chỗ sẽ làm hỏng lựa chọn của field kia, nên thủ tục bỏ tham chiếu đó đi và tạo một mảng trực tiếp mới. Sau đó nó phân giải /Opt qua chuỗi /Parent, vì các tùy chọn của choice có thể được thừa hưởng, rồi quét các entry. Một tùy chọn là chuỗi trần được so trực tiếp; một cặp [export display] được so trên phần tử export của nó, và một cặp có ít hơn hai phần tử thì bị bỏ qua. Cả hai phía đều đi qua HPDFLoadedFormTextName, nên một tùy chọn UTF-16 dạng hex khớp với một giá trị UTF-16 dạng hex mà bạn không cần viết chúng giống hệt nhau. Ở kết quả khớp đầu tiên, một /I chỉ gồm một phần tử được ghi ra và phép quét dừng lại; một giá trị vô hướng luôn thay thế mọi lựa chọn nhiều mục trước đó, bất kể cờ MultiSelect

Cách HotPDF giữ cho một choice field nhất quán: HPDFReconcileChoiceSelection xóa mảng /I cục bộ trước khi đụng vào nó, phân giải /Opt qua chuỗi /Parent, so phần export của từng tùy chọn qua HPDFLoadedFormTextName, ghi một /I chỉ có một phần tử ở kết quả khớp đầu tiên và không ghi gì khi giá trị của combo editable không có chỉ số
Một tùy chọn là chuỗi trần được so trực tiếp còn cặp export display được so trên phần tử export của nó, trong khi một giá trị nằm ngoài /Opt đúng là không để lại chỉ số nào — một /I cũ trỏ vào nhầm dòng sẽ còn tệ hơn là không có

Khi không có gì khớp, không có /I nào được ghi cả. Đó là kết cục đúng cho một combo box editable, nơi §12.7.4.4 cho phép người dùng gõ một giá trị nằm ngoài danh sách tùy chọn; một giá trị như vậy không có chỉ số, và một chỉ số cũ sẽ còn tệ hơn là không có. Đó cũng là thứ bạn nhận được nếu bạn truyền nhãn hiển thị thay vì giá trị export cho một danh sách tùy chọn dạng cặp, nên khi một combo box không chịu hiện lựa chọn của bạn, hãy kiểm tra xem bạn đã đưa nửa nào của cặp

// /Opt là [[US United States] [CA Canada] [MX Mexico]]:
// so khớp trên giá trị export, và /I trở thành [1]
Pdf.SetFormFieldValue('Country', 'CA');

// Combo editable với giá trị nằm ngoài /Opt: /V được ghi,
// /I bị gỡ, và không có chỉ số nào được bịa ra
Pdf.SetFormFieldValue('Title', 'Principal Engineer');

Giá trị và hiển thị là hai thao tác tách biệt

SetFormFieldValue không bao giờ đụng vào appearance stream của một text field hay choice field. Sau lệnh gọi, /V giữ văn bản mới trong khi /AP /N vẫn vẽ văn bản cũ, và việc trình xem hiển thị cái nào phụ thuộc vào chỗ dictionary AcroForm có mang /NeedAppearances true theo §12.7.3.3 hay không và trình xem có tôn trọng cờ đó hay không. Nếu bạn cần tệp render giá trị mới trong mọi bộ đọc, kể cả những công cụ flatten và bộ sinh thumbnail bỏ qua cờ này, hãy gọi EnsureLoadedFieldAppearanceStream với chỉ số field. Nó dựng một Form XObject từ chuỗi /DA thừa hưởng, thuộc tính căn lề /Q, bố cục comb /MaxLen cùng giá trị, phân giải font theo tên qua resource /DR của AcroForm để một font Type0 giữ đúng font hậu duệ của nó thay vì thoái hóa thành Helvetica, và trả về True khi có ít nhất một widget nhận được stream. Overload theo tên của SetFormFieldValue không trả lại chỉ số nào, nên hãy lấy một chỉ số qua GetFormField, hàm này trả về một THPDFLoadedFormField do bạn sở hữu và phải free. Bộ kiểm thử hồi quy cho thay đổi v2.752.1 nói rõ về sự tách bạch này: nó đặt một giá trị, gọi EnsureLoadedFieldAppearanceStream, rồi render trang và kiểm tra rằng các pixel bên trong hình chữ nhật của widget đã đổi trong khi các pixel bên ngoài thì không. Việc xác nhận /V đã đổi chẳng chứng minh được gì về thứ người dùng sẽ thấy

var
  Field: THPDFLoadedFormField;
begin
  Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
  Field := Pdf.GetFormField('Applicant.FullName');
  try
    // Vẽ giá trị mới vào /AP để những trình xem bỏ qua
    // /NeedAppearances vẫn hiển thị được nó
    if not Pdf.EnsureLoadedFieldAppearanceStream(Field.Index) then
      raise Exception.Create('No widget rectangle to paint into');
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('claim-form-filled.pdf');
end;

Những giới hạn nên biết trước khi xây tiếp trên đây

ReconcileLoadedButtonAppearanceStates kiểm tra /FT cục bộ của dictionary mà bạn gọi tới, nên nó tác động lên cha của nhóm radio hoặc lên một checkbox mang /FT của riêng nó; một widget con được gọi trực tiếp, khi /FT chỉ nằm trên cha, sẽ không được đồng bộ qua con đường đó. HPDFReconcileChoiceSelection xử lý một giá trị vô hướng đơn và ghi nhiều nhất một chỉ số; những list box chọn nhiều mục với vài entry được chọn nằm ngoài thứ mà SetFormFieldValue mô hình hóa. Không thủ tục nào kiểm tra giá trị bạn truyền có khớp với /Opt hay với các khóa trạng thái bật hay không, nên một lỗi gõ sẽ tạo ra một checkbox Off hoặc một combo không có chỉ số thay vì một exception. Và GetFormFieldValue trả về văn bản /V đã lưu đúng như nó nằm trong dictionary, nghĩa là với một giá trị được mã hóa hex thì bạn nhận cách viết thập lục phân, chứ không phải văn bản đã giải mã

Một khi các giá trị đã vào và phần hiển thị đã được vẽ, hai bước tiếp theo tự nhiên nằm ở hai phía của thao tác này. Trao đổi dữ liệu field với các hệ thống bên ngoài theo lô, thay vì từng lệnh SetFormFieldValue một, là việc mà nhập và xuất XFDF trong Delphi đề cập. Còn khi form đã điền xong và không nên sửa được nữa, flatten field AcroForm và XFA trong Delphi sẽ nướng đúng những trạng thái /AS cùng appearance stream mô tả ở đây vào nội dung trang tĩnh, và đó là lý do việc làm chúng nhất quán trước khi flatten không phải chuyện tùy chọn

API chỉnh sửa form đã nạp trong bài này, gồm SetFormFieldValue, EnsureLoadedFieldAppearanceStream và đồ thị tính toán lại tăng dần, đều có trong HotPDF Delphi Component cho Delphi và C++Builder