M16 — Security and forms
State: to do — Depends on: M03, M11, M15 — Cryptography placed by ADR 41; scripts and dynamic XFA by ADR 37; versions by ADR 40
Goal
Open every password-protected document and write protected ones, say precisely what the core cannot open itself; and read, fill, flatten, create and exchange the fields of interactive forms — AcroForms whole, XFA and usage rights detected and kept coherent, Acrobat's standard formats and sums applied without ever running a script.
Eighteen committed documents and five remote ones are encrypted, and every milestone since M03 has had to
leave them out: they are refused at opening, so none of them has been round-tripped, stamped, merged,
annotated or extracted. Eleven of the seventeen whose password the manifest records open with an empty
one — a protection that protects nothing, and that every viewer opens without a word — while the library
refuses them all. Forms are the other half of the business: a Cerfa to fill for each client, a subscription
form to fill from a spreadsheet, a signed contract whose signature fields must be left ready for signing. The
failures to prevent are ordinary and silent: a hybrid XFA form filled through its AcroForm, which Acrobat then
shows empty because its XFA data was left stale; a Reader-extended form saved in a way that disables Reader's
features with a message the user cannot act on; a numeric field written as 1234.5 where the form's own
format says 1 234,50 €; a sum that stays at its old total because nobody ran the calculation; a check box
drawn in a ZapfDingbats font that voids the PDF/A claim.
Scope
In:
- Decryption under the standard security handler, every revision a valid file can carry: RC4 40-bit
(R2), RC4 up to 128 bits (R3), crypt filters with RC4 or AES-128 (R4), AES-256 (R5 read, R6), with
EncryptMetadata, theIdentityfilter,/EFFfor embedded files and the/Cryptstream filter; owner and user authentication, passwords as text or as bytes; - AES-GCM and the integrity MAC read — ISO/TS 32003 and 32004 — with the framework's
AesGcmand HMAC (ADR 41); - the public-key security handler detected and reported under a stable code, with the seam through which
AdCodicem.Pdf.Signing(M26) supplies the key; unencrypted wrapper documents (ISO 32000-2 §7.6.7) recognized, their payload exposed as an attachment; - permissions: decoded, respected by every operation of the library by default, and overridable only by an explicit caller declaration that is reported;
- encryption on write: AES-256 (R6) by default, AES-128, RC4-128 and RC4-40 on explicit request; crypt-filter options — attachments only, metadata left clear; a full rewrite that keeps, removes or replaces encryption, and an incremental update of an encrypted document under its own key; deterministic output (invariant 6);
- the form model: every field type, widget, flag, value and option, the calculation order, default resources and appearances, the scripts each field carries, and a JSON listing;
- filling, with field appearances generated for text, choice and button fields, fonts embedded by default;
- Acrobat's standard formats (
AFNumber,AFPercent,AFDate,AFTime,AFSpecial,AFRange) and simple calculations (AFSimple_Calculate: sum, product, average, minimum, maximum) recognized without executing any script, and every other script reported; - XFA detected and classified — static, hybrid, dynamic —, its datasets read, and on fill kept in step or removed, as the caller's policy says;
- usage rights (
/UR3, legacy/UR) interpreted, kept through a save that stays within their grants, and removed with a diagnostic when a save would break them; - flattening fields, whole or by name, through M11's flattener;
- a field creation API — text, check box, radio group, combo box, list box, push button — and signature
field placeholders with
/Lockand seed values, on an opened document and on one being generated (M08's builder, which M17 uses); - FDF, XFDF and JSON import and export of field values — and, through M11's model, of annotations —; and batch filling of one template from many records;
- validation rules in the
form.*family and thesecurity.*rules that need the file key, added to M02's structural profile; M03's version table gains the rows these features need; - the command-line tool's
encrypt,decrypt,security,fields,fillandexportverbs, andflatten --fields.
Out, explicitly:
- decrypting and encrypting with the public-key handler — M26, in
AdCodicem.Pdf.Signing, through the seam defined here (ADR 41); signing a signature field, visible signature appearances, certification — M26; verifying a MAC that ISO/TS 32004 attaches to a signature — M27; - writing AES-GCM or a MAC — not planned: Acrobat does not open such files yet, by the survey's account, and PDF/A and every business exchange avoid encryption anyway; the read side exists because invariant 12 asks for it;
- executing JavaScript, of any kind — never (ADR 37). Scripts are listed, kept on merge (M06), reported on fill and flatten, removed by sanitization (M19); only Acrobat's standard functions are recognized, by their exact call shape;
- rendering, flattening or filling dynamic XFA — never (ADR 37): detected, reported, its datasets read, the XFA removable; writing a dynamic form's datasets would be filling it without seeing it, and stays out with it;
- Acrobat's simplified field notation (
/** BVCALC … EVCALC **/) — reported like any other script until a real document carries one (below, What it lacks); - templates and page spawning (
/Names /Pages,/Templates,getTemplate().spawn) — read, kept, reported; - barcode fields (Acrobat's
/PMDpaper forms barcodes) — kept as fields, their appearance left as it is, reported; - HTML form controls mapped to fields — M17, on the generation-side API delivered here;
- scrubbing form values for redaction — M19, through this model;
- permissions and encryption as sanitization categories — M19; converting a received encrypted document to PDF/A, which forbids encryption — M21, which decrypts through this milestone;
- rasterizing filled forms — M25; MuPDF and pdf.js are the visual referees until then.
Design
Where it lives
In the core, beside the reader and the writer they extend:
Security/ handlers, crypt filters, key derivation, permissions, the key-source seam, the encryption stage
of the writer, AES-GCM and the MAC
Forms/ the field model, filling, appearances, the Acrobat-format recognizer and calculator, XFA, usage
rights, flattening, field creation, FDF, XFDF and JSON, batch filling
| Type | Responsibility |
|---|---|
PdfCredentials | None, Password(string), PasswordBytes(ReadOnlySpan<byte>), KeySource(IPdfDecryptionKeySource); on PdfReaderOptions.Credentials |
IPdfDecryptionKeySource | The seam for a handler the core cannot open alone: given the handler's /SubFilter and /Recipients bytes, return the seed and the permissions, or nothing. Implemented by AdCodicem.Pdf.Signing (M26) |
PdfSecurityInfo | document.Security: handler, V, R, key length, the method of strings, streams and embedded files, EncryptMetadata, declared permissions, how the caller authenticated (Owner, User, KeySource, None), the MAC's presence and verdict, whether the document is a wrapper and its payload |
PdfPermissions | Flags — Print, PrintHighQuality, Modify, Copy, Annotate, FillForms, ExtractForAccessibility, Assemble — decoded from /P for the revision, and their effective value for the authentication |
PdfPermissionPolicy | Respect (default) or Ignore, on PdfReaderOptions |
PdfPermissionException | Typed: the operation, the permission, its bit |
PdfFormException | Typed: a fill, a flattening or a creation the form cannot take — a dynamic XFA form, a refused XFA policy, a name that collides |
PdfEncryptionOptions | Immutable: algorithm, user and owner passwords, permissions, EncryptMetadata, scope (Everything, EmbeddedFilesOnly), and an entropy source the caller must name |
PdfEntropy | System (the platform's generator) or FromSeed(bytes); no default (invariant 6, below) |
PdfAcroForm | document.Form: the field tree, lazy; Find(name); NeedAppearances, SigFlags, CalculationOrder, default resources and appearance; Xfa; UsageRights |
PdfField and its views — PdfTextField, PdfCheckBoxField, PdfRadioGroupField, PdfPushButtonField, PdfComboBoxField, PdfListBoxField, PdfSignatureField, PdfUnknownField | Typed read of each field type with its flags, value, default value, options and widgets; an unknown /FT is kept and listed |
PdfWidget | Rectangle, page, flags, appearance states, on-state, /MK (border and background colors, caption, icon, rotation), border style, /DA, /Q |
PdfFieldValue | Text, Number (decimal), Date, Time, State (a button's), Choices, Clear |
PdfFieldScripts | Per field and event (keystroke, format, validate, calculate, focus, blur, mouse): Recognized(PdfAcrobatFormat) or Other(length, hash) |
PdfAcrobatFormat | One record per recognized function, with its parameters: AFNumber, AFPercent, AFDate, AFTime, AFSpecial, AFSpecialMask, AFRange, AFSimpleCalculate |
PdfFillOptions | Immutable: appearances (Generate, NeedAppearances), fonts (EmbedSubstitutes, UseDeclared), XFA (KeepInStep, Remove, Refuse), formats (Apply, Ignore), calculations (Recompute, Leave), read-only fields (Refuse, Override), unknown names (Refuse, Report) |
PdfXfaInfo | Kind (None, Static, Hybrid, Dynamic), packets (names and lengths), template version, the datasets as a bounded XML reader; Remove() |
PdfUsageRights | Present, legacy /UR, the grants of each category, /P, /Msg, and the coverage M04 computed |
PdfFormBuilder | Field creation — AddText, AddCheckBox, AddRadioGroup, AddComboBox, AddListBox, AddPushButton, AddSignatureField — on an opened document's change set or on M08's PdfDocumentBuilder.Form |
PdfFormData | FDF, XFDF and JSON: Export, Import (to a PdfFormDataSet of values and annotations), Apply |
PdfBatchFiller | One template analyzed once; records filled into one output each or into one volume |
Every operation that reads or changes a document takes M03's CancellationToken and IProgress<PdfProgress>,
adding the stages Decrypting, Encrypting and Filling, and returns M06's PdfOperationReport, as M09 and M11
do, with the codes listed below.
Opening a protected document
PdfReaderOptions.Credentialsis new, and its default,PdfCredentials.None, tries the empty user password, which opens eleven of the seventeen committed documents whose password is recorded. A password given as text is tried in the encoding the revision defines — PDFDocEncoding for R2 to R4, UTF-8 after SASLprep for R5 and R6 —, then in those producers have been seen to use instead — Windows-1252 and UTF-8 bytes for R2 to R4, the string without normalization for R6: a fixed list, each attempt costing one key derivation, and the encoding that matched reported (encryption.password-encoding, information). The owner password is tried before the user password, so that the authentication is the strongest the caller can prove.- Failure is typed and immediate.
PdfEncryptedExceptiongains aReason:PasswordRequired(nothing given opens it — the Cabinet's AES file whose password nobody published),PublicKeyHandler(no key source, or one that declined),UnsupportedHandler(a/Filternobody documents),UnsupportedAlgorithm(V0 or 3, an unknownR, a crypt-filter method nobody documents),PrimitiveUnavailable(the platform lacks a primitive the revision needs — below). ThrowOnEncryptedkeeps its name and changes its meaning, which ADR 30 allows a preview: true, the default, throws when no credential opens the document; false opens the structure without the key, as M04 does to compute signature coverage — strings and streams stay ciphertext, each read reported once (encryption.content-not-decrypted), and nothing that needs the content is attempted.- Nothing is read eagerly. Authentication reads
/Encryptand the trailer's/ID; strings are decrypted as their object is parsed, streams as the first stage of their decoding. Opening an encrypted document reads what opening its twin reads — the laziness test ofCorpusReadingTestsstops skipping them.
The standard security handler
V | R | Cipher | Key | From password to key | Written |
|---|---|---|---|---|---|
| 1 | 2 | RC4 | 40 bits | ISO 32000-2 algorithms 2, 4 and 6: MD5 over the padded password, /O, /P, the first /ID element | On request (Rc4_40), reported weak |
| 2 | 3 | RC4 | 40 to 128 bits, by 8 | Algorithms 2, 5 and 6: MD5 re-hashed 50 times, the owner key through 20 RC4 passes | On request (Rc4_128), reported weak |
| 4 | 4 | RC4 (/V2) or AES-128-CBC (/AESV2), per crypt filter | 128 bits | As R3, plus EncryptMetadata | On request (Aes128) |
| 5 | 5 | AES-256-CBC (/AESV3) | 256 bits | SHA-256 over the password and a salt — Adobe's extension level 3, withdrawn | Never; read |
| 5 | 6 | AES-256-CBC (/AESV3) | 256 bits | Algorithm 2.B: rounds of SHA-256, 384 and 512 over the UTF-8 password (SASLprep, at most 127 bytes); the file key unwrapped from /UE or /OE; /Perms checked | The default (Aes256) |
| 6 | 7 | AES-256-GCM (/AESV4) | 256 bits | As R6 | Never; read (below) |
- Per-object keys for R2 to R4 follow algorithm 1: MD5 over the file key, the object's number and
generation, and
sAlTfor AES, truncated to the key length plus five bytes, at most 16. R5 and later use the file key directly. - RC4, MD5 and AES are ours as well as the framework's (the maintainer's decision of 2026-09-27, a consequence
of ADR 41): the base class library has no RC4, and browser
WebAssembly, where M23 runs the core, has no MD5 and no AES. The core carries managed MD5 (M06's, written for
/CheckSum), RC4 and AES in CBC and ECB modes, so that every revision but R7 opens in the browser; SHA-2 and HMAC come from the framework, and so do MD5 and AES wherever the platform has them. The managed code is tested against RFC 1321's, RFC 6229's and NIST SP 800-38A's vectors and fuzzed, and it is not constant-time, which the documentation says: it decrypts a file already in the caller's hands, where timing reveals nothing the file does not. AES-GCM (R7) comes from the platform only; where it lacks it, a typed refusal names the primitive (PrimitiveUnavailable). Never a crash. - Algorithm 2.B ends by itself. Its loop runs at least 64 rounds and stops once the last byte of the round's output is at most the round number less 32: by the 288th round every byte satisfies that, whatever the input. No bound to classify under ADR 34.
/Perms(R6) is decrypted with AES-256 in ECB mode and checked: theadbmarker,/P, and theEncryptMetadatabyte. A mismatch is a tampered or careless file; the more restrictive of the two readings applies and the difference is reported (encryption.permissions-mismatch, warning).- Bounds — the lengths of
/O,/U,/OE,/UE,/Perms(32, 48 or 16 bytes as the revision says),/Length(a multiple of 8, 40 to 128 for RC4), a/CFdictionary's size — are checked before any is used; none sizes an allocation. A value only an invalid file carries is refused underUnsupportedAlgorithm, and its reason written where the bound is declared (invariant 12).
What is encrypted, and what is not
Every string and stream of every indirect object, except:
- the
/Encryptdictionary itself, direct or indirect — its object number is known before anything is parsed; - the
/IDstrings, and the trailer, and every cross-reference stream; - the objects inside an object stream, whose strings are not encrypted a second time: the object stream is;
- a signature dictionary's
/Contents(ISO 32000-2 §7.6.2), which M04 already locates by lexing, and which M26's placeholder patch relies on; - streams of
/Type /MetadatawhenEncryptMetadatais false — every such stream, as qpdf reads it, not only the catalog's; - whatever a crypt filter named
/Identitycovers, and a stream whose own/Filterarray begins with/Cryptnaming another filter in its/DecodeParms.
AES strings and streams begin with a 16-byte IV and end on PKCS#5 padding. A ciphertext shorter than one block,
a length not a multiple of 16, or padding that does not check is taken as far as it decrypts and reported
(encryption.padding-invalid, repair): producers have written empty strings unencrypted, and viewers show
them empty. Decryption of a stream is a stage of M01's decoding pipeline, block by block through pooled buffers,
so that M23's piecewise decoding (#48) inherits it unchanged.
AES-GCM and the integrity MAC
ISO/TS 32003 adds AES-256-GCM as a crypt-filter method on the R6 key derivation; ISO/TS 32004 adds a message
authentication code over the file's bytes, keyed from the file key, standalone or attached to a signature.
The V, R and method values in the table above are the feature survey's; slice 3 checks them, and every
other constant of both specifications, against their text before a line is written.
- GCM: each string and stream carries a nonce, its ciphertext and a tag. A tag that does not verify means
the bytes were altered after encryption: the object's content is withheld — an empty string, a stream
that does not decode — and reported (
encryption.authentication-failed, warning;security.authentication-failed, error, in validation), never returned as if it were sound. - The MAC: verified over the byte range the trailer's authentication dictionary names, with an HMAC keyed
by the key the TS derives from the file key and a salt the encryption dictionary adds. A mismatch is
reported (
encryption.mac-mismatch, warning, and the same validation rule); a MAC attached to a signature is listed and left to M27 (encryption.mac-unverified, information). - ADR 41 and the MAC's container. ADR 41 keeps general CMS parsing out of the core, and ISO/TS 32004 wraps
the MAC in a CMS structure. Slice 3 first confirms that the standalone MAC can be verified by reading the
one fixed profile the specification allows, with
System.Formats.Asn1, bounded, refusing anything outside it; if it cannot, verification moves toAdCodicem.Pdf.Signingwith M27, the core reports the MAC present and unverified, and ADR 41 is amended to say so. - Platforms.
AesGcm.IsSupportedis false on some — browser WebAssembly among them: a GCM file there is refused withPrimitiveUnavailable.
What the core does not open itself
- The public-key handler (
/Filter /Adobe.PubSec,/SubFilteradbe.pkcs7.s3,s4ors5): the core parses the encryption dictionary and its crypt filters, lists the recipients' count and bytes, and asksIPdfDecryptionKeySourcefor the seed. Given one, it derives the file key itself — SHA-1 for RC4 and AES-128, SHA-256 for AES-256, over the seed, every recipient string and, when metadata is left clear, four bytes of0xFF— and the document opens like any other. Without one:PdfEncryptedExceptionwithPublicKeyHandler, andencryption.public-key-handlernaming the subfilter and the recipient count; never a page read as ciphertext. The unit tests implement the seam inside the test project, withEnvelopedCmsand the test key of the fixture (below), which proves that the seam is sufficient for M26 before M26 exists. - Wrapper documents (ISO 32000-2 §7.6.7, Table 28): an unencrypted file whose
/Collectionshows one embedded file with/AFRelationship /EncryptedPayloadand an/EPdictionary naming the payload's crypt filter and version — Microsoft Purview's protected files are such wrappers. Recognized at opening, reported (encryption.wrapper-document, information, naming the filter), the payload exposed as an attachment through M06's API (document.Security.Payload); M06's assembly reports a wrapper part rather than merging its cover page as content (assembly.wrapper-placeholder), and M15's extraction says the text it finds is the wrapper's.
Permissions
PdfPermissions decodes /P for the revision: its low 32 bits, whatever the integer's sign — the 1996 IRS form
writes 65516, a 16-bit value —; bits 3 to 6 for R2, which has no bits 9 to 12 and whose accessibility and
assembly follow its copy and modify bits, as qpdf and pdf.js read them; bits 3 to 6 and 9 to 12 for R3 and
later. Bit 10, which ISO 32000-2 deprecates, is read as the text of Table 22 says, checked in slice 2 against
it and against qpdf's and pdf.js's readings.
- Owner authentication grants everything. User authentication grants what
/Psays. Seven of the committed documents authenticate as owner with their recorded password — the owner password is the user password, or empty. - The operations of the library each name the permission they need, and ask one guard,
document.Security.Demand(operation):
| Operation | Permission |
|---|---|
| Extraction — text, images, exports, search (M15) | Copy; extraction for accessibility, tagged structure first, under ExtractForAccessibility |
| Pages inserted, removed, rotated, extracted; merge, split, outline edits (M06, M07) | Assemble (R3 and later), Modify (R2) |
| Stamps, headers, Bates, normalization (M09); flattening, layers (M11, below) | Modify |
| Annotations added or removed (M11) | Annotate |
| Fields filled, from any source (below) | FillForms or Annotate |
| Fields created or removed (below) | Annotate and Modify |
| Encryption removed or replaced | Owner authentication only |
Respect, the default, throwsPdfPermissionExceptionbefore anything is written or returned.Ignoreis a declaration by the caller that it is entitled to do what the file forbids — its owner who lost the owner password, an archive under a legal duty: the operation runs and each permission it overrides is reported (permission.not-respected, warning). ISO 32000-2 §7.6.4.2 says processors "shall respect the intent of the document creator"; a library cannot tell who its caller is, so it asks.- Printing is not an operation of the library; the flags are exposed for the caller's own printing path.
Writing encryption
PdfSaveOptions.Encryption is Keep (the default), Remove, or Apply(PdfEncryptionOptions).
Keepwrites the input's/Encryptdictionary as it was and its first/IDelement, so that the file key, and every password, stay what they were; it needs no password beyond the one that opened the document, owner or user. An incremental update of an encrypted document is alwaysKeep: its new objects are encrypted under the same key, its trailer repeats/Encryptand the first/IDelement, and the signed revisions stay byte for byte — M03's round trip and M09's stamps now run on encrypted inputs.RemoveandApplyneed a full rewrite, which M03 refuses on a signed document unless the caller insists, and owner authentication, orIgnore(reported).PdfEncryptionOptions:Aes256(R6) by default;Aes128(R4),Rc4_128(R3) andRc4_40(R2) only withAllowLegacyAlgorithms, each reported (encrypt.weak-algorithm), and RC4 refused in 2.0 output, which keeps R6 alone. An owner password is required when the permissions restrict anything — an owner password equal to the user password grants the user everything, which would make the restriction a pretense. R2 to R4 passwords must be representable in PDFDocEncoding (ArgumentExceptionotherwise: readers would not reproduce the bytes); R6 passwords go through SASLprep.EncryptMetadata = falseleaves the XMP readable to indexers;EmbeddedFilesOnlywrites/StmFand/StrFas/Identityand/EFFas the standard filter — a case file whose annexes alone are protected.- Where it happens. The encryption stage belongs to M03's forward-only writer: strings are encrypted as each
object is serialized, under its number, which the writer reserved ahead; a stream is compressed, then
encrypted, then written behind its indirect
/Length, which now counts the IV and the padding; objects bound for an object stream are written clear, and the object stream encrypted as a stream; the cross-reference stream and/Encryptare written clear. - Determinism (invariant 6). R2 to R4 derive the file key from the passwords,
/Pand the first/IDelement, which M03 takes from the input or the caller, never from its own hash — so their output is fixed. R6 wants a random file key and two salts, and AES wants unpredictable IVs. So: every IV is the first 16 bytes of an HMAC-SHA-256 keyed by the file key over the object's number, generation and the string's or stream's index within it — unique, unpredictable without the key, and deterministic; the R6 file key and salts come fromPdfEntropy, which the options require:System, the platform's generator, is the caller's explicit choice of varying output, andFromSeed(bytes)reproduces identical bytes run after run. An ADR written with slice 4 records this: randomness in encryption is an input the caller supplies, like a date. - Conformance. Every part of PDF/A forbids encryption: under a claim,
Applyfollows M09'sPdfConformancePolicy—RefusethrowsPdfConformanceException;RemoveClaimencrypts, removes the claim and reportsencrypt.conformance-claim-removed. PDF/UA-1 requires that encryption not stop assistive technology: under a PDF/UA claim,ExtractForAccessibilityis always granted, and a caller who clears it meets the same policy.
Output version (ADR 40)
M03's table of minimum versions gains, checked against the Arlington model's SinceVersion rather than written
from memory (ADR 44):
| What M16 writes | Minimum |
|---|---|
RC4 with a 40-bit key (V 1, R 2) | 1.1 |
An AcroForm, widgets, /DA, /Q, /MaxLen | 1.2 |
/TU, /TM, signature fields | 1.3 |
RC4 with a longer key (V 2, R 3); file-select, DoNotSpellCheck, DoNotScroll | 1.4 |
Crypt filters (V 4); comb and rich-text flags; RadiosInUnison; CommitOnSelChange; /Lock and /SV | 1.5 |
AES-128 (/AESV2); /EFF | 1.6 |
AES-256 (V 5, R 6) | 2.0, or 1.7 with /Extensions /ADBE at level 8, which M03's model writes and unions |
PDF 2.0 deprecates NeedAppearances and XFA: the first is never set in 2.0 output, since a 2.0 fill always
generates appearances, and the second is carried only as the input had it, never created. M02's version-aware
shape rules report what an input carries.
The form model
- The field tree is walked lazily from
/AcroForm /Fields, iteratively, with a visited set, bounded by the index as M11's walks are — M16 adds no reader limit; a field tree with a cycle exists in the remote corpus, and M06's walker already names fields this way. A terminal field is one whose kids are widgets, or none; a field and its only widget merged in one dictionary is read as both./FT,/Ff,/V,/DV,/DAand/Qare inherited down the tree,/DAand/Qfrom the AcroForm dictionary last;/MaxLentoo, as viewers read it. - Names: the partial name
/T, decoded as a text string (the Cerfa forms'Prénomis UTF-16BE), the fully qualified name joined with periods; a partial name that contains a period, or two terminal fields with the same fully qualified name that are not kids of one field, are listed as they are and found by validation./TU(tooltip, the accessible name) and/TM(the export name) are read. - Values: a text field's
/Vis a text string or a text stream; a button's is a name — for a check box, one of its widgets' on-states, found as the key of/AP /Nthat is not/Off; for a radio group, the on-state of one kid, mapped through/Optwhen the kids' states are indices — which is how Acrobat writes non-ASCII and duplicate export values; a choice field's is a string or an array, with/Ifor the selected indices and/Optas strings or as[export display]pairs. - Scripts: each field's
/AA(keystrokeK, formatF, validateV, calculateC, and the mouse and focus events) and its/A, each JavaScript source read — a string or a stream, bounded by the object limit — and classified by the recognizer below; document-level scripts (/Names /JavaScript,/OpenAction, the catalog's/AA) counted and listed. Nothing is evaluated. - The listing — each field's name, type, flags, value, default value, options, widgets (page, rectangle,
flags, on-state, whether an appearance exists), scripts, XFA kind, usage rights — serializes to documented
JSON; it is what the
fieldsverb prints.
Filling
form.Fill(values, options) and field.SetValue(value) record the change in M03's change set; nothing is
written until the save.
- By type: a text field takes
Text, or aNumber,DateorTimethat its recognized format renders (below);/MaxLenrefuses a longer value; a comb field without/MaxLenis drawn as a plain field and reported. A check box takestrue,falseor the name of a state; a radio group the export value of one kid —/Von the field,/ASset to that kid's on-state and to/Offon the others. A combo box takes one of its options, or any text when it is editable; a list box one option, or several when it is multi-select, with/Irewritten sorted. - Refused, typed: a read-only field, unless the options override it (reported); a password field,
whose value ISO 32000-2 says must never be stored in the file; a file-select field; a signature field —
giving it a value is signing, M26's; a field a
FieldMDPlock or a DocMDP certification forbids, through M04's guard (below). - Rich-text fields (
/RV): the plain value is written, and/RVrewritten as a minimal XHTML body holding the same text, so that a viewer that prefers/RVshows the value (fill.rich-text-simplified, information). One committed form has such a field, DD 293's. - Kids share a value. A field with several widgets, on several pages, gets one appearance per widget.
- Encodings: a text string in PDFDocEncoding when it can be, UTF-16BE with its mark otherwise (#36's writer,
M03); names for button states, with
#escapes for bytes outside the regular characters — a radio export value such asm#00nnlich, in the remote PDFBox form, is written back byte for byte. - After a fill,
NeedAppearancesis removed, since every appearance was generated — PDF/A-2 and 3 forbid it true, and PDF 2.0 deprecates it — unless the caller choseNeedAppearancesmode, which writes no appearance, sets it, and is refused under a PDF/A claim.
Field appearances
Generated for every widget whose field was filled, and on demand (EnsureAppearances) for widgets that lack
one — hundreds do in the corpus, blank fields mostly: 185 of the 419 widgets of Cerfa 12156, 97 of the 136 of
the 2022 Form 1040. Each is a form XObject written through M08's content builder in M11's appearance frame,
numbers in "0.####".
- Variable text (ISO 32000-2 §12.7.4.3):
/DAparsed as the content-stream fragment it is —Tfgives the resource name and size, a color operator the color —; the text laid inside the widget's rectangle less its border and a two-point padding, as Acrobat pads; single-line text centered vertically, multiline text from the top and wrapped by M08's greedy breaking at spaces;/Qfor alignment; a comb field in/MaxLenequal cells, one character centered in each; size 0 meaning auto: fitted to the inner height, then shrunk until the line fits, never below 4 points — the constants checked in slice 7 against pdf.js's and qpdf's generators on the corpus. The text sits inside/Tx BMC … EMC, which viewers look for when they regenerate. - The frame:
/MK /BGfilled,/MK /BCstroked with/BS— solid, dashed with its/Darray, beveled and inset with their lighter and darker edges, underline —,/MK /Rof 90, 180 or 270 degrees applied through the XObject's/Matrix. - Choice fields: a combo box shows its value; a list box its options from
/TI, the selected ones on the highlight Acrobat draws. - Buttons: an existing appearance for a state is kept — filling a check box changes
/AS, not its drawing. A missing one is drawn: the/MK /CAcharacter of the ZapfDingbats set — check, circle, cross, diamond, square, star — as our own vector paths, not as text in a ZapfDingbats font that would not be embedded; a push button's caption as text; its icon,/MK /I, kept. - Fonts.
/DAnames a font in/DR, nearly always/Helv, Helvetica, not embedded. By default (EmbedSubstitutes) the text is drawn in M08's embedded, metric-compatible OFL substitute of the font/DAnames — Helvetica and Arial by the sans face, Times by the serif, Courier by the monospaced one —, subset in the appearance's own resources, while/DAand/DRare left as the form's author wrote them: a viewer that regenerates the appearance later gets the same metrics, and a PDF/A claim survives the fill. A font/DRembeds is used for the characters its program covers — in full or as a subset: Cerfa 12156's Arial, OmniForm's Verdana —, and M08's per-cluster fallback supplies the rest, reported (fill.font-substituted); a non-embedded font that is not a standard one (Minion, Myriad, TimesNewRomanPSMT, ArialMT) is looked up in the registry by name, then substituted.UseDeclareddraws with the declared font as viewers do, and is refused under a PDF/A claim. Text that needs shaping is drawn in logical order and reported by M08 (text.shaping-required), as for M11's appearances. - Tagged documents: a filled field's structure does not change; a widget without a
Formelement, which PDF/UA-1 requires, is reported, not repaired.
Acrobat formats and calculations, without a script
A script is text the recognizer tokenizes, never evaluates. It recognizes a source consisting of exactly one call to one of Acrobat's standard functions with literal arguments — optional whitespace, one optional semicolon, and nothing else, not a comment, not a second statement:
| Function | Arguments | What it means here |
|---|---|---|
AFNumber_Format, AFNumber_Keystroke | decimals, separator style (0 1,234.56, 1 1234.56, 2 1.234,56, 3 1234,56, 4 1'234.56), negative style (minus, red, parentheses, red parentheses), currency style, currency string, currency before | The value rendered in the appearance; a text value parsed under the same separator style; red drawn as the text color |
AFPercent_Format, AFPercent_Keystroke | decimals, separator style, and the percent sign's position when given | The value × 100 with % |
AFDate_FormatEx, AFDate_KeystrokeEx, AFDate_Format, AFDate_Keystroke | a util.printd picture (d, dd, m, mm, mmm, mmmm, yy, yyyy, H, HH, h, hh, M, MM, s, ss, tt), or the index of Acrobat's fixed list | A Date rendered in the picture; a text value parsed by it; month names in English, as Acrobat's are |
AFTime_FormatEx, AFTime_Format and their keystrokes | a picture, or an index | As dates |
AFSpecial_Format, AFSpecial_Keystroke | 0 zip code, 1 zip + 4, 2 telephone, 3 social security number | Digits placed in the fixed mask |
AFSpecial_KeystrokeEx | a mask (9, A, O, X, *) | The value checked against the mask |
AFRange_Validate | greater-than flag and bound, less-than flag and bound | The value checked against the range |
AFSimple_Calculate | "SUM", "PRD", "AVG", "MIN", "MAX", and an array of field names | Recomputed after a fill |
- Formats apply to the appearance, never to the value, as in Acrobat:
/Vkeeps1234.5, the widget shows£1,235under the HMRC form'sAFNumber_Format(0, 0, 0, 0, "£", true). A keystroke or validate function checks the caller's value: underApply, a value it would reject is refused (fill.value-rejected, naming the function), since Acrobat would not have let a user type it. - Calculations run after a fill, once, in
/COorder — Acrobat's order, not a dependency order —, each calculated field's sources read as numbers under their own format's separator style, empty as zero, a name that designates a non-terminal field standing for every terminal field beneath it. Arithmetic isdecimal, and the result is rounded by the target's format; where it differs from Acrobat's binary floating point, the difference is at most one unit in the last place the format shows, a limit tested and documented. - Everything else is reported, never guessed. A custom script, an unrecognized function, a recognized one
with a non-literal argument, trailing code:
fill.script-not-run(warning) naming the field and the event; a calculated field whose calculation is such a script and whose sources changed is left as it was and reported stale (fill.calculation-stale, warning). The HMRC IHT205 form has seventeen of them, the corpus's test that nothing is guessed; Cerfa 12156's 26 sums are the test that the recognized ones are computed. - The referee is pdf.js's own implementation of these functions (
src/scripting_api/aform.js, Apache-2.0), run under Node in a container on the same values and arguments (below).
XFA
- Detected by
/AcroForm /XFA, a stream or an array of packets, and classified by what the file says, not by guesswork:
| Kind | How it is recognized | In the corpus |
|---|---|---|
Dynamic | The catalog's /NeedsRendering true, or the configuration's dynamicRender required: the PDF page is a placeholder the viewer replaces | The two Canadian forms (remote) |
Static | Otherwise, the template's baseProfile="interactiveForms" — XFA foreground: the page content is PDF, XFA describes the fields | Cerfa 14880 |
Hybrid | Otherwise, a full template rendered statically — dynamicRender forbidden in all four committed forms — beside an AcroForm field tree that mirrors it | The 2022 Form 1040, DD 293, AR-11 |
The tests run in that order; a file that fits none — XFA with no AcroForm field and no placeholder — is
listed as Hybrid with no mirror and reported.
- Parsed as hostile XML:
XmlReaderwith DTDs prohibited, no resolver, the characters bounded by the stream's own decoding limit, and no recursion, so that depth costs nothing; packets read on demand, the datasets through a forward-only reader. Thexmpmetapacket some forms carry inside their XFA is listed and never mistaken for the document's XMP. - Datasets read:
PdfXfaInfo.Datasetsgives thexfa:datasubtree; the listing shows each bound value. - On fill (
PdfFillOptions.Xfa):KeepInStep, the default for static and hybrid forms, writes each filled value into the datasets packet at the node the template binds the field to — the normal binding by named subforms and fields with their occurrence indices, and explicitdataRefbindings of the plain$.a.b[n]shape — text, numbers, check boxes through the template's<items>, choice lists through their saved values. A binding outside those shapes (global, scripted SOM expressions, a<picture>the slice does not convert) makes partial synchronization a lie: the whole XFA is then removed instead, and each unresolved field reported (xfa.removed, warning). Theformpacket, where Acrobat keeps its own record of the merged state, is left as it was; how Acrobat treats datasets it did not write is exactly what no container can check, and a form filled this way and saved by Acrobat is wanted in the corpus (below).Removedeletes/XFA: the AcroForm becomes the form (xfa.removed).RefusethrowsPdfFormException.- A dynamic form is never filled:
PdfFormExceptionnaming ADR 37; removing its XFA leaves the placeholder page as the only content, which is reported (xfa.placeholder-left, warning).
- XFA scripts (
<script>,<calculate>,<validate>in the template — 33 scripts in the AR-11) are counted and reported with every fill (fill.script-not-run), like AcroForm scripts.
Usage rights
/Perms /UR3(and legacy/UR, whose signature has no byte range — the remote CFIA form) — M04 lists them with their coverage and keeps their transform parameters; M16 reads the grants:/Document(FullSave),/Form(Add,Delete,FillIn,Import,Export,SubmitStandalone,SpawnTemplate,Online),/Annots(Create,Delete,Modify,Copy,Import,Export,Online,SummaryView),/Signature(Modify),/EF(Create,Delete,Modify,Import),/P,/Msg.- Whether a save keeps them is decided from M04's classification of the pending change set: an incremental
update whose every change is a class the grants cover —
FormFillunderFillIn,AnnotationunderCreate,ModifyorDelete, a new signature underSignature Modify, an embedded file under/EF— leaves the usage-rights signature covering its revision and its grants honored. A full rewrite, or a change of any other class, breaks them: Adobe Reader then disables its extended features with a message the user cannot act on. PdfSaveOptions.UsageRights:RemoveWhenBroken, the default, removes/UR3and/URfrom/Permsin the same save and reportsusage-rights.removed(warning) with the change that broke them — what Acrobat's Save a Copy does;Refusethrows before writing;Keepleaves them broken, reported (usage-rights.broken-kept, warning). M09'sstamp.usage-rights-brokenand M11'sannotate.usage-rights-brokennow lead to this decision.- Removing
/Perms /UR3changes the catalog, which M04 classifies asOther: on a document that is also certified — the Canadian IMM 1344 form — removal would break the certification, so the save is refused unless the caller insists, and the report names both. - No container runs Adobe Reader: that an incremental fill within the grants keeps Reader's features is asserted structurally — coverage, classes, grants — and recorded as such; a Reader-saved fill is wanted in the corpus (below).
Signed and certified forms
Filling is M04's FormFill class: allowed under DocMDP P=2 and P=3, forbidden under P=1; a field a FieldMDP
lock names is forbidden to it. M04's write guard applies unchanged — PdfSignatureInvalidationException, or
the insisted path with write.signature-invalidated —, and a fill of a signed document is always an
incremental update, since M03 refuses a full rewrite of it. Filling and then signing an empty signature field is
the ordinary contract workflow: the fill is written as one revision, the signature (M26, or pyHanko in the
tests) as the next.
Flattening fields
form.Flatten(filter, options) hands widgets to M11's flattener — its placement algorithm, its All, Print
and Screen modes, its layer policies —, after generating appearances for the filled widgets and for those that
have none.
- The flattened fields leave
/Fields, their widgets leave/Annots, and a parent left without kids leaves too;/AcroFormis removed when no field remains, with/NeedAppearances,/XFA,/COand/SigFlagsrecomputed otherwise. - Hidden widgets are skipped, as M11's modes say: OmniForm's hidden widgets, which pdf.js once drew outside the page, stay out of the content.
- What is lost is reported: every script of a flattened field and the calculation order
(
flatten.scripts-removed), the XFA (xfa.removed), usage rights (the save decides, above). - Signature fields: a signed one is never flattened — its appearance as page content would show a
signature nobody can verify (
flatten.signed-field-kept); an unsigned one is kept by default, since it is a placeholder, and flattened only when named. - Tagged documents: the field's
Formelement keeps its place; itsOBJRis replaced by the marked content of the flattened appearance, and it gains aPrintFieldattribute object —/Role(tv,cb,rb,pb),/checked,/Descfrom/TU—, the form ISO 32000 defines for a form that is no longer interactive; slice 9 settles the result against veraPDF's PDF/UA-1 profile and records it. - Signed and extended documents: flattening changes content — M04's
Other, refused under a certification and written as an update after approval signatures, as M11 flattens (flatten.after-signature).
Creating fields and signature placeholders
PdfFormBuilder works in two contexts:
- on an opened document: fields and widgets enter M03's change set, widgets placed through M09's page edits
and M11's annotation writer; a new field named like an existing one is refused, or added as a widget of it
when its type and options agree (
AddWidget); M04's guard classifies a new signature field asNewSignature(allowed under P=2) and any other new field asOther; - on a document being generated, M08's
PdfDocumentBuilder.Form: a widget is written with its page, the field tree — terminal fields, their parents,/Fields,/DR,/SigFlags— atFinish, from object numbers reserved at first use, as M13 writes the structure tree. It is the path M17 takes, and it is forward-only.
What a field takes — immutable options per type: name (a period in it creates the parents), tooltip, mapping
name, flags (read-only, required, no-export), value and default value, /MaxLen, comb, multiline, password,
scroll and spell-check flags, alignment, font (PdfFieldFont.Standard(...), which names a standard font in
/DR and draws the appearance in its embedded substitute, as a fill does, or PdfFieldFont.Embedded(face, Subset | Full) — the second for a viewer to type any character later, at a documented cost in size), size (0
for auto), colors, border, background, rotation, options with export values, and for push buttons a
ResetForm action or a SubmitForm action with its URL, format and field list — never a JavaScript action
(ADR 37). Each action is checked against the claimed PDF/A part's list of forbidden actions, as veraPDF's rules
state it.
- Appearances are generated at creation by the generator above, or supplied by the caller for any field
type — a form XObject for
/Nof a text or choice field, one per state of a button (its on-state and/Off, and/Dwhen given) — and then written as given, the field's/DA,/Q,/MKand/BSstill set so that a viewer can redraw it: M17 paints its fields with M12's painter and hands the appearances over this way. A created field is never left toNeedAppearances. - Tagging: on a tagged document, each widget in a
Formelement holding itsOBJR, under the structure element the caller names, or appended after the page's last element and reported (field.tag-appended);/TUrequired under a PDF/UA claim (a conflict for M09'sPdfConformancePolicyotherwise);/Tabs /Son the page. - Signature placeholders (
AddSignatureField):/FT /Sig, a widget visible — its appearance the caller's form XObject, or a frame with an optional caption, so that the box shows in every viewer before signing — or invisible, with a zero rectangle, which PDF/A exempts from an appearance;/Lock(All,IncludeorExcludewith field names, and in 2.0 output its/P);/SVseed values — filter, subfilter, digest methods, reasons, legal attestations, MDP, a time-stamp server's URL as text, certificate constraints as bytes the caller supplies —, with their/Ffflags;/SigFlagsgainsSignaturesExist. No cryptography, and no network: M26 signs the field; pyHanko signs it in the tests.
FDF, XFDF and JSON
PdfFormData.Export(form, format, output, options) and Import(input, format) → a PdfFormDataSet of values
and annotations, which Apply(form, set, fillOptions) fills like any other values.
- What travels: every terminal field's value by fully qualified name, fields with
NoExportleft out, empty ones on request; the document's/ID(/IDin FDF,<ids original modified>in XFDF) and a file reference the caller gives (/F,<f href>); withIncludeAnnotations, the markup annotations of M11's model, in the subtypes it authors, mapped to FDF's/Annotsand XFDF's<annots>elements — M11 left this exchange here. - Import reports each name the form lacks (
form-data.field-unknown), each value its field refuses (form-data.value-rejected), and an/IDthat is not the document's (form-data.document-mismatch, information), and applies the rest. - FDF (ISO 32000-2 §12.7.8) is PDF syntax: read by M01's lexer and parser under its limits, the
/FDFdictionary's/Fieldswalked iteratively with a visited set, hierarchical/Kidsand dotted/Tboth accepted./JavaScript,/EmbeddedFDFs,/Pagesand an encrypted FDF are not followed: each reported (form-data.ignored), never executed or fetched. Written as%FDF-1.2, deterministic, strings encoded as text strings. - XFDF (ISO 19444-1, XFDF 3.0):
XmlReader, DTDs prohibited, no resolver, its size under the caller's reader limits (ADR 34), read without recursion; nested<field name>elements and dotted names both accepted on import;<value>repeated for a multi-select list,<value-richtext>read as text. Written in UTF-8, nested, in field order. - JSON: a schema of ours, published on the site as JSON Schema 2020-12 — a list of fields, in order, each
{ "name", "type", "value" }, a check box's valuetrue,falseor a state's name, a multi-select's an array,nullto clear —, read and written withSystem.Text.Jsonsource generation (AOT). The same shape is thefieldslisting's, so a listing edited by hand fills the form.
Batch filling
PdfBatchFiller.Create(template, options) opens the template once, analyzes its fields — types, recognized
formats, calculation order, appearance recipes with their fonts resolved and their substitutes subset lazily —
and freezes that analysis; records then fill it one at a time or in parallel.
- Outputs: one file per record, as an incremental update of the template's bytes — the template copied
verbatim and one revision appended, which keeps usage rights when the grants allow it and costs a copy and a
few objects —, or as a full rewrite, flattened or not; or one volume, through M06's assembly, each record's
fields nested under a name of its own (M06's
Nest: the record's key, orrecord-n), or flattened first. - Records:
IEnumerable<PdfFormDataSet>, from JSON, XFDF, FDF, or — through the tool — CSV whose header names the fields; each record's report kept by its key. - Memory follows one record: the template's index and analysis are shared read-only, each worker holds its
own lightweight document over the shared source (invariant 8 — a
PdfDocumentis not thread-safe), and a record's objects are released once its output is written. Which reader structures must become shareable is slice 12's first measurement. - Determinism: a record gives the same bytes alone, first, last, or in parallel;
/IDper M03's rule or from the caller; no date the caller did not supply.
Validation rules
Added to M02's structural profile, documented in docs/website/docs/reference/validation-rules.md, and declared in the manifest's
findings of every document that earns them. M02's slice 5 opens the security.* family with what needs no
key; M16 adds what does:
| Rule | Severity | Meaning |
|---|---|---|
security.permissions-mismatch | Warning | /Perms disagrees with /P or with EncryptMetadata |
security.authentication-failed | Error | An AES-GCM tag or the ISO/TS 32004 MAC does not verify: the bytes changed after encryption, and readers that check disagree with those that do not |
security.revision-deprecated | Warning | A document declaring PDF 2.0 encrypted with a revision other than 6 |
security.wrapper-document | Information | The document is a wrapper; its content is the payload |
form.xfa-out-of-step | Error | A hybrid or static form whose datasets and AcroForm values differ: Acrobat shows one, every other viewer the other |
form.xfa-dynamic | Information | The pages are a placeholder; only an XFA processor shows the form |
form.value-without-appearance | Warning | A field has a value, a widget has no normal appearance, and NeedAppearances is not true: viewers that do not regenerate show nothing |
form.field-name-duplicate | Warning | Two terminal fields share a fully qualified name without being kids of one field |
form.partial-name-period | Warning | A partial name contains a period, which ISO 32000-2 forbids |
form.radio-value-no-widget | Warning | A radio group's or check box's /V names no widget's on-state |
form.field-tree-cycle | Warning | The field tree returns to a field already in it |
form.usage-rights | Information | The document is Reader-extended: a save outside its grants removes them |
Diagnostics and report codes
Reader diagnostics, in PdfDiagnosticCodes, disjoint from validation rule identifiers (ADR 36):
encryption.password-encoding (information), encryption.content-not-decrypted (warning),
encryption.padding-invalid (repair), encryption.permissions-mismatch, encryption.authentication-failed,
encryption.mac-mismatch (warning), encryption.mac-unverified (information),
encryption.public-key-handler (warning), encryption.wrapper-document (information). Every clean encrypted
corpus document must open with information entries only.
Report codes, in M06's PdfOperationReport, each documented with what to do about it: permission.not-respected;
encrypt.* (weak-algorithm, metadata-clear, conformance-claim-removed, accessibility-granted); fill.*
(script-not-run, calculation-stale, value-rejected, value-coerced, format-not-applied,
font-substituted, rich-text-simplified, read-only-overridden, appearance-approximated); xfa.*
(kept-in-step, removed, placeholder-left); usage-rights.* (removed, broken-kept); flatten.*,
extending M11's (scripts-removed, signed-field-kept); field.* (tag-appended, name-collision,
action-refused); form-data.* (field-unknown, value-rejected, document-mismatch, ignored).
The command-line tool
Passwords are never taken as an argument, which process lists and shell histories keep: from a file, from the
environment variable --password-env names, or from standard input.
security FILE [--password-file F] [--json] handler, revision, methods, permissions, wrapper, MAC
encrypt FILE -o OUT (--random | --seed-file F) [--algorithm aes-256|aes-128|rc4-128|rc4-40]
[--user-password-file F] [--owner-password-file F] [--allow print,print-high,modify,copy,
annotate,fill,accessibility,assemble] [--metadata clear] [--attachments-only]
decrypt FILE -o OUT [--password-file F] [--ignore-permissions]
fields FILE [--password-file F] [--json] fields, values, scripts, XFA kind, usage rights
fill FILE (--json D | --xfdf D | --fdf D) -o OUT [--flatten] [--xfa keep|remove] [--full-rewrite]
fill FILE --records R.json|R.csv (-o DIR/{key}.pdf | --volume OUT) [--flatten]
export FILE --format xfdf|fdf|json [--annotations] [-o OUT]
flatten FILE --fields [NAME…] M11's verb, extended
Slices
Each slice ends on a green commit, with its acceptance rows passing, the codes and rules it introduces documented, and the referee harness it adds running in CI.
- The standard handler, R2 to R4, and crypt filters. Delivers
PdfCredentials, the password encodings, algorithms 1 to 7, RC4, AES-128-CBC, the decryption stage of M01's pipeline, what is and is not encrypted, crypt filters (V4) withIdentity,/CryptandEncryptMetadata, the typed failures, the new meaning ofThrowOnEncrypted, the managed RC4 and AES-CBC and -ECB beside M06's MD5, and the platform matrix that chooses between them and the framework's. Addsexpect.security(handler, revision, key length, methods, permissions) to the manifest and its schema, written bybuild_corpus.pyfromqpdf --show-encryption. Proven by unit tests — RFC 1321's MD5, RFC 6229's RC4 and NIST SP 800-38A's AES vectors, run on the managed code and on the framework's alike, keys derived from fixtures whose keyqpdf --show-encryption-keyreports, every exception of What is encrypted, FsCheck (decryption in chunks of any size equals decryption in one) — and on the corpus: the sixteen committed documents under R2, R3 and R4 open with their recorded password and meet every expectation of their entry;CorpusReadingTestsstops expecting a refusal; the OPF documents match their unencrypted siblings;QpdfSecurityRefereeTestscompare our decrypted object graph withqpdf --decrypt's, in a container. Leaves AES-256. - AES-256 and permissions. Delivers R5 and R6 — algorithm 2.B, SASLprep,
/UEand/OE,/Perms—,PdfPermissions, the policy and the ADR that makes respecting it the library-wide default,Demandand its calls in M06, M07, M09, M11 and M15's operations, and thesecurity.permissions-mismatchrule. Proven by unit tests (RFC 4013's examples, a password of 128 bytes and one of 1 MB, a 16-bit/P, every bit under every revision) and by the AES-256 invoice matching the Chromium invoice it was derived from; every encrypted document's permissions equal qpdf's and pikepdf's; M09's stamp on the AR-11 refused byModify, extraction from it refused byCopy, both written and reported underIgnore. Leaves PDF 2.0's additions. - What PDF 2.0 adds, and what the core cannot open. Delivers AES-GCM and the MAC — after checking every
constant against ISO/TS 32003 and 32004, and settling the MAC container question with ADR 41 —,
PrimitiveUnavailable, the public-key detection andIPdfDecryptionKeySource, wrapper documents, M06'sassembly.wrapper-placeholder, and thesecurity.authentication-failedandsecurity.wrapper-documentrules. Proven by the fixtures of What it lacks — GCM and MAC files and their tampered copies, a public-key file opened through the test project'sEnvelopedCmssource, a wrapper — each matching its twin, and by pyHanko decrypting the public-key file with the same key in a container. Leaves writing. - Encryption on write. Delivers
Keep,RemoveandApply,PdfEncryptionOptions,PdfEntropy, the encryption stage of M03's writer, derived IVs, incremental updates under the input's key, the version rows, the PDF/A and PDF/UA rules, the ADR on randomness, and theencrypt,decryptandsecurityverbs. Proven by unit tests (FsCheck: any document encrypted then decrypted gives its object graph back, for every algorithm; IVs never repeat within a document; two runs with one seed give identical bytes) and by qpdf, pikepdf, poppler'spdfinfo, pdf.js and MuPDF opening what we encrypt with the same passwords and permissions. Leaves forms. - The form model. Delivers
PdfAcroForm, the field views, widgets, values, inheritance, names, scripts classified, the listing and thefieldsverb, and the rulesform.field-name-duplicate,form.partial-name-period,form.radio-value-no-widget,form.field-tree-cycle,form.value-without-appearance. Extendsexpect.formFields, which M06 filled with names, to name, type and value, from qpdf's--jsonacroformkey. Proven by unit tests (merged field and widget, inheritance at every level, a value as a stream,/Optindices, a cycle, a tree a hundred thousand deep, a million kids, hostile JavaScript sources) and by qpdf, pikepdf, PyMuPDF and pdf.js'sgetFieldObjects()listing the 1,269 terminal fields of the 25 committed documents that have any as we do. Leaves XFA and usage rights. - XFA and usage rights, read. Delivers the classification, the bounded packet reader, the datasets, the
grants,
form.xfa-dynamic,form.xfa-out-of-stepandform.usage-rights; addsexpect.form(XFA kind, usage-rights grants, script counts) to the manifest. Proven by unit tests (DTDs, entity expansion, a packet of 100 MB, a depth of a million, every grant) and by pdf.js'sisPureXfaand pikepdf's reading of/NeedsRendering, the packets and/Permson the four committed XFA forms, the HMCTS AcroForm's usage rights and the remote dynamic forms. Leaves writing forms. - Filling and appearances. Delivers
Fill,SetValue, every type's rules and refusals, the appearance generator, fonts and substitutes,EnsureAppearances, XFAKeepInStepandRemove, the usage-rights policy, fills under signatures through M04's guard, and thefillverb for JSON. Proven by unit tests (snapshots of appearance content bytes for each type, border and rotation; FsCheck: an auto-sized value fits its box for any string and box; a comb of any length), and by the round trip, rendering and referee rows below — MuPDF and pdf.js drawing our appearances alike within the threshold, pdftotext finding the values, pyHanko classifying a fill after signing as form filling. Leaves formats. - Formats and calculations. Delivers the recognizer, the formats in appearances, keystroke and range
checks,
AFSimple_Calculatein/COorder, stale calculations reported. Proven by unit tests (FsCheck: format then parse gives the number back under every separator style; the recognizer accepts exactly the grammar, over a corpus of near-misses — a comment, two statements, a variable argument) and byAcrobatFormatRefereeTests, which run pdf.js'saform.jsunder Node in a container on every format and sum the corpus declares, and on generated values around each boundary. Leaves flattening. - Flattening fields. Delivers
Flattenthrough M11's flattener, selection by name, the AcroForm clean-up, signature fields, tagged flattening withPrintField, the loss reports,flatten --fields. Proven by rasterized comparison with the filled page, byqpdf --flatten-annotations=all --generate-appearanceson the same fills, pikepdf finding no field, pdftotext finding the values in the page content, and veraPDF's PDF/UA-1 profile on the InDesign form. Leaves field creation. - Creating fields and signature placeholders. Delivers
PdfFormBuilderin both contexts, every type, caller-supplied appearances for every type, the actions allowed, tagging,AddSignatureFieldwith/Lockand/SV. Proven by unit tests (collision policies, a field name with periods, every option, a caller's appearances written as given, per state for buttons) and by qpdf, pikepdf and pdf.js listing created fields, veraPDF on PDF/A and PDF/UA inputs, and pyHanko signing each placeholder in a container and finding the signature intact and the lock applied. Leaves exchange. - FDF, XFDF and JSON. Delivers
PdfFormData, the three formats both ways, annotations through M11's model, the JSON schema, theexportverb andfill --xfdf|--fdf. Proven by unit tests (hostile XML, FDF with a cycle, embedded FDFs, JavaScript; FsCheck: export then import is the identity on values for any form we create) and by pdftk-java'sfill_form,generate_fdfanddump_data_fields_utf8in a container agreeing with us in both directions. Leaves batch filling. - Batch filling, budgets and the remote corpus. Delivers
PdfBatchFiller, the shared template analysis, the three output shapes,fill --records,SecurityBenchmarksandFormBenchmarkswithMemoryDiagnoser, determinism across every operation of the milestone. Proven by the batch rows, the budgets recorded instatus.md,CorpusToolTests, and a greenRemote corpusrun.
Tests required
Unit — tests/AdCodicem.Pdf.Tests:
- Security: every algorithm of ISO 32000-2 §7.6 against a known answer — published vectors for the primitives (RFC
6229, RFC 1321 and NIST SP 800-38A, on the managed code and the framework's; 800-38D; those of the key derivation
ISO/TS 32004 names), keys taken from fixtures whose key an independent tool established for the handler; SASLprep
on RFC 4013's examples; algorithm 2.B's round count over random inputs, never past 288; FsCheck — encrypt then
decrypt is the identity for every algorithm, key length, string and stream length, chunking; IVs distinct across a
document; the exceptions of What is encrypted one by one;
/Punder every revision and sign; every operation'sDemandunder each authentication and policy. - Hostile: an
/Encryptthat is a stream, an/Rof 99, a/Lengthof 7,/Oof 5 bytes, a/CFnaming itself,/StmFnaming nothing, AES strings of 15 bytes, a GCM tag cut short, a/Recipientsarray at the object limit, a key source that throws or returns 1 MB, a wrapper with a thousand embedded files — each ends in a typed exception or a report, inside time and allocation budgets. - Forms: inheritance of every inheritable entry; merged fields and widgets; each field type's values and
refusals;
/Optwith and without pairs; radio states by index; names with UTF-16, PDFDocEncoding and periods; appearance snapshots by content bytes per type, border style, rotation, alignment, comb and auto size; font substitution and fallback; formats — every separator, negative and currency style, every picture token, every special format, leap days and years past 9999 refused —; calculations in/COorder with a source after its target, as Acrobat computes it; the recognizer against its grammar and its near-misses; XFA binding shapes, supported and not; usage-rights grants against every change class; flattening per mode on widgets; creation with every option and in both contexts; FDF, XFDF and JSON both ways. - Hostile forms: a field tree with a cycle, a depth of a million, a million kids;
/DAof 1 MB; a JavaScript stream at the decoding limit; XFA with a DTD, an entity bomb, a depth of a million; XFDF alike; FDF with/Kidscycles — each a report or a typed exception, bounded in time and memory. - Determinism: every write of the milestone twice, identical bytes, under the invariant culture and
fr-FR.
Integration — tests/AdCodicem.Pdf.IntegrationTests, every referee in a container
(ADR 27):
- qpdf —
--show-encryption,--decrypt,--checkwith a password,--json(acroform,encrypt), and--flatten-annotationswith--generate-appearances. - pikepdf — permissions, the field list,
/Perms, XFA packets and datasets (read with lxml),/NeedsRendering. - poppler —
pdfinfo(encryption, permissions),pdftotexton decrypted, filled and flattened documents — it extracts text drawn in widget appearances, as it does in DILA's notice —,pdftoppm. - MuPDF —
mutool drawrendering,mutool info. - pdf.js, pinned
pdfjs-distunder Node — opening with a password,getPermissions(),getFieldObjects(),isPureXfa, rendering with forms enabled; itsaform.jsrun on our formats and sums. - PyMuPDF — widgets, their values and types, as a second listing.
- pdftk-java —
fill_formwith our XFDF and FDF,generate_fdf,dump_data_fields_utf8. - pyHanko — decrypting the public-key fixture; signing our placeholders and existing empty fields;
evaluate_modifications()on fills after signatures. - veraPDF — PDF/A at the claimed level and PDF/UA-1 after fills, flattening and field creation; encryption of a claiming document refused or its claim removed.
Images are compared in the referee container, as M11 does, at M09's cross-engine threshold.
Acceptance conditions
"The encrypted documents" are the seventeen committed ones whose password the manifest records: under R2,
vendor/us-federal/distiller2-irs-9465-1996-rc4-40.pdf,
vendor/us-federal/pdfwriter3-copyright-office-dmca-summary-1998-rc4-40.pdf and the three
vendor/opf-format-corpus/ibooks-author*-rc4-40-open-password.pdf; under R3,
vendor/uk-ogl/pagemaker-distiller5-hmrc-iht205-form.pdf and the four
vendor/opf-format-corpus/openoffice32-writer-rc4-128-*.pdf; under R4 with AES-128,
vendor/us-federal/livecycle-uscis-ar11-xfa-form.pdf,
vendor/jp-nta/indesign-distiller18-nta-gift-tax-vertical.pdf,
vendor/pikepdf/livecycle-dod-dd293-aes128-xfa.pdf and the three
vendor/opf-format-corpus/pdfmaker9-word-distiller-aes128-*.pdf whose password is empty; under R6,
documents/secured/qpdf-invoice-aes256.pdf. "The AcroForms" are the committed documents with fields other than
signatures, less the three veraPDF fixtures: documents/form/reportlab-subscription-form.pdf, the IRS Form 9465
and SS-4 (vendor/us-federal/distiller3-irs-ss4-1995-form.pdf), the HMCTS N208
(vendor/uk-ogl/indesign-acrobat-hmcts-n208-form.pdf), the HMRC IHT205, Cerfa 13983
(vendor/fr-licence-ouverte/libreoffice-cerfa-13983-form.pdf), Cerfa 12156
(vendor/fr-licence-ouverte/pdfmaker-acrobat-cerfa-12156-form.pdf), the InDesign PDF/UA-1 form
(vendor/pdf-association/indesign15-pdfua1-form.pdf), OmniForm's RD 1924-5
(vendor/us-federal/omniform-usda-rd1924-5-hidden-widgets.pdf) and the I-9
(vendor/us-federal/designer-distiller23-uscis-i9-javascript-form.pdf).
| Documents | Behavior | Verified by |
|---|---|---|
| The encrypted documents | Each opens with its recorded password, as owner where the password is the owner's, and meets every expectation of its manifest entry — pages, cleanliness, diagnostics, findings; opening reads no more than a quarter of the file | CorpusReadingTests.Every_corpus_document_opens_as_its_manifest_describes, CorpusReadingTests.Opening_does_not_read_the_content_of |
| The same | The object graph we decrypt equals the one qpdf --decrypt writes, object by object, streams by decoded bytes | QpdfSecurityRefereeTests.Decryption_matches_qpdf |
documents/secured/qpdf-invoice-aes256.pdf and documents/invoice/chromium-invoice-fr.pdf, from which it was derived; the three PDFMaker 9 AES files and vendor/opf-format-corpus/pdfmaker9-word-distiller-fonts-subset.pdf; the four OpenOffice RC4-128 files and vendor/opf-format-corpus/openoffice32-writer-simple.pdf; the three iBooks files and their unencrypted exports ibooks-author11-quartz-lorem-ipsum.pdf, ibooks-author11-quartz-lorem-ipsum-image.pdf, ibooks-author20-quartz-lorem-ipsum-image.pdf; the six others and their decrypted twins — not in the corpus (below) | Every page's decoded content streams byte-identical to its twin's — the siblings' were checked equal with pikepdf when this milestone was written — and, for the iBooks exports, which were exported separately, the same text in pdftotext | CorpusSecurityTests.Decrypted_content_matches_its_unencrypted_twin |
vendor/opf-format-corpus/pdfmaker9-word-distiller-aes128-unknown-open-password.pdf | With no credentials, and with a wrong password, PdfEncryptedException with PasswordRequired, inside the time budget, no content read; never a page of noise | CorpusSecurityTests.An_unknown_password_is_refused_not_guessed |
Remote: remote/pdfcpu-issues/adobe-sign-certified-aes128-test-agreement.pdf (identity crypt filter, metadata clear, a certification), remote/canada/livecycle-es10-ircc-imm1344-certified-dynamic-xfa.pdf, remote/canada/livecycle-es9-cfia-fish-export-license-dynamic-xfa.pdf, remote/opf-format-corpus/jhove-hul-109-distiller4-mac-word-transportation-report.pdf, remote/opf-format-corpus/jhove-hul-114-livecycle-es10-ircc-imm5257-certified-xfa.pdf | Each opens and meets its expectations; the Adobe Sign file's XMP reads without decryption; its signature's /Contents is left as written, its coverage unchanged, and its change classification — NotAnalyzed in M04 — now given, as pyHanko gives it | CorpusSecurityTests.Remote_encrypted_documents_open_and_their_signatures_are_analyzed |
| Every encrypted document | Permissions equal qpdf --show-encryption's and pikepdf's, including the 1996 form's 16-bit /P 65516 and the R2 files' accessibility, which follows their copy bit | QpdfSecurityRefereeTests.Permissions_read_as_qpdf_reads_them |
The encrypted documents opened as user: the AR-11 and DD 293 (/P −1052: no copying, no modification, no assembly), the IHT205 and the NTA return (−1068: filling but no annotation), the PDFWriter 3 summary (−12), openoffice32-writer-rc4-128-no-copy.pdf and pdfmaker9-word-distiller-aes128-no-copy.pdf (−1044), the PDFMaker files that forbid accessibility (−1556) or printing (−3080) | Under Respect, M15's extraction, M09's stamp, M11's note and M06's page removal each throw PdfPermissionException exactly where /P forbids them, and run where it allows them — filling the IHT205 through bit 9; under Ignore, each runs and reports permission.not-respected | CorpusSecurityTests.Operations_respect_the_permissions_they_need |
The earlier milestones' acceptance tests that left encrypted documents out: M03's round trip, M04's classification, M06's listings and its layer row (CorpusAssemblyTests.Layers_keep_their_identity_and_visibility, on the AR-11 original rather than its twin), M08's font rows (CorpusFontTests.Every_embedded_font_program_reports_the_advances_freetype_reads over the 36 programs of the 17 encrypted documents, CorpusFontTests.Standard_14_widths_are_the_producers_widths on the IHT205's widths), M09's stamps (the dynamic XFA forms' stamp.dynamic-xfa row among them), M10's codes, M11's annotations and layers — the AR-11's layer rows on the original rather than its twin —, M15's extraction | Each runs on the encrypted documents its permissions allow: the round trip keeps /Encrypt and the first /ID element and qpdf opens the result with the same password; the refusal rows that named M16 become permission rows | CorpusRoundTripTests.Encrypted_documents_round_trip_under_their_own_key, CorpusStampTests.Encrypted_documents_are_stamped_under_their_permissions, and each named test with its exclusion lifted |
| A public-key-encrypted document and its test key — not in the corpus (below) | Without a key source, PdfEncryptedException with PublicKeyHandler and encryption.public-key-handler naming the subfilter and the recipients; never content. With the test project's EnvelopedCms source, the content matches its twin, and pyHanko decrypts it to the same objects | CorpusSecurityTests.Public_key_encrypted_documents_are_reported_never_misread, PyHankoSecurityRefereeTests.The_key_source_seam_opens_what_pyhanko_opens |
| AES-GCM and ISO/TS 32004 documents, standalone and attached MAC, their twins and their tampered copies — not in the corpus (below) | The sound ones open and match their twins; a flipped byte inside a string withholds that object and reports encryption.authentication-failed; one outside every string fails the MAC, encryption.mac-mismatch; the attached MAC is reported unverified; validation reports security.authentication-failed on both tampered files | CorpusSecurityTests.Authenticated_encryption_withholds_what_fails_its_tag |
AES-256 R5, R6 under non-ASCII passwords, attachments-only encryption, a /Crypt stream filter, metadata left clear — not in the corpus (below) | Each opens with its password, in the encoding reported, and matches its twin | CorpusSecurityTests.Every_revision_and_crypt_filter_opens |
| A wrapper document — not in the corpus (below) | encryption.wrapper-document and security.wrapper-document; the payload listed and extractable as an attachment; M06's assembly reports the wrapper instead of merging its cover page | CorpusSecurityTests.Wrapper_documents_are_recognized_and_their_payload_exposed |
documents/invoice/chromium-invoice-fr.pdf, libreoffice-invoice-fr.pdf, reportlab-invoice.pdf, documents/report/chromium-report-fr.pdf, documents/contract/chromium-contract-fr.pdf, vendor/pdf-association/indesign13-pdfua1-german-book-chapter.pdf, documents/invoice/qpdf-invoice-with-facturx-xml.pdf (attachments only) | Encrypted under each algorithm, with restricted permissions: qpdf, pikepdf, pdfinfo, pdf.js and MuPDF open each with the same passwords and report the same permissions; qpdf --decrypt gives back the input's object graph; the PDF/UA chapter keeps accessibility granted and passes PDF/UA-1 in veraPDF once decrypted | QpdfSecurityRefereeTests.Documents_we_encrypt_open_with_the_same_password_and_permissions |
documents/archival/libreoffice-report-pdfa2b.pdf (PDF/A-2b), vendor/zugferd/mustang-zugferd2-en16931-invoice.pdf (PDF/A-3u) | Apply throws PdfConformanceException under Refuse; under RemoveClaim the file is encrypted, the claim gone, the loss reported | CorpusSecurityTests.Encrypting_a_pdfa_document_is_refused_unless_the_claim_may_go |
| The encrypted documents that grant annotation or filling | A note (M11) or a fill written as an incremental update stays encrypted under the same key and /ID; the original bytes are a prefix of the output; qpdf reads the update with the password | CorpusSecurityTests.An_update_to_an_encrypted_document_stays_under_its_key |
| The documents authenticated as owner — the AES-256 invoice, the IRS 9465, the iBooks files, the two OpenOffice files behind an open password — and the ten others | Remove gives what qpdf --decrypt gives on the owner-authenticated ones; on the others it is refused under Respect, and written and reported under Ignore | QpdfSecurityRefereeTests.Removing_encryption_needs_the_owner_or_a_declaration |
documents/stress/reportlab-journal-1000-pages.pdf, and its AES-256 and RC4-128 derivatives — not in the corpus (below) | Encrypting and decrypting hold memory flat as pages grow; decrypting a page allocates nothing per block beyond pooled buffers; budgets recorded in status.md | CorpusSecurityTests.Encryption_holds_its_budget, SecurityBenchmarks |
Every document with fields — 1,269 terminal fields in 25 committed documents, and remotely the OPM OF-306 (remote/opm/word365-acrobat-opm-of306-form.pdf) | Every field listed with its fully qualified name, type, flags, value, options and widgets as qpdf's --json and pikepdf list it, PyMuPDF and pdf.js's getFieldObjects() agreeing; expect.formFields met | CorpusFormTests.Every_field_reads_as_referees_read_it |
The committed XFA forms — Cerfa 14880 (vendor/fr-licence-ouverte/livecycle-es9-cerfa-14880-xfa-form.pdf), the 2022 Form 1040 (vendor/us-federal/livecycle-irs-1040-2022-xfa-ur3.pdf), DD 293, AR-11 —; remote, the two Canadian dynamic forms, the IMM 5257, and the filled XFA form inside remote/opf-format-corpus/acrobat9-portfolio-signed-3d.pdf, opened as M07 unpacks it | Static, hybrid, hybrid, hybrid, dynamic, dynamic, and what pdf.js makes of the IMM 5257; pdf.js's isPureXfa true exactly for the dynamic ones; the datasets read equal the packet pikepdf returns, the portfolio member's with its fictitious values; expect.form met | CorpusFormTests.Xfa_forms_are_classified_and_their_datasets_read |
| The AcroForms and the XFA forms | Every script listed by field and event, recognized or not: the IHT205's 36 AFNumber_Format and 36 keystrokes in pounds and its dd/mm/yyyy date recognized, its seventeen custom calculations and its other scripts reported; Cerfa 12156's eight date formats and 26 AFSimple_Calculate sums recognized, its template spawn reported; Cerfa 13983's two date formats; the I-9's AFSpecial_Format zip and telephone recognized and its custom validation and check-box scripts reported; OmniForm's three AFNumber_Format in dollars recognized and its fmt_* functions reported — per field and event, as a referee script over the /JS sources, in pikepdf, counts them | CorpusFormTests.Scripts_are_recognized_or_reported_never_run |
documents/form/reportlab-subscription-form.pdf | Filled — nom "Noailles", prenom "Élodie", societe "Étude Noailles & Associés", courriel, newsletter checked —, saved, reopened: the values read back are the values written, and qpdf, pikepdf, PyMuPDF and pdftk read the same; MuPDF and pdf.js render them within the threshold of each other; after flattening, pdftotext finds them in the page content, the page renders as the filled one did, and pikepdf finds no field and no widget | CorpusFormTests.The_form_document_round_trips_through_fill_save_and_flatten |
| The AcroForms, each field filled with a value of its type | Every value reads back, in our reader and in qpdf, pikepdf and PyMuPDF; every widget has an appearance; the documents that had no NeedAppearances still have none; pdftotext finds every text value | CorpusFormTests.Every_acroform_fills_and_reads_back |
| The same fills on the widgets that had no appearance — 32 of the IRS 9465's 34, 48 of the SS-4's 83, 15 of the N208's 29, 9 of Cerfa 13983's 20, 185 of Cerfa 12156's 419, 110 of OmniForm's 216, 3 of the InDesign form's 9 | Our appearances render within the threshold of pdf.js's own drawing of the same values, and qpdf's --generate-appearances output for text fields; no ZapfDingbats or other non-embedded font in any appearance we write | CorpusFormTests.Generated_appearances_draw_as_viewers_draw_them |
| The IHT205's pound amounts, Cerfa 12156's and 13983's dates, the I-9's zip and telephone, OmniForm's dollar amounts, each given typed values and text values; generated values at every boundary | The appearance shows exactly what pdf.js's aform.js computes for the same value and arguments; /V keeps the unformatted value; a value a keystroke function rejects is refused and named | AcrobatFormatRefereeTests.Formats_are_applied_as_pdfjs_applies_them |
| Cerfa 12156, its sources filled; the IHT205, its sources filled | The 26 sums recomputed in /CO order equal pdf.js's AFSimple_Calculate on the same values; the IHT205's seventeen calculated fields left as they were and each reported fill.calculation-stale | CorpusFormTests.Simple_sums_are_computed_and_other_calculations_reported |
| The 2022 Form 1040, and DD 293, AR-11 and Cerfa 14880 once decrypted where they are encrypted; the N208 (usage rights, no XFA) | Filled incrementally under KeepInStep: the datasets hold each filled value at its bound node, as lxml reads pikepdf's packet, and validation reports no form.xfa-out-of-step; the usage-rights signature still covers its revision (M04) and every later change is FormFill, within the grants. Filled by a full rewrite: /UR3 gone and usage-rights.removed reported. Under Remove: no /XFA, xfa.removed. An unresolvable binding removes the XFA and names the field. Silence fails the test | CorpusFormTests.Filling_a_hybrid_or_reader_extended_form_keeps_it_coherent_or_reports_the_loss |
| Remote: the two Canadian dynamic forms | A fill throws PdfFormException naming ADR 37; their datasets read; removing their XFA reports xfa.placeholder-left; for the certified IMM 1344, removing its usage rights is refused, since it would break the certification | CorpusFormTests.Dynamic_xfa_is_read_and_reported_never_filled |
A DocMDP P=2 certified form, and one with a FieldMDP lock — not in the corpus, M04's gap (below); vendor/us-federal/itext-govinfo-us-code-certified.pdf (P=1) | A fill of an unlocked field is written as an update, and pyHanko finds the certification intact at form-filling level; a locked field refused with PdfSignatureInvalidationException; a field created on the P=1 document refused likewise; each written and reported under AllowInvalidatingSignatures | PyHankoFormRefereeTests.Filling_a_certified_form_stays_within_its_permissions |
Cerfa 13983 (empty Signature1 and Signature2), DD 293 and Cerfa 12156 (one empty signature field each); remote, the OPM OF-306 (two) | Filled, then signed by pyHanko in a container in its own revision: the signature is intact, and the fill lies inside the signed revision | PyHankoFormRefereeTests.Filled_forms_sign_in_their_empty_fields |
| The filled AcroForms — not in the corpus, derived by pdftk from recorded values (below) | Flattened: the pages render as the filled ones within the threshold, and as qpdf --flatten-annotations=all --generate-appearances renders them; pdftotext finds every value; pikepdf finds no field; OmniForm's hidden widgets appear nowhere; each lost script reported | CorpusFormTests.Flattening_keeps_what_the_fields_showed |
vendor/pdf-association/indesign15-pdfua1-form.pdf, filled then flattened | veraPDF's PDF/UA-1 profile reports no failure the input did not have; each former field's Form element holds its content and a PrintField attribute | VeraPdfFormRefereeTests.Filling_and_flattening_a_pdfua_form_keeps_it_accessible |
| The filled AcroForms and their blank originals — the subscription form, the SS-4, the N208, Cerfa 13983, Cerfa 12156, the InDesign form, the I-9 | XFDF exported from each filled form imports into its blank form and reproduces every value, as qpdf's --json and pdftk's dump_data_fields_utf8 read them; pdftk's fill_form with our XFDF and our FDF gives the same values; our import of pdftk's generate_fdf output gives them too; JSON likewise | PdftkFormRefereeTests.Xfdf_exported_from_a_filled_form_refills_the_blank_one |
vendor/opf-format-corpus/reader10-openoffice32-annotated-object-streams.pdf (a note and a highlight by AnJackson), M11's annotated set | Their annotations exported to XFDF and FDF and imported into the unannotated document reproduce each annotation's entries as pikepdf lists them | CorpusFormDataTests.Annotations_travel_in_xfdf_and_fdf |
documents/contract/chromium-contract-fr.pdf (untagged), indesign13-pdfua1-german-book-chapter.pdf (tagged), libreoffice-report-pdfa2b.pdf (PDF/A-2b) | A field of every type and a signature placeholder created on each: qpdf, pikepdf and pdf.js list them with their names, types, values and options; veraPDF finds no new PDF/UA-1 or PDF/A-2b failure; pyHanko signs the placeholder, finds the signature intact and the lock applied | CorpusFieldCreationTests.Created_fields_are_listed_signable_and_keep_claims |
| The subscription form as a template, a thousand records | One file per record as an incremental update, a thousand files as full rewrites, one flattened volume: memory flat as records grow, throughput recorded in status.md; a sample of records read by pdftk with its values; each record's bytes the same alone and in parallel | CorpusBatchFillTests.A_thousand_records_hold_their_budget, FormBenchmarks |
Remote: remote/eu-dss/dss-signed-widget-self-parent-startxref-past-eof.pdf (a widget that is its own /Parent), remote/pdfbox/acrobat-form-nul-in-names-pdfbox6178.pdf (an export value /m#00nnlich) | The cycle reported by form.field-tree-cycle, no hang; the radio group filled with its NUL-bearing value and written back byte for byte, pdf.js selecting the same button | CorpusFormTests.Hostile_field_trees_and_names_end_in_a_report |
| Every write above | Two runs give identical bytes | CorpusSecurityTests.Encryption_is_deterministic_under_a_seed, CorpusFormTests.Form_operations_are_deterministic |
| The same operations through the tool | The AOT binary produces what the API produces | CorpusToolTests.Security_and_form_verbs_match_the_api |
The remote rows close only on a green Remote corpus run, recorded in status.md with its date.
Corpus
What the corpus holds
- Encryption, committed: eighteen documents — RC4-40 under R2 from Distiller 2 and Acrobat (1998, a 16-bit
/P), PDFWriter 3 and Quartz (iBooks Author); RC4-128 under R3 from Distiller 5 (PageMaker) and OpenOffice.org 3.2, with and without an open password and with copying forbidden; AES-128 under R4 from LiveCycle, InDesign and Acrobat 9, the last forbidding copying, accessibility or printing, and one whose open password nobody published; AES-256 under R6 from qpdf. Featuresencryption-*,encrypted,empty-user-password,open-password,permissions-*,open-password-unknown. Twins: the Chromium invoice for the AES-256 file, and same-content siblings for ten more — the PDFMaker 9 test document, OpenOffice's one-line document, the iBooks exports. - Encryption, remote: five more — Adobe Sign's certification under AES-128 with an
Identitycrypt filter andEncryptMetadata false(identity-crypt-filter,encrypt-metadata-false), two Canadian dynamic XFA forms, two JHOVE files; signatures inside encrypted files (signed-and-encrypted). - Forms: 25 committed documents with fields, 1,269 terminal fields — text (comb in five documents,
multiline, one rich-text field), check boxes, two radio groups, combo boxes in five, push buttons, signature
fields;
/DRfonts standard, non-standard and embedded; a calculation order in the IHT205 (17 entries) and Cerfa 12156 (26);AFNumber,AFDate,AFSpecialandAFSimple_Calculatescripts and custom ones; widgets without an appearance in fifteen documents; field tooltips, a merged field and widget, a radio group that cannot be toggled off in the PDF/UA-1 form. Featuresacroform,calculated-fields,calculation-order,format-keystroke-javascript,field-javascript,widgets-without-appearance-streams,hidden-widgets,field-tooltips,merged-field-widget,unsigned-signature-field. - XFA and usage rights: four committed XFA forms — one static (XFA foreground), three hybrid —, all four
Reader-extended, plus the HMCTS AcroForm with usage rights; remote, two dynamic forms (one certified,
one with legacy
/UR), a certified XFA form with usage rights, and a filled XFA form inside a portfolio. Featuresxfa,xfa-static,xfa-acroform-hybrid,xfa-dynamic,usage-rights-ur3,usage-rights-reapplied,ur-signature-without-byterange. - Signatures around forms: the GPO's P=1 certification; empty signature fields in Cerfa 12156, 13983, DD 293 and, remotely, the OF-306; approval-signed documents.
- Hostile shapes: a field tree with a cycle and a NUL in an export value, both remote.
- Scale: the 1000-page journal.
What it lacks
| Need | Why | Priority | Likely source |
|---|---|---|---|
| Decrypted twins of the six encrypted documents with no unencrypted sibling: IRS 9465, the PDFWriter 3 summary, the IHT205, the NTA return, AR-11, DD 293 | "Content matches the twin it was derived from" has nothing to compare with on these; M11 already needs the AR-11's | 1 | Derived here: qpdf --decrypt, a recorded transformation in build_corpus.py |
A document encrypted with the public-key handler — adbe.pkcs7.s4 and s5, RC4-128, AES-128 and AES-256 —, with its test certificate and key, and its twin | The roadmap's "a public-key-encrypted one is reported, never misread" has no document; M26's decryption needs the same | 1 | A public source: pyHanko's test data (MIT), to be screened for a public-key file; failing it, generated here with pyHanko's public-key handler over its fictitious test PKI; an Acrobat certificate-security file as a contribution |
| AES-GCM (ISO/TS 32003) and MAC (ISO/TS 32004) documents — standalone and attached to a signature —, their twins, and tampered copies | Reading them is a deliverable, and invariant 10 wants it proven on a file another implementation wrote | 1 | Generated here with an independent writer: pyHanko if its pinned version writes them, iText 9 in a container otherwise, as a producer only; tampered copies derived by a recorded byte flip |
| AES-256 under R6 with non-ASCII passwords — accented, German, CJK, and one that SASLprep maps —, and under R5 | SASLprep and the R5 path are untested without them | 1 for R6, 2 for R5 | Generated here: pikepdf with Unicode passwords, qpdf --force-R5; an Acrobat-made R6 file with an accented password as a contribution |
| Filled versions of the committed AcroForms, with fictitious values, filled by an independent tool; the same hybrid forms filled through their AcroForm only | The XFDF row needs "a filled corpus form"; flattening needs third-party fills; a pypdf fill that leaves the XFA stale is form.xfa-out-of-step's document | 1 | Derived here: pdftk-java's fill_form from a recorded XFDF, and pypdf for the stale hybrids, each recorded in build_corpus.py; a form filled in Acrobat as a contribution (W08, already wanted) |
A DocMDP P=2 certification followed by a fill, a FieldMDP lock over some fields, and the same before the fill | The permitted and forbidden fills of a certified form have no document — M04's gap too | 1 | Generated here: pyHanko's certify, fill and sign over its fictitious PKI, as M04 plans |
Attachments-only encryption, a /Crypt filter on one stream, and EncryptMetadata false committed | The committed corpus has none; the remote Adobe Sign file covers the last only on the nightly run | 2 | Generated here by a recorded pikepdf construction checked by qpdf; Acrobat's "encrypt only file attachments" as a contribution |
| An unencrypted wrapper document | Recognition must be proven on a file shaped as Microsoft Purview writes them, not only on ours | 2 | Generated here following ISO 32000-2 Table 28 by a recorded pikepdf construction; a Purview-protected file with fictitious content as a contribution |
| The 1000-page journal encrypted under AES-256 and RC4-128 | The memory and throughput budgets of decryption need a large encrypted document | 2 | Derived here with pikepdf, recorded |
List boxes with multi-select, push buttons with SubmitForm and ResetForm, a rich-text field with a value, password and file-select fields, AFPercent, AFTime, AFRange_Validate, AFSpecial_KeystrokeEx | None of these is committed | 2 | Generated here: LibreOffice's form export (list boxes, buttons, formatted fields) and a recorded ReportLab or pikepdf construction; Acrobat-made forms as a contribution (W08) |
| A Reader-extended form filled and saved by Adobe Reader, and an XFA form filled and saved by Acrobat, committed | What an incremental fill within usage rights looks like, and whether Acrobat re-merges datasets, are asserted structurally only; no container runs Reader | 2 | A contribution (W08) |
| FDF and XFDF files written by Acrobat and by pdftk | The exchange formats have no committed samples; they enter the manifest with M07's fdf and xfdf formats and their block — the fields and values an independent tool reads —, beside the form they belong to | 2 | Generated here with pdftk-java; Acrobat's as a contribution |
| A calculation in Acrobat's simplified field notation | Recognizing it is deferred until a real document carries one | 3 | A contribution (W08) |
| Right-to-left and CJK values in text and comb fields of a third-party form | The shaping report is checked on our own fills only | 3 | A contribution (W09) |
| A form of ten thousand fields | The fill and appearance budgets need a form larger than Cerfa 12156's 419 widgets | 3 | Generated here with ReportLab, recorded |
Traps
- An empty user password is the common case: eleven of seventeen. Try it first, silently, and report nothing for it — every viewer does the same, and a "clean" document stays clean.
- Not everything is encrypted:
/Encryptitself,/ID, cross-reference streams, objects inside object streams, a signature's/Contents, metadata whenEncryptMetadatais false, and whateverIdentitycovers. Decrypting one of them turns a sound file into noise. /Pis a 32-bit signed integer written by producers that did not all know it: the 1996 IRS form writes 65516. Read the low 32 bits; never compare the whole integer.- R2 has no bits 9 to 12: its accessibility and assembly follow its copy and modify bits, as qpdf reads it.
- Owner authentication is all or nothing. A document whose owner password is its user password gives every caller the owner's rights — and one written by us that way would make its permissions a pretense.
- Passwords are bytes, not strings: PDFDocEncoding up to R4, UTF-8 after SASLprep from R5; producers got it
wrong both ways. SASLprep needs NFKC, which
string.Normalizetakes from ICU — absent under invariant globalization and in browser WebAssembly: measure it there. - The primitives are not everywhere: .NET has no RC4, browser WebAssembly no MD5 and no AES, and
AesGcm.IsSupportedcan be false. The core's managed MD5, RC4 and AES cover the first three; GCM missing is a typed refusal, never a crash. - A CBC IV must be unpredictable, and output must be deterministic. Both hold only if the IV comes from a function keyed by the file key; a counter or a hash of public data breaks the first, the system generator the second.
- AES grows what it encrypts: 16 bytes of IV and up to 16 of padding. A stream's
/Lengthcounts them; a forward-only writer only knows it at the end — which is what the indirect/Lengthis for. - Empty strings: producers write them unencrypted, zero bytes long, under AES. Decrypt what can be, report the rest, show it empty — as viewers do.
- A GCM failure is not a decryption error: it says someone changed the bytes. Returning the plaintext anyway is the one thing that must never happen.
- Filling a hybrid form through its AcroForm leaves Acrobat showing the XFA's data: stale or empty. Keep the datasets in step or remove the XFA; never leave both disagreeing, and never in silence.
- Dynamic XFA pages are placeholders. A fill, a stamp or a merge of them is invisible to the only viewer that renders them; removing their XFA leaves "please wait" as the whole document.
- Usage rights die of any byte they do not expect. A full rewrite always breaks them; an incremental update keeps them only within their grants. And removing them from a certified document is itself a forbidden change.
- A format is the appearance's, not the value's.
/Vkeeps1234.5; writing£1,235there breaks every calculation that reads it. - Calculations follow
/CO, once, not their dependencies: a sum listed before its source is stale in Acrobat too. Emulate Acrobat, not what would be right. - Separator styles change parsing, not only printing: under style 2,
1.234is one thousand two hundred and thirty-four. - A recognized function must be the whole script.
AFNumber_Format(2, 0, 0, 0, "", true); foo();is a custom script that happens to begin with a standard call. - ZapfDingbats is not embedded in any committed form's
/DR, and every check box drawn in it is a PDF/A failure the next time the form is filled. Draw the symbols as paths. - A subset in
/DRcannot type new characters, and a viewer regenerating with it shows boxes. Draw with the subset what it has, fall back for the rest, and leave/DAto name what the author chose. /Tx BMC … EMCis not decoration: viewers that regenerate an appearance look for it to know what to replace.- A password field's value must never be stored, whatever the caller asks.
- Check-box on-states are not always
Yes: read them from/AP /N. Radio export values may be indices into/Opt, and names may carry#00. - A widget can be its own field, a field's
/Vcan be a stream, and/DAis inherited from the AcroForm dictionary when no field says otherwise. - FDF is PDF syntax and can carry JavaScript; XFDF and XFA are XML from strangers. Parse all three with every bound on, and execute nothing.
- Flattening a signed signature field forges a picture of a signature. Keep it interactive.
- No container runs Acrobat or Reader. What only they can say — that usage rights still hold, that datasets were re-merged — is asserted structurally and recorded as such, never claimed.
Documentation
docs/website/docs/guides/encryption.md— opening protected documents, credentials and encodings, the permission policy, encrypting and its options, entropy and determinism, decrypting, updates of encrypted documents, what the core cannot open and where the signing satellite takes over; the managed MD5, RC4 and AES the core uses where the platform has none, and that they are not constant-time.docs/website/docs/concepts/security-handlers.md— revisions, crypt filters, what is and is not encrypted, AES-GCM and the MAC, wrappers, the key-source seam.docs/website/docs/guides/forms.md— reading, filling, appearances and fonts, formats and sums without scripts, flattening, creating fields, signature placeholders, signed and certified forms.docs/website/docs/concepts/xfa-and-usage-rights.md— the three kinds of XFA and what each operation does to them; usage rights, their grants and when a save removes them.docs/website/docs/guides/form-data-exchange.md— FDF, XFDF and JSON, the JSON schema, batch filling.docs/website/docs/reference/acrobat-formats.md— every recognized function, its arguments, what we render, and each known difference from Acrobat and pdf.js.docs/website/docs/reference/security-and-form-codes.md— everyencryption.*diagnostic and everypermission.*,encrypt.*,fill.*,xfa.*,usage-rights.*,flatten.*,field.*andform-data.*code.docs/website/docs/reference/validation-rules.md— thesecurity.*andform.*rules added.docs/website/docs/reference/tool/—security,encrypt,decrypt,fields,fill,export,flatten --fields.docs/website/docs/introduction.mdanddocs/features/features.json— theencryptionandformsfeatures delivered.docs/architecture.md—Security/andForms/in the core, the key-source seam toAdCodicem.Pdf.Signing, the encryption stage of the writer.docs/corpus.md—expect.security,expect.form,expect.formFieldsas records; where exchange files live.- ADRs: randomness in encryption is the caller's input (slice 4); the primitives on platforms that lack them (slice 1); document permissions respected by every operation by default, overridden only by a reported declaration (slice 2); ADR 41 amended if the ISO/TS 32004 MAC cannot be read without a general CMS parser (slice 3).
Exit criteria
- Every revision of the standard handler opens, crypt filters included; AES-GCM and the MAC are read; the public-key handler is reported and its seam proven; wrappers are recognized.
- Permissions are decoded, respected by every operation of M06, M07, M09, M11, M15 and M16, and overridable only by a reported declaration.
- Encryption is written by every algorithm, deterministically, with its crypt-filter options, as a full rewrite and as an update under the input's key.
- Forms are read, filled with generated appearances, formatted and summed without a script, flattened, created with signature placeholders, and exchanged as FDF, XFDF and JSON; batch filling works.
- XFA is classified, kept in step or removed on fill; usage rights are kept within their grants or removed and reported; dynamic XFA is never filled.
- Every earlier acceptance test that left encrypted documents out runs on them.
- The version rows are in M03's table and tested against the Arlington model's
SinceVersion. - The
security.*andform.*rules are in the structural profile, documented, and every manifest entry declares what they report on it. - The priority-1 gaps above are filled; each remaining gap is recorded in
docs/corpus-contributions.md. - The acceptance conditions above pass on the corpus, in CI, with no document skipped, and the remote rows
on a green
Remote corpusrun recorded instatus.md. - Unit tests cover each behavior, its degenerate cases and its hostile ones; the security handler, the XFA, XFDF and FDF parsers and the script recognizer are fuzzed, seeded from the corpus, in a nightly campaign.
- Integration tests confirm decryption, encryption, permissions, fields, appearances, formats, XFA, fills after signatures, flattening and exchange through qpdf, pikepdf, poppler, MuPDF, pdf.js, PyMuPDF, pdftk-java, pyHanko and veraPDF, each in a container.
-
SecurityBenchmarksandFormBenchmarksmeasure decryption, encryption, filling and batch filling withMemoryDiagnoser;status.mdrecords the budgets. - The documentation site publishes the guides, concepts and references above.
- Every page of Documentation is written in its Diátaxis section, one mode per page (ADR 47).