Technisch artikel

Formulierveldwaarden zetten in een geladen PDF met Delphi

In de veldlijst van bijna elke PDF-viewer leest het straatveld op een Duitstalig claimformulier als Straße. De bytes achter zijn /T-entry vertellen een ander verhaal: <FEFF005300740072006100DF0065>, een hexadecimale UTF-16BE-string met een byte order mark ervoor. Code die veldnamen tegen die rauwe bytes matcht, meldt het veld als ontbrekend, ook al kan een gebruiker er meteen inklikken. Hetzelfde formulier kan de on-state van een checkbox als 2 bewaren in plaats van Yes, en zijn landenlijst als [export display]-paren, wat je drie aparte manieren geeft waarop de waarde die je schrijft en de waarde die een viewer tekent uit elkaar kunnen lopen

Alle drie de variaties komen uit dezelfde hoek: een formulier dat jaren geleden is gebouwd in welke authoring-tool de klant maar toevallig had, en dat je Delphi-toepassing nu vanuit een database moet vullen en terug moet geven als een bestand dat er in elke lezer hetzelfde uitziet. Je kunt het formulier niet opnieuw maken, dus de vulcode moet de regels volgen waaronder het formulier is geschreven. ISO 32000-1 heeft voor elk van de drie gevallen een regel: §7.9.2.2 voor text strings, §12.7.4.2.3 voor button appearance states, Table 231 in §12.7.4.4 voor choice-selections. Velden aanmaken die nog niet bestaan is een ander werk, dat staat in AcroForm-velden toevoegen aan een geladen PDF in Delphi

Het veld vinden: HotPDF decodeert /T voordat hij namen matcht

THotPDF.SetFormFieldValue heeft twee overloads, één met een nulgebaseerde veldindex en één met een volledig gekwalificeerde naam, en beide resolven tegen een namen-tabel die de component bij eerste gebruik bouwt uit de AcroForm-boom. Terwijl die tabel wordt gebouwd, gaat elke partiele naam door een decoder. Een hexadecimale /T-string wordt teruggezet naar bytes; beginnen die bytes met FE FF en is de lengte even, dan wordt de rest als UTF-16BE gelezen en opnieuw gecodeerd als UTF-8. Elke gedecodeerde partiele naam wordt daarna met een punt aan zijn parent vastgeknopt, precies zoals §12.7.3.1 dat beschrijft, dus een kid-veld genaamd City onder een parent genaamd Address wordt geregistreerd als Address.City. Alleen de lookup-sleutel wordt gedecodeerd. Het /T-object in het bestand houdt zijn hex-spelling, en het opslaan van het document raakt de identiteit van een veld dat je alleen maar vulde niet

Sleutels worden naar kleine letters gebracht voordat ze de tabel in gaan, dus 'address.city' vindt Address.City. Dat gaat verder dan de standaard, die veldnamen als hoofdlettergevoelig behandelt, en het betekent dat twee velden die alleen in hoofdletters verschillen zouden botsen in de lookup. Voor code die veldnamen uitleest uit een configuratiebestand dat iemand met de hand typte, is een gemiste hoofdletter een stuk vaker faal oorzaak dan twee velden in één formulier die alleen in hoofdlettergebruik verschillen

Hoe HotPDF niet-ASCII AcroForm-namen resolveert: de hexadecimale /T-string wordt teruggezet naar bytes, de FE FF byte order mark wordt als UTF-16BE gedecodeerd en opnieuw gecodeerd als UTF-8, en de gekwalificeerde naam knoopt zich aan zijn parent vast zodat Applicant.FullName en een veld genaamd Straße allebei in de opzoektabel belanden
Alleen de cache-sleutel verandert: de velddictionary houdt zijn hexadecimale codering, lookups normaliseren naar kleine letters als gemak voorbij de standaard, en het opslaan van het document herschrijft nooit de identiteit van een veld dat je alleen maar vulde

De gedecodeerde namen zijn UTF-8-bytes die in een AnsiString rondrijden, en precies daar komen de eigen stringconversies van Delphi in de weg. Een literal als 'Applicant.Straße' die aan een AnsiString-parameter wordt doorgegeven, wordt via de systeem-ANSI-codepagina omgezet, dus ß komt aan als de ene byte DF in plaats van het UTF-8-paar C3 9F, en de lookup mist. ASCII-namen merken niets. Voor al het andere: enumerere de velden één keer, decodeer elke naam met UTF8ToString en schrijf via de index-overload:

// FindFieldIndex is een helper die je zelf toevoegt: hij vergelijkt de
// gedecodeerde UTF-8-namen met een Unicode-string, dus geen ANSI-code
// pagina staat tussen je literal en de lookup
function FindFieldIndex(Pdf: THotPDF; const Name: string): Integer;
var
  I: Integer;
begin
  for I := 0 to Pdf.GetFormFieldCount - 1 do
    if SameText(UTF8ToString(Pdf.GetFormFieldName(I)), Name) then
      Exit(I);
  Result := -1;
end;

var
  Pdf: THotPDF;
  Street: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('claim-form.pdf') <= 0 then Exit;

    // ASCII-namen kunnen direct naar de by-name-overload
    Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');

    Street := FindFieldIndex(Pdf, 'Applicant.Straße');
    // De waarde is ook een AnsiString, dus een literal 'ß' zou afhangen van
    // de systeemcodepagina; FEFF-hex (Hauptstraße 12) voorkomt dat
    if Street >= 0 then
      Pdf.SetFormFieldValue(Street,
        'FEFF00480061007500700074007300740072006100DF0065002000310032');

    // Dezelfde UTF-16BE-hex met FEFF-prefix werkt ook met de by-name-
    // overload (dit is München); hij wordt als hex-string weggeschreven
    Pdf.SetFormFieldValue('Applicant.City', 'FEFF004D00FC006E006300680065006E');

    Pdf.SaveLoadedDocument('claim-form-filled.pdf');
  finally
    Pdf.Free;
  end;
end;

Wat SetFormFieldValue schrijft, stap voor stap

Beide overloads draaien dezelfde sequentie nadat het veld is gevonden: schrijf /V, herbouw de choice-selectie-index, markeer het veld als gewijzigd, wissel button appearance states, regenereer de tekst- of choice-appearance en registreer het veld in een dirty-set. Die laatste stap voedt de parameterloze RecalculateLoadedFormFieldsIncremental, die alleen de calculatiescripts opnieuw draait die een gewijzigd veld lezen, direct of via een keten als regelitem, subtotaal, totaal

Het objecttype van de nieuwe /V hangt af van het veld. Voor een checkbox of radioknop schrijft SetFormFieldValue altijd een name object, want §12.7.4.2.3 bewaart de geselecteerde state als naam en PDF-namen zijn kale bytes zonder text-string-codering. Elk ander veld krijgt een string object, en de waarde die je doorgeeft wordt eerst bekeken. Een string die met FEFF begint, een even lengte heeft en alleen hex-cijfers bevat, wordt opgevat als de UTF-16BE-vorm uit §7.9.2.2 en geserialiseerd als <FEFF...>. Daar leunt de City-regel hierboven op. Al het andere wordt als literal string opgeslagen met exact de bytes die je aanleverde, dus platte Latijnse tekst gaat als platte tekst erin

Waarom houdt een checkbox zijn oude vinkje na de waardewijziging?

Bij een buttonveld bepaalt de waarde niet wat er getekend wordt. Een checkbox-widget draagt een /AS-entry die benoemt welke stream in /AP /N momenteel zichtbaar is, en viewers tekenen vanuit /AS, niet vanuit /V. Zet /V op Yes terwijl /AS nog steeds Off zegt en het bestand tegenspreekt zichzelf; een flattener zet het niet-aangevinkte hokje vast in de pagina terwijl de formulierdata beweert dat hij aan staat. Daarom bezoekt SetFormFieldValue het buttonveld en elke entry in zijn /Kids-array, leest de on-state-naam uit de /AP /N van elke widget en zet /AS op die naam wanneer hij met de veldwaarde matcht, of op Off wanneer niet

Waarom een HotPDF-checkbox zijn oude vinkje houdt als alleen /V verandert: viewers tekenen vanuit de /AS appearance state in /AP /N, dus SetFormFieldValue bezoekt het veld en elke kid, leest de on-state-naam als de eerste sleutel anders dan Off en herschrijft /AS bij match of naar Off anders
Radiogroepen vergelijken elke kid met de waarde die via de /Parent-keten van het parentveld wordt geërfd, dus de groep op één exportwaarde zetten zet exact die widget aan en elke sibling uit

Echte formulieren buigen die regel op twee manieren. De off-appearance heet Off in de standaard, maar authoring-tools laten zijn stream vaak helemaal weg en laten de viewer niets tekenen, dus een normaal appearance dictionary met één enkele entry is prima legaal. De on-state-naam is wat de auteur koos: Yes, On, 2, een woord in de eigen taal van het formulier. De on-state wordt daarom genomen als de eerste sleutel in /AP /N die niet Off is, en de vergelijking met jouw waarde negeert hoofdletters. Radioknoppen voegen de opbouw uit §12.7.4.2.4 toe: de geselecteerde waarde woont in /V op het parentveld, terwijl elke kid-widget meestal geen eigen /V heeft. Elke kid wordt vergeleken met de waarde die via zijn /Parent-keten wordt geërfd

// Checkbox: de waarde moet gelijk zijn aan de on-state-sleutel in /AP /N
// (hier 'Yes'; de formulierontwerper koos misschien '2' of 'On')
Pdf.SetFormFieldValue('Consent', 'Yes');

// Radiogroep: /V komt op de parent als naam; elke kid-widget
// krijgt /AS gezet op zijn eigen on-state-naam of op Off
Pdf.SetFormFieldValue('PaymentMethod', 'Card');

// Checkbox wissen: een waarde die geen on-state matcht geeft /AS Off
Pdf.SetFormFieldValue('Newsletter', 'Off');

De keerzijde is dat een typefout geen exception oplevert. 'yes ' met een spatie achteraan matcht niets, dus het hokje belandt op Off en het bestand is nog steeds volkomen geldig. Als je waarden uit een databasekolom komen, trim ze vóór de aanroep

Keuzevelden: /I in de pas houden met /V

Een combobox of listbox bewaart zijn selectie op twee plekken. Naast /V definieert Table 231 /I als een array van nulgebaseerde indexen in /Opt, en een viewer die /I naar optie 0 ziet wijzen terwijl /V optie 3 benoemt, kan de verkeerde rij markeren. SetFormFieldValue herbouwt /I bij elke aanroep op een choice-veld. Hij verwijdert de eigen /I-entry van het veld vóór alles en bewerkt nooit de oude array ter plekke: een indirecte array kan met een ander veld worden gedeeld, en het muteren ervan zou ook de selectie van dat veld veranderen. Daarna resolveert hij /Opt via de /Parent-keten, want opties kunnen worden geërfd, en loopt hij de entries af. Een kale stringoptie wordt direct vergeleken, een [export display]-paar op zijn export-element, en een paar met minder dan twee elementen wordt overgeslagen

Hoe HotPDF een choice-veld consistent houdt: SetFormFieldValue verwijdert de lokale /I-array voordat hij haar aanraakt, resolveert /Opt via de /Parent-keten, vergelijkt de exporthelft van elke optie als gedecodeerde tekst, schrijft een /I met één element bij de eerste match en schrijft niets als een bewerkbare combo-waarde geen index heeft
Een kale stringoptie wordt direct vergeleken en een export-display-paar op zijn export-element, terwijl een waarde buiten /Opt terecht geen index oplevert — een verouderde /I naar de verkeerde rij zou erger zijn dan geen

De vergelijking draait op gedecodeerde tekst, dus een optie die in PDFDocEncoding staat matcht met een waarde die als UTF-16BE-hex is weggeschreven, en omgekeerd. Een scalaire waarde levert hoogstens één index op; welke multiselectie het veld ook had, hij wordt vervangen. Als niets matcht, wordt er helemaal geen /I weggeschreven. Voor een bewerkbare combobox is dat de correcte uitkomst, want §12.7.4.4 laat de gebruiker een waarde buiten de lijst typen en zo'n waarde heeft geen index om naar te wijzen

Achter de meest gehoorde klacht over comboboxes zit dezelfde regel: de aanroep slaagt, /V bevat de nieuwe tekst, en de viewer toont niets als geselecteerd. Meestal gaf de aanroeper het weergavelabel (Canada) door aan een lijst die uit [CA Canada]-paren is opgebouwd. De component kan dat niet als fout behandelen zonder bewerkbare combos te breken, dus die check hoort in jouw code thuis. GetFormFieldOptionValue geeft het export-element van elke optie terug, wat een guard kort houdt:

// SetChoiceByExport is je eigen guard, geen onderdeel van de component:
// hij weigert waarden die geen export-element in /Opt matchen
procedure SetChoiceByExport(Pdf: THotPDF; const FieldName,
  ExportValue: AnsiString);
var
  I: Integer;
begin
  for I := 0 to Pdf.GetFormFieldOptionCount(FieldName) - 1 do
    if Pdf.GetFormFieldOptionValue(FieldName, I) = ExportValue then
    begin
      // /Opt is [[US United States] [CA Canada] [MX Mexico]]:
      // 'CA' matcht het export-element en /I wordt [1]
      Pdf.SetFormFieldValue(FieldName, ExportValue);
      Exit;
    end;
  raise Exception.CreateFmt('%s is not an export value of %s',
    [string(ExportValue), string(FieldName)]);
end;

// Bewerkbare combo met vrije tekst: direct wegschrijven. /V wordt gezet,
// /I wordt verwijderd, en er wordt geen index verzonnen
Pdf.SetFormFieldValue('Title', 'Principal Engineer');

Is de nieuwe waarde zichtbaar zonder /NeedAppearances?

Ja, voor tekst- en choice-velden. Een viewer tekent de opgeslagen /AP-stream en kijkt niet naar /V, dus een nieuwe waarde met een oude appearance laat de oude tekst op het scherm en in de afdruk staan, tenzij de AcroForm-dictionary /NeedAppearances true draagt (§12.7.3.3) en de viewer daarvoor kiest. Flatteners en thumbnailgeneratoren negeren die flag vaak. SetFormFieldValue regenereert de appearance stream voor tekst- en choice-velden als onderdeel van de aanroep, met dezelfde builder als de publieke EnsureLoadedFieldAppearanceStream. De stream wordt gebouwd uit de geërfde /DA-string, de /Q-uitlijning, /MaxLen voor comb-velden en de waarde zelf. Het font dat in /DA staat wordt via de AcroForm-/DR-resources geresolveerd, dus een Type0-font houdt zijn eigen glyphs in plaats van terug te vallen op Helvetica. Buttons houden de state-appearances die hun ontwerper tekende; voor hen verplaatst alleen /AS

SetFormFieldValue is een procedure en vertelt je niet of er iets is getekend. Een widget zonder bruikbare /Rect krijgt geen stream, en kapotte formulieren met wees- of rechthoekloze widgets bestaan echt. Als het gerenderde resultaat ertoe doet, roep dan zelf EnsureLoadedFieldAppearanceStream aan nadat je de waarde hebt gezet. Hij geeft True terug zodra ten minste één widget een stream heeft gekregen. GetFormField geeft je een THPDFLoadedFormField waarvan de Index-property is wat de aanroep verwacht; dat object is van jou en moet worden vrijgegeven

var
  Field: THPDFLoadedFormField;
begin
  Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
  Field := Pdf.GetFormField('Applicant.FullName');
  try
    // False betekent dat geen widget van dit veld een rechthoek had om in
    // te tekenen; de waarde is bewaard, maar niets zal haar tonen
    if not Pdf.EnsureLoadedFieldAppearanceStream(Field.Index) then
      raise Exception.Create('No widget rectangle to paint into');
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('claim-form-filled.pdf');
end;

Checken dat /V veranderde bewijst weinig over wat een gebruiker te zien krijgt. Een test die ertoe doet rendert de pagina daarvoor en erna en bevestigt dat pixels binnen de widgetrechthoek veranderden terwijl pixels erbuiten onveranderd bleven. Wij houden die check aan, want appearance-bytes alleen hebben ons eerder misleid: een stream bevatte elke juiste byte terwijl een viewer die /NeedAppearances true honoreerde de weergave uit /V herbouwde en er niets van toonde

Waarden teruglezen, en wat er meestal op volgt

GetFormFieldValue geeft de geërfde /V van het veld terug. Een waarde die als FEFF-geprefixeerde hex is opgeslagen, komt gedecodeerd naar UTF-8 terug, dus het veld City hierboven leest terug als de UTF-8-bytes van München en UTF8ToString maakt er een Delphi-string van. Een literal string komt terug met de bytes waarmee hij werd bewaard. Roundtrip-checks doe je door aan beide kanten gedecodeerde tekst te vergelijken, niet door de spelling die je erin stopte

Veldgegevens in bulk verplaatsen tussen het formulier en een extern systeem, in plaats van één aanroep per veld, is het onderwerp van XFDF-import en -export in Delphi. Een afgerond formulier vergrendelen zodat niemand het kan bewerken is AcroForm- en XFA-velden flattenen in Delphi, dat exact de hier beschreven /AS-states en appearance streams tot statische paginacontent omzet. Wat op dat moment inconsistent is, blijft voor altijd inconsistent, dus: vullen, checken, dan flattenen

SetFormFieldValue, EnsureLoadedFieldAppearanceStream en de incrementele herberekening-API maken deel uit van de HotPDF Delphi Component, naast de aanroepen die velden in een document dat je uit Delphi of C++Builder laadde hernoemen, verwijderen en flattenen