Bài viết kỹ thuật

PDFium Component XFA Save: Newline, Emoji và restoreState

PDFium Component lưu các giá trị form XFA đã sửa một cách chính xác, qua save và mở lại, khi nó chạy runtime Windows V8 pdfium.v8.dll đóng gói trong v3.125.2 trở lên. Các runtime cũ thêm line feed vào giá trị field, cắt emoji xuống còn một ký tự BMP không liên quan, lặng lẽ bỏ qua các lần save XFA single-stream và có thể nuốt một lần ghi cuối thất bại. Một triệu chứng khi mở lại chẳng phải lỗi thư viện chút nào: một form động mà root subform thiếu restoreState="auto" dựng lại layout của nó từ template

Các bug report cho mấy chuyện này nhìn giống hệt nhau. Một khách hàng điền một form XFA khai báo trong một viewer Delphi, save, mở lại, và có gì đó hơi lệch. Một hộp comment trống rỗng giờ giữ một dòng trắng, và sau lần save thứ hai nó giữ hai. Một cái tên gõ kèm emoji quay về với một glyph private-use. Chẳng ai nhận được lỗi, và đó chính là thứ khiến mấy bug này tốn kém: sự trôi dạt lộ diện vài tuần sau trong một bản export của người khác

Chuyện gì hỏng khi một form XFA được save rồi mở lại?

Bốn lỗi riêng trong đường save XFA native gây ra sự trôi dạt giá trị, và mỗi lỗi nấp sau một lần save trông thành công. Hai cái đến từ serialization, một cái từ bố cục lưu trữ single-stream, và một cái từ chính PDF writer. Bảng dưới ánh xạ từng triệu chứng tới nguyên nhân của nó và tới bản phát hành mà PDFium Component sửa nó

Triệu chứng sau khi mở lạiNguyên nhânSửa trong
Field trống giữ một line feed; giá trị thêm một newline mỗi lần saveCả hai XFA writer chèn newline layout sau start tagv3.125.2, pdfium.v8.dll
U+1F642 quay về thành U+F642, hay emoji biến mất khỏi form packetCắt cụt 16-bit wchar_t khi decode; lọc surrogate trong form serializerv3.125.2, pdfium.v8.dll
Các chỉnh sửa trong tài liệu XFA single-stream giản đơn biến mấtSave native từ chối bố cục stream, nhưng giá trị trả về bị bỏ quav3.125.2; comment và processing instruction được giữ kể từ v3.126.0
Tệp bị cắt cụt dù save report thành côngLần ghi đệm cuối thất bại sau khi writer đã trả về thành côngv3.125.2 runtime V8; v3.125.3 pdfium.dll thường
Form động ba trang mở lại thành hai trangRoot subform không đòi restoreState="auto"Cách dựng form, không phải lỗi thư viện

Các bài viết trước kết luận rằng các chỉnh sửa field XFA không thể được lưu bền với PDFium chút nào, điều đúng với các runtime thời đó. Runtime V8 mới hơn lưu các giá trị XFA bằng đường native, nên một chỉnh sửa thực hiện trong form sống tới được gói datasets đã lưu mà không cần mổ xẻ packet phía bạn

Runtime PDFium nào lưu các giá trị XFA?

Độ trung thực save XFA phụ thuộc DLL native, không phải wrapper Delphi, nên phép kiểm đầu tiên là runtime nào mà tiến trình của bạn thực sự nạp. PDFium Component đóng gói hai bản dựng Windows cho mỗi kiến trúc: pdfium.dll thường, build không có V8 lẫn XFA, và pdfium.v8.dll, thứ mang JavaScript engine cùng XFA form runtime. Chỉ pdfium.v8.dll chạy nổi một form XFA, nên mọi bản sửa XFA nói trong bài này sống ở đó, bắt đầu từ các thư viện V8 Win32 và Win64 dựng lại trong v3.125.2

Bản sửa lần-ghi-cuối là code PDF writer chung, nên nó cũng quan trọng với tài liệu thường. v3.125.3 dựng lại các thư viện pdfium.dll thường để mang theo cùng bản sửa đó. Dùng chung mã nguồn không phải bằng chứng cho hành vi dùng chung: cho tới khi binary được dựng lại, DLL cũ vẫn giữ bug cũ

Một cái bẫy thứ hai nấp trong loader. Trước v3.125.2, đặt EnableV8Engine thành True khiến binding chọn tên mặc định pdfium.v8.dll và bỏ qua một đường dẫn đầy đủ trong LibraryName. Một ứng dụng trỏ vào một runtime vừa deploy có thể cứ nạp một bản sao cũ hơn từ một thư mục khác. Kể từ v3.125.2, một LibraryName chứa thư mục chọn đúng tệp đó ở cả hai chế độ engine, và một đường dẫn thiếu nêu lỗi thay vì rơi về một thư viện đóng gói khác

uses
  System.SysUtils, PDFium;

procedure SelectXfaRuntime;
begin
  // Một thư mục trong LibraryName ghim đúng tệp này (v3.125.2 trở lên);
  // nếu thiếu tệp, việc nạp nêu lỗi thay vì rơi về phương án khác
{$IFDEF WIN64}
  PDFium.LibraryName := ExtractFilePath(ParamStr(0)) + 'DLLs\Win64\pdfium.v8.dll';
{$ELSE}
  PDFium.LibraryName := ExtractFilePath(ParamStr(0)) + 'DLLs\Win32\pdfium.v8.dll';
{$ENDIF}
  PDFium.EnableV8Engine := True;
  PDFium.LoadLibrary;  // hỏng ở startup, không phải ở lần save đầu
end;

Sau khi mở một tài liệu, TPdf.XFA báo tệp có chứa XFA và TPdf.XfaRuntimeAvailable báo DLL đã nạp thực sự chạy nổi nó. Nếu bạn còn cần phân biệt form tĩnh với form động, TPdf.FormType trả về ftXfaFull hay ftXfaForeground; bài về phát hiện form XFA và trích các gói XFA trong Delphi nói kỹ phần thăm dò đó

Vì sao các field XFA đã save nhận thêm line feed?

Các field XFA đã save nhận thêm line feed vì cả hai XFA writer native, writer phần tử XML chung và form packet serializer, đều pretty-print output của mình với một newline sau mỗi start tag. Trong phần lớn XML, khoảng trắng đó chỉ để đẹp. Trong dữ liệu XFA thì không: khi gói datasets được parse lại, text nằm giữa <Comments> và </Comments> chính là giá trị field, newline tính luôn. Một field trống vì thế mở lại với một LF duy nhất, và mỗi chu kỳ save-và-mở-lại tiếp theo có thể cộng thêm một cái

Sơ đồ chu kỳ save XFA của PDFium Component nơi writer thêm một newline sau start tag, parser khi mở lại đọc LF giữa các tag Comments thành giá trị field, và mỗi lần save tiếp theo nối thêm một line feed cho tới khi v3.125.2 chỉ bỏ khoảng trắng do serializer tự tổng hợp
Một chu kỳ save-mở-lại gieo line feed đầu tiên và mỗi vòng sau cộng thêm một, đó là lý do sự trôi dạt chỉ lộ đủ hình dạng ở thế hệ thứ hai

Cách sửa hiển nhiên là trim giá trị khi nạp, nhưng sẽ sai. Người dùng gõ dấu cách đầu dòng, cuối dòng và text đa dòng có chủ đích vào các XFA field, và một khối địa chỉ hay một mã cố định độ rộng phải sống sót từng byte một. Bản sửa v3.125.2 vì thế chỉ bỏ khoảng trắng mà chính serializer tổng hợp quanh các tag. Giá trị người dùng, các text node có sẵn và các section CDATA đi qua nguyên vẹn, nên " indented" vẫn thụt dòng và một field cố ý trống vẫn trống

Vì sao một emoji quay về thành một ký tự khác?

Một emoji quay về sai vì wchar_t Windows rộng 16 bit, và hai đường decode từng lưu một giá trị scalar Unicode đầy đủ trong một wchar_t đơn. Bộ decode stream UTF-8 và bộ parse các character reference số như &#x1F642; đều dính cả hai. U+1F642, gương mặt cười nhẹ, không vừa 16 bit, nên các bit cao rơi mất và U+F642 hiện ra thay thế: một code point trong Private Use Area mà phần lớn font vẽ thành một ô vuông hay chẳng vẽ gì

Form serializer gặp vấn đề ngược lại. Nó lọc ký tự từng wchar_t một, thấy hai code unit surrogate vô hiệu khi đứng riêng, và bỏ cả hai, nên emoji biến mất khỏi form packet hoàn toàn. Trong v3.125.2, bộ decode tiêu thụ trọn vẹn từng scalar value và phát ra một cặp surrogate đúng chuẩn. Khi chỉ còn một slot output, nó giữ low surrogate chờ đợi và không report hết stream trong khi unit đó còn đang nằm trong buffer. Một chuỗi UTF-8 bị cắt giữa các lần đọc được mang sang lần đọc kế thay vì bị vứt. Form exporter giờ giữ các cặp surrogate hợp lệ lại với nhau, và các character reference số cũng tạo ra các cặp đúng

Sơ đồ xử lý surrogate của PDFium Component nơi U+1F642 đến dưới dạng cặp UTF-16 D83D DE42 và hai đường lỗi làm hỏng nó: các decoder wchar_t 16 bit cắt cụt scalar thành U+F642 trong private use area, trong khi form serializer lọc surrogate đơn lẻ và làm emoji biến mất hoàn toàn
wchar_t Windows rộng 16 bit, nên một scalar cần một cặp surrogate hoặc mất nửa cao hoặc biến mất khỏi packet cho tới khi cả hai đường học được cách giữ cặp lại với nhau

Dữ liệu test Latin-1 chẳng bao giờ phơi ra bất cứ điều gì trong số này, nên mọi test round-trip XFA cần ít nhất một ký tự supplementary-plane

XFA single-stream và những lần save thất bại chẳng ai thấy

Một tài liệu XFA single-stream mất các chỉnh sửa vì helper save native từ chối bố cục lưu trữ đó còn caller bỏ qua thất bại. ISO 32000-1 §12.7.8 cho phép entry /XFA của interactive form dictionary là một mảng tên packet và stream, hoặc một stream đơn giữ toàn bộ tài liệu XDP. Mảng packet là trường hợp phổ biến, nhưng stream đơn hoàn toàn hợp pháp, và phần save PDF kết thúc như thể chẳng có chuyện gì trong khi dữ liệu form vẫn nằm ở giá trị cũ

Kể từ v3.125.2, runtime V8 xử lý tập con single-stream được hỗ trợ. Nó trước hết export cả hai packet sống, datasets và form, vào một vùng dàn dựng rồi kiểm chứng chúng, và chỉ sau đó thay thế các packet tương ứng trong XDP gốc. Các packet khác cùng các khai báo namespace root được giữ lại. Nếu phần dàn dựng thất bại, stream XFA bền vững chưa bao giờ bị đụng tới và tài liệu giữ dấu hiệu sửa đổi của nó

XML comment và processing instruction cần được chăm sóc thêm vì XML DOM nội bộ bỏ rơi chúng. Trong v3.125.2, sự hiện diện của chúng khiến save hỏng hẳn thay vì mất nội dung một cách lặng lẽ. v3.126.0 giữ chúng lại: trước khi parse, mỗi comment hay processing instruction được đổi chỗ cho một marker dựng từ một prefix không xuất hiện ở bất cứ đâu trong text gốc. Sau khi các packet sống được thay thế, mọi marker phải xuất hiện đúng một lần trước khi token gốc được trả về và stream được ghi. Các token ngoài các packet được thay thế vì thế giữ text và thứ tự của chúng, gồm cả token trong prolog, template và các packet khác

Vài input vẫn bị từ chối một cách có chủ đích, và mỗi lần từ chối là một thất bại save tường minh:

  • Comment hay processing instruction nằm bên trong các packet sống datasets hay form, vì vị trí gốc của chúng không thể ánh xạ vào nội dung vừa export
  • Khai báo DTD và chữ ký XMLDSig, vì viết lại XDP không thể giữ một chữ ký XML còn hiệu lực
  • Mã hóa UTF-8 hay UTF-16 vô hiệu, tag chưa đóng, character reference vô hiệu, entity lạ và processing instruction dị dạng, bị từ chối thay vì được sửa lặng lẽ
Đường ống save XFA single-stream của PDFium Component nơi các packet datasets và form sống được export ra vùng dàn dựng, kiểm chứng, rồi thay thế bên trong XDP gốc với các comment được giữ qua marker, trong khi các thất bại dàn dựng và các input như DTD hay XMLDSig từ chối save một cách tường minh
Phần export dàn dựng được kiểm chứng trước khi bất cứ thứ gì được thay thế, nên một lần save thất bại để nguyên stream XFA bền vững và tài liệu giữ dấu hiệu sửa đổi

Đầu ra single-stream là UTF-8 và giữ nguyên mô hình nội dung XML, chứ không phải bố cục byte gốc hay khai báo mã hóa

Lỗi cuối nằm dưới XFA. File writer native đệm output theo khối 32 KB và chỉ flush khối cuối dang dở trong destructor của nó, sau khi document writer đã report thành công. Một lỗi đĩa đầy hay I/O trên khối cuối đó là vô hình với caller. Kể từ v3.125.2 ở runtime V8 và v3.125.3 ở runtime thường, lần flush cuối đó là một phần của kết quả save, và dấu hiệu sửa đổi XFA chỉ được xóa sau một thành công thật. Phía Delphi, TPdf.SaveAs(const FileName: string; Option: TSaveOption = saNone; PdfVersion: TPdfVersion = pvUnknown): Boolean ghi vào một tệp tạm cạnh tệp đích và đổi chỗ nó vào vị trí chỉ khi save trả về True, nên một lần save thất bại để nguyên tệp cũ

Vì sao một form XFA động mở lại với ít trang hơn?

Một form XFA động mở lại với ít trang hơn khi root subform của nó không khai báo restoreState="auto", và đó là một quyết định của người dựng form chứ không phải lỗi PDFium Component. Trong XFA 3.3, restoreState trên root subform mặc định là manual. Dưới manual, bộ xử lý XFA chỉ khôi phục trạng thái giới hạn từ form packet đã lưu và giao phần còn lại cho các script của tác giả. Giá trị field đã lưu và số instance của subform lặp vẫn quay về, nhưng các thuộc tính hình học đặt lúc runtime thì không

Trường hợp phơi ra chuyện này là một form ba trang mà script của nó phình một subform thành h="450pt". Form packet đã lưu giữ chiều cao mới, các giá trị và số instance. Nhưng khi mở lại, layout được dựng từ các chiều cao template và form reflow xuống hai trang. Runtime đã đúng: template chưa từng đòi khôi phục tự động. Khai báo nó trên root subform sửa được việc mở lại:

<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
  <subform name="form1" layout="tb" restoreState="auto">
    <pageSet>
      <pageArea name="Page1">
        <contentArea x="0.25in" y="0.25in" w="8in" h="10.5in"/>
        <medium stock="letter"/>
      </pageArea>
    </pageSet>
    <subform name="Details" layout="tb" w="7.5in">
      <!-- các field; script có thể đổi h hay thêm instance lúc runtime -->
    </subform>
  </subform>
</template>

Nếu bạn không sở hữu template, đừng vá quanh nó trong viewer: một form dựa vào chế độ manual mong các script của chính nó dựng lại trạng thái. Repagination sống trong lúc người dùng gõ là một chủ đề riêng, được nói trong cách PDFium Component theo dõi số trang XFA động và các field đổi chỗ

Verify một lần save XFA trong Delphi thế nào?

Phép kiểm save XFA đáng tin duy nhất là mở lại tệp đã lưu trong một instance TPdf mới tinh rồi đọc dữ liệu đã lưu. TPdf.GetXfaDatasets trả về gói datasets đúng như nó được lưu trong tài liệu, không phải mô hình dữ liệu XFA sống, nên gọi nó trước khi save cho thấy các giá trị cũ. Sau khi mở lại, nó cho thấy đúng những gì đã được ghi. Một tài liệu single-stream không có packet nào được đặt tên riêng: PDFium report toàn bộ XDP như một packet với tên rỗng, nên GetXfaPacketByName('datasets') và GetXfaDatasets chẳng trả về gì, và con đường dự phòng đọc trọn stream qua GetXfaFormPackets

uses
  System.SysUtils, PDFium, FPdfXfa;

function ReadSavedXfaData(const FileName: string): string;
var
  Pdf: TPdf;
  Packets: TXfaPacketList;
  Bytes: TBytes;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    Bytes := Pdf.GetXfaDatasets;          // bố cục mảng packet
    if Length(Bytes) = 0 then
    begin
      Packets := Pdf.GetXfaFormPackets;   // single stream: một packet không tên
      if Length(Packets) = 1 then
      begin
        SetLength(Bytes, Length(Packets[0].Content));
        if Length(Bytes) > 0 then
          Move(Packets[0].Content[0], Bytes[0], Length(Bytes));
      end;
    end;
    Result := TEncoding.UTF8.GetString(Bytes);  // output XDP đã lưu là UTF-8
  finally
    Pdf.Free;
  end;
end;

Routine save rồi commit chỉnh sửa đang chờ, kiểm kết quả SaveAs và so giá trị khi mở lại. TPdf.ClearFormFieldFocus hạ focus form, khoảnh khắc PDFium commit edit buffer của field đang focus. TPdf.SetFocusedFormFieldText(const Value: WString): Boolean điền field đang focus bằng lập trình, nhưng nó dựa trên một focus mà wrapper theo dõi qua FocusFormField, hàm đi qua các widget annotation. Một trang XFA động thường không có cái nào, nên ở đó text thường tới qua input bàn phím trong TPdfView, và hàm trả về False khi chẳng field nào đang theo dõi có focus

function XmlText(const S: string): string;
begin
  Result := StringReplace(S, '&', '&amp;', [rfReplaceAll]);
  Result := StringReplace(Result, '<', '&lt;', [rfReplaceAll]);
end;

procedure SaveXfaAndVerify(Pdf: TPdf; const FileName, FieldTag,
  Expected: string);
var
  Saved: string;
begin
  // Điền qua script tùy chọn; False nghĩa là chẳng field nào đang theo dõi có focus
  if (Pdf.FocusedFormFieldIndex >= 0) and
     not Pdf.SetFocusedFormFieldText(Expected) then
    raise EPdfError.Create('Could not write the focused field');

  Pdf.ClearFormFieldFocus;              // commit edit buffer
  if not Pdf.SaveAs(FileName) then      // gồm cả lần flush cuối (v3.125.2+)
    raise EPdfError.CreateFmt('Saving %s failed', [FileName]);

  Saved := ReadSavedXfaData(FileName);
  if Pos('<' + FieldTag + '>' + XmlText(Expected) + '</' + FieldTag + '>',
    Saved) = 0 then
    raise EPdfError.CreateFmt('%s did not survive the round trip', [FieldTag]);
end;

Coi phép thử chuỗi con như một smoke test. Một phần tử rỗng có thể được serialize thành <Tag/>, attribute có thể xuất hiện trên các data element, và việc escape ngoài & và < là quyền của serializer. Với phép kiểm production, hãy nạp XML đã mở lại bằng một XML parser thật và so text node của data element bị gắn kết. Chạy phép kiểm hai lần liên tiếp nữa, vì lỗi newline chỉ lộ đủ hình dạng ở thế hệ thứ hai

Tra nhanh: danh mục kiểm độ trung thực save XFA

  • Triển khai pdfium.v8.dll từ v3.125.2 trở lên cho các form XFA, và v3.125.3 trở lên cho pdfium.dll thường, để bản sửa lần-ghi-cuối có ở cả hai
  • Trỏ LibraryName vào một đường dẫn đầy đủ và đặt EnableV8Engine thành True; một đường dẫn thiếu nêu lỗi thay vì nạp một bản sao khác
  • Xác nhận TPdf.XFA và TPdf.XfaRuntimeAvailable sau khi mở tài liệu
  • Gọi ClearFormFieldFocus trước SaveAs để field đang focus được commit
  • Đừng bao giờ bỏ qua kết quả Boolean của SaveAs; một kết quả False để nguyên tệp cũ
  • Verify bằng cách mở lại trong một TPdf mới và đọc GetXfaDatasets, rơi về GetXfaFormPackets cho XFA single-stream
  • Test với giá trị rỗng, dấu cách đầu dòng, text đa dòng, & và một ký tự supplementary-plane, qua hai thế hệ save
  • Chờ các thất bại save tường minh cho DTD, XMLDSig và comment bên trong các packet sống của XFA single-stream
  • Nếu một form động mất hình học runtime khi mở lại, hãy kiểm root subform có restoreState="auto" trước khi nghi thư viện

Về cấu trúc callback mà XFA runtime trông chờ từ một ứng dụng chủ, xem FPDF_FORMFILLINFO version 2 và ABI XFA trong Delphi. Runtime V8, wrapper Delphi và C++Builder cùng control viewer đều nằm trong PDFium Component cho Delphi và C++Builder, thứ gồm cả hai runtime Windows cho Win32 và Win64