Technisch artikel

Formulierveldwaarden zetten in een geladen PDF met Delphi

HotPDF Delphi Component vult een bestaand AcroForm-veld op een geladen PDF via THotPDF.SetFormFieldValue, aangesproken op zero-based veldindex of op volledig gekwalificeerde veldnaam. De nieuwe /V-entry schrijven is het makkelijke deel; wat de aanroep betrouwbaar maakt op formulieren uit de praktijk is dat dezelfde methode ook drie stukken state consistent houdt die onzichtbaar zijn tot ze misgaan: de gedecodeerde identiteit van het veld zodat een niet-ASCII-naam überhaupt gevonden kan worden, de /AS-appearancestate op checkbox- en radiowidgets, en de /I-selectie-indexarray op keuzevelden. De zichtbare appearance stream is een aparte, expliciete stap via EnsureLoadedFieldAppearanceStream

Het scenario is het alledaagse: een klant stuurt u zijn eigen formulier, een belastingaangifte, een verzekeringsclaim, een inkooporder die iemand jaren geleden in Acrobat heeft gebouwd, en uw Delphi-applicatie moet het uit een database vullen en een bestand teruggeven dat overal correct opent. U heeft geen invloed op hoe het formulier is gemaakt. Veldnamen kunnen UTF-16-gecodeerd zijn, exportwaarden van checkboxes kunnen 2 zijn in plaats van Yes, en comboboxen kunnen [export display]-optieparen gebruiken. Elk van die details heeft een regel in ISO 32000-1, en elke regel is iets wat SetFormFieldValue nu voor u afhandelt. Dit artikel gaat over wat het doet, waarom, en waar het ophoudt. Voor het zusterprobleem van velden aanmaken die nog niet bestaan, zie AcroForm-velden toevoegen aan een geladen PDF in Delphi

Waarom vindt SetFormFieldValue een veld met een niet-ASCII-naam niet?

Vóór v2.752.1 was het antwoord codering: het veld leefde in het bestand onder een hexadecimale UTF-16BE-naam, en de naamcache bewaarde de hex-spelling in plaats van de tekst. ISO 32000-1 §12.7.3.1 definieert de partiële veldnaam /T als een text string, en §7.9.2.2 zegt dat een text string UTF-16BE mag zijn met een voorloop FE FF byte order mark. Authoringtools serialiseren zulke namen routineus als hex-strings volgens §7.3.4.3, dus een veld dat Straße heet komt binnen als <FEFF005300740072006100DF0065>. Binnen HotPDF houdt THPDFStringObject.Value de ruwe hexadecimale tekst zolang IsHexadecimal gezet is, precies wat u wilt voor een lossless roundtrip van de originele dictionary en precies wat u niet als lookupsleutel wilt. HPDFLoadedFormTextName scheidt de twee belangen. Wanneer de relatietabel wordt opgebouwd gaat elke /T-waarde erdoorheen: is het stringobject hexadecimaal, dan herstelt HPDFHexToBytes de bytereeks; beginnen de bytes met FE FF en hebben ze even lengte, dan wordt de payload als UTF-16BE gedecodeerd en als UTF-8 opnieuw gecodeerd; het resultaat wordt daarna met een punt aan de naam van zijn parent gekoppeld tot de volledig gekwalificeerde naam die §12.7.3.1 beschrijft, dus een kind met de naam City onder een parent met de naam Address wordt geregistreerd als Address.City. De cachesleutel wordt genormaliseerd naar kleine letters, waardoor SetFormFieldValue('address.city', ...) ook slaagt; dat is een gemak bovenop de standaard, want de specificatie behandelt namen als hoofdlettergevoelig. Cruciaal is dat alleen de cachesleutel verandert. Het /T-object in de velddictionary houdt zijn hexadecimale codering, dus het opslaan van het document herschrijft niet de identiteit van een veld dat u alleen maar hebt gevuld

Hoe HotPDF niet-ASCII AcroForm-namen oplost: HPDFHexToBytes herstelt de UTF-16BE-payload achter een hexadecimale /T-string, de FE FF byte order mark wordt gedecodeerd en als UTF-8 opnieuw gecodeerd, en de gekwalificeerde naam wordt aan zijn parent gekoppeld zodat Applicant.FullName en een veld met de naam Straße beide in de lookupcache belanden
Alleen de cachesleutel verandert: de velddictionary houdt zijn hexadecimale codering, lookups normaliseren naar kleine letters als gemak bovenop de standaard, en het opslaan van het document herschrijft nooit de identiteit van een veld dat u alleen maar hebt gevuld
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('claim-form.pdf') <= 0 then Exit;

    // Gekwalificeerde namen worden uit UTF-16BE /T-strings gedecodeerd en
    // met punten aaneengezet, zodat geneste en niet-ASCII-namen oplossen
    Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
    Pdf.SetFormFieldValue('Applicant.Straße', 'Hauptstraße 12');

    // Waarden die niet Latin-1 zijn reizen als FEFF-voorafgegaan UTF-16BE-hex
    // en worden als PDF-hexadecimaalstring geschreven
    Pdf.SetFormFieldValue('Applicant.City', 'FEFF004D00FC006E006300680065006E');

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

Wat schrijft SetFormFieldValue nu eigenlijk?

Beide overloads doorlopen dezelfde vijf stappen: de velddictionary lokaliseren, /V schrijven via HPDFSetDictFormValue, de selectie-indices van keuzevelden in overeenstemming brengen, de dictionary dirty markeren, de appearance states van knoppen in overeenstemming brengen, en ten slotte de veldindex vastleggen via NoteLoadedFormFieldDirty. Die laatste stap is belangrijk als het formulier berekeningsscripts draagt, want de dirty-set is wat de parameterloze overload van RecalculateLoadedFormFieldsIncremental verbruikt om alleen de berekeningen opnieuw te draaien die via de keten een gewijzigd veld lezen. HPDFSetDictFormValue is zelf voorzichtig met het objecttype dat het vervangt. Is de bestaande /V een name object, wat checkbox- en radiofelden voor hun exportwaarde gebruiken, dan wordt de nieuwe waarde als name geschreven, nooit als string, want PDF-namen zijn per constructie ASCII-only. Anders schrijft het een stringobject en bekijkt het de waarde die u meegaf: een string die met FEFF begint, even lengte heeft en uitsluitend uit hexcijfers bestaat wordt behandeld als de UTF-16BE-wirevorm uit §7.9.2.2 en opgeslagen met IsHexadecimal gezet, zodat hij als <FEFF...> serialiseert in plaats van als een letterlijke (FEFF...). Dat is het mechanisme waar de City-regel hierboven op leunt; elke andere string wordt opgeslagen als letterlijke string met de bytes die u gaf, dus voor gewone Latijnse tekst geeft u gewone tekst mee

Waarom houdt een checkbox zijn oude vinkje na de waardewijziging?

Omdat bij een knopveld de waarde alleen niet bepaalt wat er getekend wordt. ISO 32000-1 §12.7.4.2.3 schrijft voor dat een checkboxwidget een /AS-appearancestate draagt die noemt welke stream in /AP /N op dat moment getoond wordt, en viewers schilderen vanuit /AS, niet vanuit /V. Verandert u /V naar Yes maar laat u /AS op Off staan, dan is het bestand intern tegenstrijdig, en flattening bakt de verouderde niet-aangevinkte appearance rustig in de pagina terwijl de formulierdata aangevinkt zegt. ReconcileLoadedButtonAppearanceStates bestaat om dat gat te dichten: voor een veld waarvan /FT gelijk is aan Btn bezoekt het de velddictionary zelf en elke entry in zijn /Kids-array, leest de on-statenaam uit /AP /N, en herschrijft /AS naar die naam wanneer hij overeenkomt met de veldwaarde of naar Off wanneer dat niet zo is

Waarom een HotPDF-checkbox zijn oude vinkje houdt wanneer alleen /V verandert: viewers schilderen vanuit de /AS-appearancestate in /AP /N, dus bezoekt ReconcileLoadedButtonAppearanceStates het veld en elk kind, leest de on-statenaam als de eerste key anders dan Off, en herschrijft /AS bij een match of anders naar Off
Radiogroepen vergelijken elk kind met de parentwaarde die InheritedButtonValue terugvindt door de /Parent-keten omhoog te lopen, dus de groep op één exportwaarde zetten zet precies die widget aan en elk broertje uit

Twee details uit echte formulieren hebben de fix in v2.752.3 bepaald. Ten eerste mag een normal appearance dictionary alleen de on-state bevatten; §12.7.4.2.3 noemt de off-appearance Off, maar authoringtools laten de bijbehorende stream vaak weg en laten de viewer niets tekenen. Eerdere code stopte wanneer de dictionary minder dan twee entries had, dus die checkboxes met één state hielden stilzwijgend hun oude vinkje. De controle is nu simpelweg dat de dictionary niet leeg is, en de on-statenaam wordt genomen als de eerste key die niet Off is. Ten tweede is de on-statenaam wat de auteur ook koos. Echte formulieren gebruiken 2, Yes, On of een gelokaliseerd woord, dus de vergelijking gebeurt tegen de werkelijke key, hoofdletterongevoelig, nooit tegen een hardgecodeerde Yes. Radioknoppen voegen nog een complicatie toe, beschreven in §12.7.4.2.4: de selectie leeft in /V op het parentveld, terwijl de afzonderlijke kinderen de widgets bezitten en doorgaans geen eigen /V hebben. De geneste helper InheritedButtonValue loopt daarom de /Parent-keten omhoog, tot 64 niveaus, tot hij een niet-lege waarde vindt, zodat elk kind tegen de waarde van de groep waarin het zit wordt vergeleken. De parent op de exportwaarde van één kind zetten zet precies dat kind aan en elk broertje uit

// Checkbox: de exportwaarde moet overeenkomen met de on-state-key in /AP /N
// (vaak 'Yes', maar echte formulieren gebruiken '2', 'On' of wat dan ook)
Pdf.SetFormFieldValue('Consent', 'Yes');

// Radiogroep: /V wordt op de parent geschreven; elke kindwidget krijgt
// /AS op zijn eigen exportnaam of op Off
Pdf.SetFormFieldValue('PaymentMethod', 'Card');

// Een checkbox leegmaken: elke waarde die op geen enkele on-state past geeft /AS Off
Pdf.SetFormFieldValue('Newsletter', 'Off');

Keuzevelden: /I in de pas houden met /V

Bij een combobox of listbox is /V niet de enige plek waar een selectie wordt vastgelegd. Tabel 231 in §12.7.4.4 definieert /I als een array van zero-based indices in /Opt die de geselecteerde items aanwijst, en een viewer die /I op optie 0 ziet wijzen terwijl /V optie 3 noemt kan de verkeerde rij markeren. Sinds v2.754.1 loopt HPDFReconcileChoiceSelection binnen elke SetFormFieldValue-aanroep en bouwt hij, wanneer de geërfde /FT gelijk is aan Ch, /I opnieuw op uit de nieuwe waarde. De volgorde van de bewerkingen is bewust. De lokale /I-entry wordt eerst verwijderd, zonder zijn inhoud aan te raken: als de oude array een indirect object was dat met een ander veld werd gedeeld, zou muteren in place de selectie van dat andere veld beschadigen, dus laat de routine de verwijzing vallen en maakt ze een verse directe array aan. Daarna lost ze /Opt op via de /Parent-keten, want keuzeopties kunnen geërfd zijn, en scant ze de entries. Een kale stringoptie wordt direct vergeleken; een [export display]-paar wordt op zijn exportelement vergeleken, en een paar met minder dan twee elementen wordt overgeslagen. Beide kanten gaan door HPDFLoadedFormTextName, dus een hex-UTF-16-optie matcht een hex-UTF-16-waarde zonder dat u ze identiek hoeft te spellen. Bij de eerste match wordt een /I met één element geschreven en stopt de scan; een scalaire waarde vervangt altijd een eerdere multiselectie, ongeacht de MultiSelect-flag

Hoe HotPDF een keuzeveld consistent houdt: HPDFReconcileChoiceSelection verwijdert de lokale /I-array voordat hij hem aanraakt, lost /Opt op via de /Parent-keten, vergelijkt de exporthelft van elke optie via HPDFLoadedFormTextName, schrijft een /I met één element bij de eerste match en schrijft niets wanneer een bewerkbare combowaarde geen index heeft
Een kale stringoptie wordt direct vergeleken en een export display-paar op zijn exportelement, terwijl een waarde buiten /Opt terecht geen index achterlaat — een verouderde /I die naar de verkeerde rij wijst zou erger zijn dan geen

Wanneer niets matcht wordt er helemaal geen /I geschreven. Dat is de juiste uitkomst voor een bewerkbare combobox, waar §12.7.4.4 de gebruiker toestaat een waarde buiten de optielijst te typen; zo'n waarde heeft geen index, en een verouderde index zou erger zijn dan geen. Het is ook wat u krijgt als u een displaylabel in plaats van een exportwaarde aan een gepaarde optielijst geeft, dus wanneer een combobox uw selectie niet wil tonen, controleer dan welke helft van het paar u heeft meegegeven

// /Opt is [[US United States] [CA Canada] [MX Mexico]]:
// match op de exportwaarde, en /I wordt [1]
Pdf.SetFormFieldValue('Country', 'CA');

// Bewerkbare combo met een waarde buiten /Opt: /V wordt geschreven,
// /I wordt verwijderd en er wordt geen index verzonnen
Pdf.SetFormFieldValue('Title', 'Principal Engineer');

Waarde en appearance zijn twee aparte operaties

SetFormFieldValue raakt de appearance stream van een tekst- of keuzeveld nooit aan. Na de aanroep bevat /V de nieuwe tekst terwijl /AP /N nog de oude schildert, en welke van de twee een viewer toont hangt af van de vraag of de AcroForm-dictionary /NeedAppearances true draagt volgens §12.7.3.3 en of de viewer dat respecteert. Als u wilt dat het bestand de nieuwe waarde in elke reader rendert, inclusief flatteners en thumbnailgenerators die de flag negeren, roep dan EnsureLoadedFieldAppearanceStream aan met de veldindex. Die bouwt een Form XObject uit de geërfde /DA-string, de /Q-quadding, de /MaxLen-comblay-out en de waarde, lost het genoemde font op via de AcroForm-/DR-resources zodat een Type0-font zijn eigen descendantfont behoudt in plaats van te degraderen naar Helvetica, en geeft True terug wanneer minstens één widget een stream kreeg. De overload van SetFormFieldValue op naam geeft u geen index terug, dus haal er een op via GetFormField, dat een THPDFLoadedFormField teruggeeft die u bezit en moet vrijgeven. De regressiesuite voor de wijziging in v2.752.1 is expliciet over deze splitsing: hij zet een waarde, roept EnsureLoadedFieldAppearanceStream aan, rendert dan de pagina en controleert dat de pixels binnen de widgetrechthoek veranderd zijn terwijl de pixels erbuiten dat niet zijn. Verifiëren dat /V veranderd is bewijst niets over wat een gebruiker zal zien

var
  Field: THPDFLoadedFormField;
begin
  Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
  Field := Pdf.GetFormField('Applicant.FullName');
  try
    // Schilder de nieuwe waarde in /AP zodat viewers die
    // /NeedAppearances negeren het alsnog 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;

Beperkingen die u wilt kennen voordat u hierop bouwt

ReconcileLoadedButtonAppearanceStates toetst de lokale /FT van de dictionary die u heeft aangesproken, dus het werkt op de radioparent of op een checkbox die zijn eigen /FT draagt; een kindwidget dat op zichzelf wordt aangesproken, met /FT alleen op zijn parent, wordt niet via dat pad in overeenstemming gebracht. HPDFReconcileChoiceSelection behandelt één scalaire waarde en schrijft hoogstens één index; multiselectielistboxen met meerdere gekozen entries vallen buiten wat SetFormFieldValue modelleert. Geen van beide routines valideert de waarde die u meegeeft tegen /Opt of tegen de on-state-keys, dus een typefout levert een Off-checkbox of een combo zonder index op in plaats van een exception. En GetFormFieldValue geeft de opgeslagen /V-tekst terug zoals hij in de dictionary staat, wat voor een hex-gecodeerde waarde de hexadecimale spelling betekent, niet de gedecodeerde tekst

Zodra de waarden erin staan en de appearances geschilderd zijn, liggen de twee natuurlijke vervolgstappen aan weerszijden van deze operatie. Velddata in bulk uitwisselen met externe systemen, in plaats van één SetFormFieldValue-aanroep per keer, is wat XFDF-import en -export in Delphi dekt. En wanneer het ingevulde formulier definitief is en niet meer bewerkbaar hoort te zijn, bakt het flattenen van AcroForm- en XFA-velden in Delphi precies de hier beschreven /AS-states en appearance streams in statische paginainhoud, en daarom is ze consistent krijgen vóór het flattenen geen optie

De API voor het bewerken van geladen formulieren in dit artikel, inclusief SetFormFieldValue, EnsureLoadedFieldAppearanceStream en de graaf voor incrementele herberekening, zit in de HotPDF Delphi Component voor Delphi en C++Builder