Articol tehnic

Bug de flatten checkbox PDF: valoare de câmp vs widget în Delphi

Checkbox-urile și radio button-urile se aplatizează ca debifate pentru că starea de aspect /AS nu era niciodată sincronizată cu valoarea de câmp /V. PDFium Component, componenta VCL și LCL bazată pe PDFium pentru Delphi, C++Builder și Lazarus, citește acum acea valoare cu FPDFAnnot_GetFormFieldValue, care rezolvă dicționarul de câmp părinte, în loc de adnotarea widget

Raportul de bug care a dus aici e genul în care nu ai încredere la început. Un client aplatizează un formular de consimțământ semnat, deschide rezultatul, și fiecare checkbox e gol. Deschide fișierul sursă în Acrobat, iar căsuțele sunt vizibil bifate. Citește fișierul sursă înapoi prin aceeași componentă, iar valorile de câmp sunt corecte. Doar output-ul aplatizat le pierde, și doar pentru checkbox-uri și radio button-uri: câmpurile de text de pe aceeași pagină ies bine

De ce sunt checkbox-urile debifate după aplatizare?

Pentru că aplatizarea nu se uită niciodată la /V. FPDFPage_Flatten coace stream-ul de aspect al widget-ului în conținutul paginii, iar aspectul pe care-l alege e cel numit de /AS. Dacă /AS încă spune /Off, în timp ce valoarea de câmp spune că bifa e activă, aplatizarea coace fidel aspectul off. Valoarea nu a fost niciodată pierdută; pur și simplu nu a fost niciodată consultată

ISO 32000-1 §12.5.5 definește dicționarul de aspect /AP cu trei intrări posibile, /N, /R și /D. Pentru un checkbox sau radio button, intrarea /N nu e un stream, ci un subdicționar ale cărui chei sunt nume de stări de aspect, iar §12.5.2 face din /AS selectorul obligatoriu atunci când /N e un subdicționar. Așa că un checkbox poartă două aspecte pregătite dinainte și un singur pointer. Greșește pointerul și randarea e greșită într-un mod pe care nicio cantitate de /V corect nu-l va repara. Asta explică și de ce modul de eșec diferă de câmpurile de text, care nu au deloc un aspect pregătit dinainte de selectat: un /N de câmp de text e un singur stream care trebuie regenerat de la zero după ce valoarea se schimbă, așa că GenerateFormAppearances tratează cele două cazuri prin căi de cod complet separate, iar doar calea de buton era stricată

Unde trăiește de fapt valoarea checkbox-ului?

Pe dicționarul de câmp, nu pe widget. ISO 32000-1 §12.7.5.2 descrie checkbox-urile și radio button-urile ca și câmpuri de buton al căror /V e un obiect de nume care numește starea curentă de aspect, iar §12.7.3.1 plasează /V printre intrările comune tuturor dicționarelor de câmp. Adnotarea widget definită în §12.5.6.19 contribuie cu /AS și /AP. Nimic din specificație nu obligă un widget să poarte /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 nu e defectă. Contractul ei e exact ce spune numele ei: preia o intrare de tip șir din dicționarul de adnotare pe care i l-ai dat. A o întreba de /V pe obiectul 13 nu returnează nimic, pentru că obiectul 13 chiar nu are /V. Defectul era în apelant, care presupunea un model plat de obiecte pe care ISO 32000-1 nu l-a promis niciodată

Când partajează câmpul și widget-ul un singur dicționar?

Oricând un câmp are exact un widget. §12.5.6.19 permite ca dicționarul de câmp și singura lui adnotare widget să fie unite într-un singur obiect, iar majoritatea uneltelor de autorare iau acea scurtătură. Într-un obiect unit, /FT, /T, /V, /AS și /AP stau toate una lângă alta, așa că o citire de /V la nivel de widget reușește, iar întregul bug rămâne invizibil

Din momentul în care un câmp deține două sau mai multe widget-uri, unirea e imposibilă, iar §12.7.3.1 cere ca widget-urile să devină /Kids ai unui dicționar de câmp separat. Fiecare grup radio e în această formă prin construcție. La fel și checkbox-urile de consimțământ repetate într-un header și un footer, și orice câmp pe care o unealtă de autorare l-a copiat pe a doua pagină. Aceasta e întreaga explicație pentru care defectul a supraviețuit unei suite de regresie: corpusul de test era plin de formulare cu un singur widget, iar fișierele clienților nu erau. Dacă parcurgi widget-urile tu însuți, în loc să te bazezi pe componentă, aceeași asimetrie apare în ordinea de enumerare, iar notele despre navigarea câmpurilor de formular PDF cu PDFium Component acoperă cum se leagă o parcurgere de adnotări la nivel de pagină de arborele de câmpuri la nivel de document

Citirea valorii în modul intenționat de PDFium

FPDFAnnot_GetFormFieldValue e API-ul corect, și era legat în componentă de ceva vreme, fără ca traseul de checkbox să-l folosească. Ia atât handle-ul de formular, cât și adnotarea, ceea ce e semnalul important: cu mediul de form-fill disponibil, PDFium rezolvă adnotarea la controlul ei de formular și citește valoarea din obiectul de câmp, așa că returnează răspunsul corect deopotrivă pentru layout-uri unite și despărțite

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;

Două detalii din acel fragment sunt ușor de greșit. Lungimea returnată e un număr de bytes pentru text UTF-16, inclusiv terminatorul, așa că numărul de caractere e buflen div 2 - 1, iar o valoare de 2 înseamnă un șir gol. Garda buflen >= 4 înseamnă deci cel puțin un caracter real, ceea ce ține un câmp fără niciun /V să nu-și aibă /AS-ul suprascris cu un nume gol

La ce se înțeleg de fapt /AS și /AP /N

Se înțeleg la un nume, iar numele e ales de oricine a produs fișierul. §12.7.5.2 cere ca starea off să se numească /Off, și lasă starea on în întregime la latitudinea producătorului. /Yes e o convenție, nu o regulă. Acrobat scrie /Yes, dar o mulțime de generatoare scriu /On, /1, /Choice1, sau un cuvânt localizat, iar un grup radio dă în mod normal fiecărui copil un nume distinct de stare on, astfel încât grupul să poată exprima ce buton e selectat. Exact de asta copierea lui /V cuvânt cu cuvânt în /AS e operația corectă, nu un hack: pentru un control bifat, PDFium raportează numele stării on pe care fișierul însuși îl definește, iar pentru unul debifat raportează Off, așa că valoarea pe care o scrii în /AS e garantată să fie o cheie care există în acel subdicționar /AP /N al widget-ului. A hard-coda /Yes ar funcționa pe output-ul Acrobat și s-ar strica silențios peste tot altundeva

Ordinea operațiilor, și unde încă necesită grijă

Secvența e fixă și neiertătoare: activează form fill, atribuie valorile, regenerează aspectele, aplatizează, apoi salvează. Sări peste pasul de regenerare, iar FPDFPage_Flatten găsește stream-uri de aspect goale sau învechite și le coace fără să obiecteze, ceea ce e o pierdere silențioasă de date, nu o întoarcere de eroare

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');

Rămân două limite oneste. Întâi, sincronizarea scrie valoarea de câmp în /AS-ul fiecărui widget al acelui câmp, ceea ce e corect pentru checkbox-uri, dar aproximativ pentru grupuri radio ai căror copii definesc fiecare propriul lor nume de stare on; un copil al cărui /AP /N nu are nicio intrare care să se potrivească cu /AS-ul scris nu are niciun aspect de selectat conform §12.5.5, așa că un buton neselectat se poate aplatiza la nimic, în loc de un cerc gol. A audita un grup radio cu FPDFAnnot_GetFormControlIndex înainte de aplatizare merită cele câteva linii. În al doilea rând, nimic din toate astea nu se aplică la XFA, unde valoarea trăiește într-un pachet de date XML, nu în dicționarele AcroForm, o separare acoperită în notele despre editările de câmp XFA care nu sunt persistate. Lecția generală merită păstrată dincolo de acest singur fix: oricând un API ia handle-ul de formular în plus față de adnotare, îți spune că va rezolva ierarhia de câmp pentru tine, iar oricând ia doar adnotarea, va citi exact obiectul pe care l-ai dat. Acea distincție guvernează și schimbul de date, pentru că exportul și importul datelor de formular XFDF lucrează în nume de câmp complet calificate, niciodată în poziții de widget

Aplatizarea de formular e unul dintre acele feature-uri care arată ca un singur apel de API și se dovedește a fi un contract între trei dicționare. Dacă preferi să lucrezi contra unei componente care deja encodează acel contract, PDFium Component pentru Delphi și C++Builder livrează regenerarea de aspect, aplatizarea și accesul la câmpuri de formular descrise aici, ca proprietăți și metode obișnuite