Technical Article

Filling a Signed PDF Form in Delphi Without Invalidating the Signature

You change one value in a signed PDF form, save it, and Acrobat reports that the signature is invalid or that the document was modified after signing. In PDFium Component for Delphi, TPdf.SetFormFieldValues appends the values as an incremental revision after the signed bytes, only for fields the DocMDP, FieldMDP and /Lock rules leave open, and TPdf.SaveSourceBytes writes the result out verbatim

Why does a normal save break the signature?

A normal save breaks the signature because a PDF signature protects a precise run of bytes, and a serializer has no reason to keep any byte where it was. ISO 32000-1 §12.8.1 records that run in the signature dictionary's /ByteRange: two segments covering the file from its first byte up to the /Contents hex string and from the end of that string to the end of the signed revision, with the CMS digest inside /Contents computed over those segments alone. A full save serializes the signed objects again, so offsets move, cross-reference entries change and the validator ends up hashing different bytes from the ones the signer sealed. Asking for an incremental save does not get you out of it either. On a signed source, SaveAs paths either rewrite the signed prefix, which is what the component's compression post-processing does to the whole file under the default Compressed setting, or, with Compressed set to False and the incremental option, keep the prefix but let PDFium append a revision that re-serializes the signature field and dozens of other signed objects, which a validator reports as changes after signing. An incremental save session opened after the fill is different: on a signed source it keeps the source bytes as its prefix and only appends, while a session opened before the fill makes SetFormFieldValues refuse the batch

The format already has the answer: the incremental update of §7.5.6. A writer leaves the original file untouched and appends the changed objects, a fresh cross-reference section and a trailer whose /Prev points back to the previous one. The signed revision survives as a byte-exact prefix, every /ByteRange still covers identical segments, and the validator's question shifts from whether the signed bytes were altered to whether the later changes were permitted. SetFormFieldValues builds that appended revision on top of the bytes the caller originally loaded, never on a re-saved snapshot, and then reloads the TPdf instance from the result; SaveSourceBytes copies those bytes out verbatim, with no compression and no post-processing. A separate method was the deliberate choice over teaching SaveAs to fall back to the source: PDFium exposes no dirty flag, so changes made through direct PDFium page or annotation calls could not be detected, and a fallback would drop them without a word. Signature fields and their value dictionaries are read for policy and otherwise left alone

DocMDP, FieldMDP and /Lock decide which fields you may write

Keeping the prefix intact makes the signature verify, but whether the edit is acceptable is a different question, and three rules answer it before a single object is written. The first is the certification signature's DocMDP transform, one of the transform methods in ISO 32000-1 §12.8.2. Its parameters sit in the /TransformParams dictionary of the signature reference (Table 253), and the /P value there states what may happen after certification: 1 permits no changes, 2 permits filling in forms, instantiating page templates and signing, and 3 adds creating, modifying and deleting annotations. When /P is missing, Table 254 makes it 2, so a parser that treats a missing key as "no changes allowed" refuses forms the signer meant to be filled. A document can carry only one certification signature. Under P=1, SetFormFieldValues rejects the whole batch; under P=2 or P=3 it allows value and appearance writes on any field the remaining two rules leave unlocked. Approval signatures without a DocMDP transform skip this gate and go straight to the field locks

/ActionFields the signature locksFields named in /Fields
/AllEvery field in the documentIrrelevant; nothing is writable
/IncludeOnly the listed fieldsLocked
/ExcludeEvery field except the listed onesThe only writable fields

FieldMDP transforms and signature field locks share one vocabulary. A FieldMDP entry in a signature's /Reference array and a /Lock dictionary on a signature field (§12.7.4.5) each carry an /Action of /All, /Include or /Exclude and, for the last two, a /Fields array of field names. /Exclude reads the opposite way from what its name suggests. It does not exempt the listed fields from the lock; it locks everything else, so a signature with /Action /Exclude and /Fields [(review.comments)] leaves one field open and freezes the rest of the form. The component applies the table literally, and a request outside an Exclude list comes back with "The signature locks every field except the listed fields". /Fields entries normally hold fully qualified names such as applicant.address.city, while the target field's own /T holds only its partial name, so PDFium Component rebuilds each target field's fully qualified name along its /Parent chain and compares that: /Exclude releases only an exactly listed name, /Include also locks every field below a listed ancestor, and a shared last segment never counts as a match. Signature fields themselves are never writable through this API, whatever a policy says

Signature content the component cannot classify blocks the write. A /Reference entry whose transform method is neither DocMDP, FieldMDP nor UR stops the batch; UR is skipped on purpose, since usage rights grant viewer capabilities and never restrict field values. A pair of certification signatures with conflicting /P values blocks it as well, since nothing in the file says which one should win. We think this conservatism is the right default for a form writer. When the filled document goes back to its issuer, their validator rebuilds the appended revision and grades every object in it, the kind of analysis described in how DocMDP ownership is decided for widget annotations, and a writer that misread the policy has handed someone else's archive a revision that looks like tampering

Writing the values and saving the result

The write itself is an ordinary form-value batch. Requests address widgets by one-based page number and zero-based, page-local widget index, so a small helper that maps fully qualified names to indices keeps the call site readable; FormFieldInfo[I].Name returns the full name of the widget at index I on the current page. The routine below fills a name and a consent checkbox on page 1 of a certified application form and writes the signed result to a new file

uses
  System.SysUtils, PDFium;

function WidgetIndexOf(Pdf: TPdf; const FullName: WString): Integer;
var
  I: Integer;
begin
  for I := 0 to Pdf.FormFieldCount - 1 do
    if Pdf.FormFieldInfo[I].Name = FullName then
      Exit(I);
  raise Exception.CreateFmt('No widget for field %s on page %d',
    [FullName, Pdf.PageNumber]);
end;

procedure FillCertifiedApplication(const SourceFile, TargetFile: string);
var
  Pdf: TPdf;
  Requests: TPdfFormValueRequests;
  Report: TPdfFormValuesWriteReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourceFile;
    Pdf.Active := True;
    Pdf.PageNumber := 1;

    SetLength(Requests, 2);
    Requests[0] := Default(TPdfFormValueRequest);
    Requests[0].Page := 1;
    Requests[0].FieldIndex := WidgetIndexOf(Pdf, 'applicant.name');
    Requests[0].Value.Kind := fvkText;
    Requests[0].Value.Text := 'Dana Whitfield';

    Requests[1] := Default(TPdfFormValueRequest);
    Requests[1].Page := 1;
    Requests[1].FieldIndex := WidgetIndexOf(Pdf, 'applicant.consent');
    Requests[1].Value.Kind := fvkCheckBox;
    Requests[1].Value.Checked := True;

    if not Pdf.SetFormFieldValues(Requests, Report) then
      raise Exception.CreateFmt('Form write rejected (request %d): %s',
        [Report.FailedRequest, Report.ErrorMessage]);

    // Pdf now holds the signed original plus one appended revision.
    // Copy it out verbatim; SaveAs would serialize the signed objects again.
    if not Pdf.SaveSourceBytes(TargetFile) then
      raise Exception.Create('The complete source bytes are not available');
  finally
    Pdf.Free;
  end;
end;

SetFormFieldValues is atomic across the batch, the same all-or-nothing contract covered in atomic batch form writes: either every request lands in one appended revision, or nothing is written and the document keeps its values. On success, Report.UpdatedFields and Report.UpdatedWidgets count what changed (a field with several widgets updates more widgets than fields). On failure, read Report.ErrorMessage before anything else, because FailedRequest names a request index only for per-request problems such as a bad page or an oversized value. A field that still has keyboard focus from interactive editing in a viewer blocks the write until you call ClearFormFieldFocus, and an encrypted document must grant form filling or modification to the password it was opened with

SaveSourceBytes has its own small set of conditions. The file overload writes a temporary file next to the target and then replaces the target, so a failure leaves any earlier copy in place; the result is False when no complete source is held, for instance while a progressively downloaded document is still loading, and an open page-object or document edit transaction raises EPdfError. It writes the source bytes and nothing else, so changes made through direct page, annotation or object calls after loading never reach the output; on a signed document, route every field change through SetFormFieldValues and keep other editing out of the same session. Checking the saved copy is cheap. Observed on a CAdES-signed form certified with P=2: after a fill, the SaveSourceBytes output began with all 20,750 bytes of the original file, unchanged, the reloaded copy reported IsCryptographicallyValid, and AnalyzeSignatureRevisions, explained in post-signature revision change analysis, graded the appended revision prasAllowed with one change and no risks, while a P=1 version of the same form was refused. The same check fits in a few lines

uses
  System.SysUtils, PDFium, FPdfPades;

procedure VerifySignedCopy(const SavedFile, Password: string);
var
  Reopened: TPdf;
  Validation: TPadesValidationResult;
  Analysis: TPadesRevisionAnalysisReport;
begin
  Reopened := TPdf.Create(nil);
  try
    Reopened.Password := Password;
    Reopened.FileName := SavedFile;
    Reopened.Active := True;

    Validation := Reopened.ValidatePades;
    if not Validation.IsCryptographicallyValid then
      raise Exception.Create('Signature validation failed on the saved copy');

    Analysis := Reopened.AnalyzeSignatureRevisions;
    // A freshly filled copy must carry one permitted revision after signing,
    // so prasNoLaterChanges counts as a failure here as well
    if (Analysis.Status <> prasAllowed) or (Analysis.Risks <> []) then
      raise Exception.CreateFmt('Revision after signing graded %d: %s',
        [Ord(Analysis.Status), Analysis.ErrorMessage]);
  finally
    Reopened.Free;
  end;
end;

Which field did the signature lock?

The signature policy is checked against the batch as a whole before anything is written, so a rejected batch reports the rule that fired, and finding the field it fired on takes one more step: replay each request alone against a throwaway instance loaded from the same source. A probe that succeeds writes only into its own copy, which is freed immediately, so the document you are working on is never touched. Each probe reparses the file, so keep this for diagnostics, when a user needs to hear which fields the signer froze

uses
  System.SysUtils, System.Classes, PDFium;

// Returns one line per request the signature policy refuses.
// The caller owns the returned list.
function FindRefusedRequests(const SourceFile, Password: string;
  const Requests: TPdfFormValueRequests): TStringList;
var
  I: Integer;
  Probe: TPdf;
  One: TPdfFormValueRequests;
  Report: TPdfFormValuesWriteReport;
begin
  Result := TStringList.Create;
  SetLength(One, 1);
  for I := 0 to High(Requests) do
  begin
    Probe := TPdf.Create(nil);
    try
      Probe.Password := Password;
      Probe.FileName := SourceFile;
      Probe.Active := True;
      One[0] := Requests[I];
      if not Probe.SetFormFieldValues(One, Report) then
        Result.Add(Format('request %d (page %d, widget %d): %s',
          [I, Requests[I].Page, Requests[I].FieldIndex, Report.ErrorMessage]));
    finally
      Probe.Free;  // discards whatever this probe wrote
    end;
  end;
end;

Encrypted signed forms: sealing each appended object

RC4 (40 to 128-bit), AES-128 and AES-256 encrypted signed sources take the same append path, with one more obligation: every object in the appended revision must be encrypted the way the rest of the file is, or a reader will find plaintext where it expects ciphertext. For RC4 and AES-128, ISO 32000-1 §7.6.2 Algorithm 1 derives a key per indirect object by hashing the file key, the low three bytes of the object number and the low two bytes of the generation number with MD5, and for AES it appends the four bytes 73 41 6C 54 ("sAlT") before hashing; AES-256 (security handler revisions 5 and 6) uses the file key directly under Algorithm 1.A of ISO 32000-2. The component seals each new object under its own object and generation number with the document keys, runs every string and stream inside it through the same key, and repeats the existing /Encrypt reference and the original first file identifier in the incremental trailer. §7.5.6 asks an added trailer to carry the previous entries forward, and for RC4 and AES-128 the file key is derived from that first identifier, so a new one would orphan every encrypted string already in the file. SaveSourceBytes then copies the result with the original security dictionary untouched

A writer that gets these details wrong can pass every test it runs against itself. Type the salt as 73 41 4C 74, an easy slip when writing hex from memory, and your own reader decrypts your own output perfectly, because both sides share the mistake, while qpdf, pypdf and PDFium all use the specified bytes and report AES padding errors. Password padding has the same shape: Algorithm 2 completes a short password with the start of the fixed 32-byte padding string, and taking padding bytes by absolute position instead derives a wrong key that still round-trips through your own code. So never accept a self-round-trip as proof. Open the output in an independent parser, and search the appended tail for the value you just wrote, both as plain ASCII and as the UTF-16BE hex form <FEFF…> in which field text is serialized, because an object that missed encryption shows up there before any viewer complains

On a signed form, make SaveSourceBytes the last call of every fill routine and keep SaveAs for documents nobody has signed yet; both methods live in PDFium Component for Delphi