Техническа статия

PDF Checkbox Flatten бъг: Field Value срещу Widget в Delphi

Чекбоксовете и radio бутоните се flatten-ват необозначени, защото appearance state /AS никога не е бил синхронизиран с field стойността /V. PDFium Component, PDFium-базираният VCL и LCL компонент за Delphi, C++Builder и Lazarus, сега чете тази стойност с FPDFAnnot_GetFormFieldValue, който разрешава родителския field речник, вместо widget анотацията

Bug report-ът, довел дотук, е от вида, на който не се доверявате отначало. Клиент flatten-ва подписан consent формуляр, отваря резултата, и всеки чекбокс е празен. Отворете source файла в Acrobat и кутиите са видимо отметнати. Прочетете source файла обратно през същия компонент и field стойностите са правилни. Само flatten-натият изход ги губи, и то само за чекбоксове и radio бутони: текстовите полета на същата страница излизат наред

Защо чекбоксовете са необозначени след flattening?

Защото flattening никога не поглежда /V. FPDFPage_Flatten изпича appearance stream-а на widget-а в съдържанието на страницата, а appearance-ът, който избира, е назованият от /AS. Ако /AS все още казва /Off, докато field стойността казва, че кутията е включена, flattening вярно изпича изключения appearance. Стойността никога не е била загубена; тя никога не е била консултирана

ISO 32000-1 §12.5.5 дефинира appearance речника /AP с три възможни записа, /N, /R, и /D. За check box или radio button записът /N не е stream, а под-речник, чиито ключове са имена на appearance states, а §12.5.2 прави /AS задължителния селектор, когато /N е под-речник. Така чекбокс носи два предварително построени appearance-а и един указател. Сбъркайте указателя, и рендирането е грешно по начин, който никакво коректно /V не може да поправи. Това е и защо режимът на провал се различава от текстовите полета, които нямат предварително построен appearance за избор изобщо: текстово поле /N е единичен stream, който трябва да бъде регенериран от нулата след промяна на стойността, така че GenerateFormAppearances обработва двата случая чрез напълно отделни пътища на кода, и само пътят за бутони беше повреден

Къде всъщност живее стойността на чекбокс?

На field речника, не на widget-а. ISO 32000-1 §12.7.5.2 описва check boxes и radio buttons като button полета, чието /V е name обект, назоваващ текущия appearance state, а §12.7.3.1 поставя /V сред записите, общи за всички field речници. Widget анотацията, дефинирана в §12.5.6.19, допринася /AS и /AP. Нищо в спецификацията не задължава widget да носи /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 не е дефектен. Неговият договор е точно това, което името му казва: извлечи string запис от речника на анотацията, който сте му подали. Молбата за /V на обект 13 не връща нищо, защото обект 13 наистина няма /V. Дефектът беше в извикващия, който предполагаше плосък обектен модел, който ISO 32000-1 никога не е обещавал

Кога поле и widget споделят един речник?

Винаги когато поле има точно един widget. §12.5.6.19 позволява field речникът и неговата единствена widget анотация да бъдат слети в един обект, и повечето инструменти за авторство поемат този пряк път. В слят обект /FT, /T, /V, /AS, и /AP всички седят рамо до рамо, така че четене на /V на ниво widget успява, и целият бъг остава невидим

В момента, в който поле притежава два или повече widget-а, сливането е невъзможно, а §12.7.3.1 изисква widget-ите да станат /Kids на отделен field речник. Всяка radio група е в тази форма по конструкция. Такива са и consent чекбоксове, повторени в header и footer, и всяко поле, което инструмент за авторство е копирал на втора страница. Това е цялото обяснение защо дефектът е оцелял в regression suite: тестовият корпус беше пълен с формуляри с единичен widget, а файловете на клиента не бяха. Ако обхождате widget-и сами, вместо да разчитате на компонента, същата асиметрия се проявява в реда на изброяване, а бележките за навигация на PDF form полета с PDFium Component покриват как обход на ниво страница на анотации се отнася към дървото от полета на ниво документ

Четене на стойността по начина, по който PDFium възнамерява

FPDFAnnot_GetFormFieldValue е правилното API, и е било свързано в компонента известно време, без пътят за чекбокс да го използва. То приема form handle-а в допълнение на анотацията, което е сигналът, който има значение: с наличен form-fill environment, PDFium разрешава анотацията до нейния form control и чете стойността от field обекта, така че връща правилния отговор както за слети, така и за разделени оформления

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;

Два детайла в откъса лесно се объркват. Върнатата дължина е брой байтове за UTF-16 текст, включително терминатора, така че броят символи е buflen div 2 - 1, а стойност 2 означава празен низ. Проверката buflen >= 4 затова означава поне един реален символ, което е това, което пази поле без никакво /V от това /AS му да бъде презаписано с празно име

За какво всъщност се съгласяват /AS и /AP /N

Съгласяват се за име, а името е избрано от този, който е произвел файла. §12.7.5.2 изисква изключеното състояние да се казва /Off, и оставя включеното изцяло на производителя. /Yes е конвенция, не правило. Acrobat пише /Yes, но доста генератори пишат /On, /1, /Choice1, или локализирана дума, а radio група обикновено дава на всяко дете отделно име за включено състояние, така че групата да може да изрази кой бутон е избран. Точно затова копирането на /V буквално в /AS е правилната операция, а не хак: за отметнат control PDFium отчита името на включеното състояние, което самият файл дефинира, а за неотметнат отчита Off, така че стойността, която записвате в /AS, е гарантирано ключ, съществуващ в под-речника /AP /N на този widget. Hardcode-ването на /Yes би работило върху изход на Acrobat и тихо би се провалило навсякъде другаде

Ред на операциите, и къде все още е нужно внимание

Последователността е фиксирана и непростима: включи form fill, задай стойности, регенерирай appearances, flatten, после save. Пропуснете стъпката за регенерация, и FPDFPage_Flatten намира празни или остарели appearance streams и ги изпича без оплакване, което е тиха загуба на данни, а не грешка

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

Остават два честни лимита. Първо, синхронизацията записва field стойността в /AS на всеки widget на това поле, което е правилно за чекбоксове, но приблизително за radio групи, чиито деца всяко дефинира собствено име за включено състояние; дете, чието /AP /N няма запис, съвпадащ със записаното /AS, няма appearance за избор според §12.5.5, така че неизбран бутон може да се flatten-не до нищо вместо празен кръг. Одитирането на radio група с FPDFAnnot_GetFormControlIndex преди flattening си струва малкото редове код. Второ, нищо от това не важи за XFA, където стойността живее в XML пакет данни, вместо в AcroForm речниците, разделение, разгледано в бележките за XFA редакции на полета, които не се съхраняват. Общият урок си струва да се пази отвъд тази поправка: винаги когато API приема form handle в допълнение на анотацията, то ви казва, че ще разреши йерархията от полета вместо вас, а винаги когато приема само анотацията, ще прочете точно обекта, който сте подали. Тази разлика управлява и обмена на данни, тъй като експортирането и импортирането на XFDF form данни работи с напълно квалифицирани имена на полета, никога с позиции на widget-и

Flattening на формуляр е една от онези функции, изглеждаща като едно извикване на API и оказваща се договор между три речника. Ако предпочитате да работите срещу компонент, който вече кодира този договор, PDFium Component за Delphi и C++Builder доставя регенерацията на appearance, flattening, и достъпа до form полета, описани тук, като обикновени свойства и методи