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

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. Стойността никога не е била загубена; тя никога не е била консултирана

FPDFPage_Flatten в Delphi изпечатва в страницата потока за външен вид на квадратчето за отметка, назован от /AS, докато стойността на полето /V никога не се консултира, така че остарял /AS /Off рендира изравненото квадратче без отметка
Flattening-ът чете /AS селектора и никога не консултира /V, затова остарелият Off вид се запича, макар стойността на полето все още да казва, че кутията е включена

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

// Грешно: чете директно речника на анотацията на уиджета
buflen := FPDFAnnot_GetStringValue(Annot, 'V', nil, 0);
// За повечето реални формуляри buflen се връща като 2 (празен UTF-16 низ),
// така че /AS никога не се записва и кутията се изравнява като Off

{ Как изглеждат двата обекта, когато полето има няколко уиджета:

  12 0 obj                          % речник на полето (родителят)
  << /FT /Btn  /T (Consent)  /V /On
     /Kids [ 13 0 R 14 0 R ] >>
  endobj

  13 0 obj                          % анотация на уиджета (наследник)
  << /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 обекта, така че връща правилния отговор както за слети, така и за разделени оформления

Обектен модел на поле-квадратче за отметка в Delphi: родителският речник на полето притежава /V /On, докато дъщерните уиджет анотации носят само /AS и /AP, така че FPDFAnnot_GetStringValue не намира нищо, а FPDFAnnot_GetFormFieldValue разрешава родителя и връща On
Стойността живее на родителския field речник, докато kid уиджетите носят само /AS и /AP, затова четене на /V на ниво уиджет се връща празно, а form-field API разрешава родителя вместо това
FPDF_FORMFIELD_CHECKBOX, FPDF_FORMFIELD_RADIOBUTTON:
  begin
    // /AP е предварително изграден за състояние; само /AS трябва да се синхронизира с /V.
    // FPDFAnnot_GetFormFieldValue разрешава родителския речник на полето,
    // който е мястото, където ISO 32000-1 12.7.5.2 пази стойността.
    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 и ги изпича без оплакване, което е тиха загуба на данни, а не грешка

Фиксиран ред на операциите за изравняване на формуляр в Delphi: включи попълване на формуляр, задай стойности на полета, регенерирай външни видове, изравни, после запиши — с тиха загуба на данни, когато регенерирането на външни видове е пропуснато
Form fill трябва да е включен, преди стойностите да се присвоят, GenerateFormAppearances трябва да синхронизира /AS преди flattening, а пропускането на тази стъпка позволява на flattening да запече остарели потоци без грешка
Pdf.FileName := FormPath;
Pdf.FormFill := True;          // изисква се: FormHandle трябва да съществува
Pdf.Active := True;

Pdf.FormField[0] := 'On';      // записва само /V

Pdf.GenerateFormAppearances;   // синхронизира /AS за бутони, преизгражда /AP за текст
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 полета, описани тук, като обикновени свойства и методи