Artikel Teknis

Bug Flatten Checkbox PDF: Nilai Field vs Widget di Delphi

Checkbox dan radio button ter-flatten sebagai unchecked karena appearance state /AS tidak pernah disinkronkan dengan field value /V. PDFium Component, komponen VCL dan LCL berbasis PDFium untuk Delphi, C++Builder, dan Lazarus, kini membaca nilai itu dengan FPDFAnnot_GetFormFieldValue, yang meresolusi parent field dictionary alih-alih widget annotation

Laporan bug yang membawa ke sini adalah jenis yang tadinya Anda ragukan. Seorang pelanggan melakukan flatten pada sebuah consent form yang sudah ditandatangani, membuka hasilnya, dan setiap checkbox kosong. Buka berkas sumbernya di Acrobat dan kotak-kotaknya terlihat tercentang. Baca kembali berkas sumbernya lewat komponen yang sama dan field value-nya benar. Hanya output hasil flatten yang kehilangannya, dan hanya untuk checkbox serta radio button: text field pada halaman yang sama keluar dengan baik

Kenapa checkbox jadi unchecked setelah flatten?

Karena flatten tidak pernah melihat /V. FPDFPage_Flatten memanggang appearance stream widget ke dalam page content, dan appearance yang dipilihnya adalah yang dinamai oleh /AS. Jika /AS masih menyatakan /Off sementara field value menyatakan kotaknya on, flatten dengan setia memanggang appearance off tersebut. Nilainya tidak pernah hilang; ia tidak pernah dikonsultasikan

ISO 32000-1 §12.5.5 mendefinisikan appearance dictionary /AP dengan tiga entri yang mungkin, /N, /R, dan /D. Untuk sebuah check box atau radio button, entri /N bukanlah sebuah stream melainkan sebuah subdictionary yang key-nya adalah nama appearance state, dan §12.5.2 menjadikan /AS sebagai selector wajib ketika /N adalah sebuah subdictionary. Jadi sebuah checkbox membawa dua appearance yang sudah dibangun sebelumnya dan satu pointer. Salahkan pointer itu dan rendering-nya akan salah dengan cara yang tidak akan diperbaiki oleh /V yang benar sebanyak apa pun. Inilah juga alasan kenapa mode kegagalannya berbeda dari text field, yang sama sekali tidak punya appearance prebuilt untuk dipilih: /N pada text field adalah sebuah stream tunggal yang harus diregenerasi dari awal setelah nilainya berubah, sehingga GenerateFormAppearances menangani kedua kasus itu lewat code path yang sepenuhnya terpisah, dan hanya jalur button yang rusak

Di mana sebenarnya nilai checkbox itu berada?

Pada field dictionary, bukan pada widget. ISO 32000-1 §12.7.5.2 mendeskripsikan check box dan radio button sebagai button field yang /V-nya adalah sebuah name object yang menyebut appearance state saat ini, dan §12.7.3.1 menempatkan /V di antara entri yang umum bagi semua field dictionary. Widget annotation yang didefinisikan di §12.5.6.19 menyumbangkan /AS dan /AP. Tidak ada apa pun dalam spesifikasi yang mewajibkan sebuah widget membawa /V

// Wrong: reads the widget annotation dictionary directly
buflen := FPDFAnnot_GetStringValue(Annot, 'V', nil, 0);
// For most real forms buflen comes back as 2 (an empty UTF-16 string),
// so /AS is never written and the box flattens as Off

{ What the two objects look like when the field has several widgets:

  12 0 obj                          % field dictionary (the parent)
  << /FT /Btn  /T (Consent)  /V /On
     /Kids [ 13 0 R 14 0 R ] >>
  endobj

  13 0 obj                          % widget annotation (a kid)
  << /Type /Annot  /Subtype /Widget  /Parent 12 0 R
     /AS /Off
     /AP << /N << /On 20 0 R  /Off 21 0 R >> >> >>
  endobj }

FPDFAnnot_GetStringValue tidak cacat. Kontraknya persis seperti apa yang dikatakan namanya: ambil sebuah entri string dari annotation dictionary yang Anda serahkan kepadanya. Memintanya untuk /V pada objek 13 mengembalikan ketiadaan karena objek 13 memang sungguh-sungguh tidak punya /V. Cacatnya ada pada caller, yang mengasumsikan sebuah model objek datar yang tidak pernah dijanjikan ISO 32000-1

Kapan field dan widget berbagi satu dictionary?

Setiap kali sebuah field memiliki persis satu widget. §12.5.6.19 mengizinkan field dictionary dan widget annotation tunggalnya digabung menjadi satu objek, dan kebanyakan authoring tool mengambil jalan pintas itu. Dalam sebuah objek yang tergabung, /FT, /T, /V, /AS, dan /AP semuanya duduk berdampingan, sehingga sebuah pembacaan /V level-widget berhasil dan seluruh bug ini tetap tak terlihat

Begitu sebuah field memiliki dua widget atau lebih, penggabungan itu menjadi mustahil, dan §12.7.3.1 mensyaratkan widget-widget itu menjadi /Kids dari sebuah field dictionary yang terpisah. Setiap radio group berada dalam bentuk ini menurut konstruksinya. Begitu juga consent checkbox yang diulang di sebuah header dan footer, dan field apa pun yang disalin sebuah authoring tool ke halaman kedua. Itulah keseluruhan penjelasan kenapa cacat ini bertahan lolos dari sebuah regression suite: corpus test-nya penuh dengan form single-widget dan berkas pelanggan tidak. Bila Anda menelusuri widget sendiri alih-alih mengandalkan komponennya, asimetri yang sama muncul dalam urutan enumerasi, dan catatan tentang navigasi form field PDF dengan PDFium Component membahas bagaimana sebuah penelusuran annotation level-halaman berkaitan dengan field tree level-dokumen

Membaca nilai sebagaimana dimaksudkan PDFium

FPDFAnnot_GetFormFieldValue adalah API yang benar, dan API ini sudah terikat dalam komponen selama beberapa waktu tanpa dipakai oleh jalur checkbox. Ia mengambil form handle selain annotation-nya, dan itulah sinyal yang penting: dengan lingkungan form-fill tersedia, PDFium meresolusi annotation-nya ke form control-nya dan membaca nilai dari objek field, sehingga ia mengembalikan jawaban yang benar baik untuk layout yang digabung maupun yang terpisah

FPDF_FORMFIELD_CHECKBOX, FPDF_FORMFIELD_RADIOBUTTON:
  begin
    // /AP is prebuilt per state; only /AS has to be synchronised with /V.
    // FPDFAnnot_GetFormFieldValue resolves the parent field dictionary,
    // which is where ISO 32000-1 12.7.5.2 keeps the value.
    buflen := FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, nil, 0);
    if buflen >= 4 then
    begin
      SetLength(OrigVal, buflen div 2 - 1);
      FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, PWideChar(OrigVal), buflen);
      FPDFAnnot_SetStringValue(Annot, 'AS', Pointer(OrigVal));
    end;
  end;

Ada dua detail dalam cuplikan itu yang mudah salah. Panjang yang dikembalikan adalah hitungan byte untuk teks UTF-16 termasuk terminatornya, sehingga jumlah karakternya adalah buflen div 2 - 1 dan sebuah nilai 2 berarti string kosong. Guard buflen >= 4 karena itu berarti setidaknya satu karakter sungguhan, yang menjaga agar sebuah field yang sama sekali tidak punya /V tidak memiliki /AS-nya ditimpa dengan sebuah nama kosong

Apa yang benar-benar disepakati /AS dan /AP /N

Mereka sepakat pada sebuah nama, dan namanya dipilih oleh siapa pun yang membuat berkasnya. §12.7.5.2 mensyaratkan state off dinamai /Off, dan sepenuhnya menyerahkan state on ke produsennya. /Yes adalah sebuah konvensi, bukan sebuah aturan. Acrobat menulis /Yes, tapi banyak generator menulis /On, /1, /Choice1, atau sebuah kata lokal, dan sebuah radio group biasanya memberi setiap kid sebuah nama on-state yang berbeda sehingga grup itu bisa menyatakan tombol mana yang terpilih. Inilah persisnya kenapa menyalin /V apa adanya ke dalam /AS adalah operasi yang benar, bukan sebuah akal-akalan: untuk sebuah control yang tercentang, PDFium melaporkan nama on-state yang didefinisikan berkasnya sendiri, dan untuk yang tidak tercentang ia melaporkan Off, sehingga nilai yang Anda tulis ke dalam /AS dijamin menjadi sebuah key yang ada di dalam subdictionary /AP /N widget itu. Hard-coding /Yes akan bekerja pada output Acrobat dan diam-diam rusak di tempat lain mana pun

Urutan operasi, dan di mana masih dibutuhkan kehati-hatian

Urutannya tetap dan tidak memaafkan kesalahan: aktifkan form fill, tetapkan nilai, regenerasi appearance, flatten, lalu save. Lewati langkah regenerasi dan FPDFPage_Flatten menemukan appearance stream yang kosong atau basi dan memanggangnya tanpa keluhan, yang merupakan kehilangan data diam-diam alih-alih sebuah return error

Pdf.FileName := FormPath;
Pdf.FormFill := True;          // required: FormHandle must exist
Pdf.Active := True;

Pdf.FormField[0] := 'On';      // writes /V only

Pdf.GenerateFormAppearances;   // syncs /AS for buttons, rebuilds /AP for text
if Pdf.FlattenAllPages(FLAT_PRINT) then
  Pdf.SaveAs('consent-flat.pdf');

Ada dua batasan jujur yang tersisa. Pertama, sinkronisasi ini menulis field value ke dalam /AS dari setiap widget dari field itu, yang benar untuk checkbox tapi hanya perkiraan untuk radio group yang setiap kid-nya mendefinisikan nama on-state-nya sendiri; sebuah kid yang /AP /N-nya tidak punya entri yang cocok dengan /AS yang ditulis tidak punya appearance untuk dipilih menurut §12.5.5, sehingga sebuah tombol yang tidak terpilih bisa ter-flatten menjadi ketiadaan alih-alih sebuah lingkaran kosong. Mengaudit sebuah radio group dengan FPDFAnnot_GetFormControlIndex sebelum flatten sepadan dengan beberapa baris kode itu. Kedua, tidak satu pun dari ini berlaku untuk XFA, tempat nilainya berada dalam sebuah paket data XML alih-alih dictionary AcroForm, sebuah pemisahan yang dibahas dalam catatan tentang edit field XFA yang tidak persisten. Pelajaran umumnya layak dipertahankan melampaui perbaikan tunggal ini: setiap kali sebuah API mengambil form handle selain annotation-nya, itu memberi tahu Anda bahwa ia akan meresolusi field hierarchy untuk Anda, dan setiap kali ia hanya mengambil annotation-nya, ia akan membaca persis objek yang Anda serahkan. Perbedaan itu juga mengatur pertukaran data, karena mengekspor dan mengimpor data form XFDF bekerja dalam nama field yang fully qualified, tidak pernah dalam posisi widget

Form flattening adalah salah satu fitur yang terlihat seperti satu pemanggilan API tapi ternyata adalah sebuah kontrak di antara tiga dictionary. Bila Anda lebih memilih bekerja dengan komponen yang sudah mengkodekan kontrak itu, PDFium Component untuk Delphi dan C++Builder menghadirkan regenerasi appearance, flattening, dan akses form field yang dijelaskan di sini sebagai property dan method biasa