A contract is signed, then gains one more update, and an audit script folds that update into the signed revision or passes the rewritten page off as validation material. Neither happens once a revision ends where startxref names a section the /Prev chain reached and a rewritten object keeps its earlier class — the two rules behind the PDFlibPas revision map
Why %%EOF is the wrong place to cut a file
Every incremental update is terminated by its own end-of-file marker (ISO 32000-1 §7.5.6), so splitting the file at each %%EOF looks like the obvious way to find revisions. The marker is just five bytes, though, and nothing stops those bytes from turning up inside a content stream, an embedded file or a font program. Linearised files add a second problem: Annex F (§F.3.4) lets the first-page trailer end with its own startxref and %%EOF close to the start of the file, well ahead of the main cross-reference table. Splitting at every marker invents revisions that no cross-reference section backs. Guessing which markers are fake is worse — that guess is precisely what a forger gets to shape. The PDFlibPas revision map works the other way round. It follows the /Prev chain, records the byte offset of every cross-reference section the chain actually read, and only then scans for startxref N … %%EOF sequences, keeping a boundary only when N is one of those recorded offsets. A stray marker inside a stream would need a startxref in front of it naming a real section before it could count for anything
The bytes between those three tokens are less tidy than §7.5.5 suggests. The standard draws startxref, the offset and the marker on consecutive lines, yet end-of-line markers and the other white-space characters of §7.2.2 (Table 1) may repeat, and no rule caps how many. Miss one padded marker and two revisions fuse. The signed one now appears to end after bytes nobody signed. The PDFlibPas revision map follows the sequence from keyword to operand to marker however much white space separates them and wherever that padding falls in the file, and it counts each keyword once. The trailer keyword has a similar loose joint: << is a delimiter (§7.2.2, Table 2) and §7.5.5 never demands a line break after trailer, so trailer<< on one line is conforming syntax. Read line by line, that line looks like a cross-reference subsection header — and the chain stops halfway
Walking the revision map with PDFlibPas
The revision accessors are declared on TPDFlib and implemented in the Small Business edition: create a TPDFlibSmallBusiness from the PDFlibSmallBusiness unit (Enterprise descends from it and carries the same calls). On a plain TPDFlib they return their failure value (0, 7 or -1) and set LastErrorCode to PDFLIB_ERROR_SMALLBUSINESS_REQUIRED. Revisions are numbered from 0, the original document. Each call takes a file name and a password and builds the map from the bytes on disk, so the answers describe the file as stored, never a document you happen to have loaded and edited. GetDocumentRevisionKind reports the most far-reaching change in a revision. An update that adds an OCSP response and also rewrites a page is reported as the page rewrite:
| Kind | Meaning | Typical source |
|---|---|---|
| 0 | Housekeeping only: objects that change nothing a reader sees | Cross-reference stream, a new object stream container |
| 1 | Validation material | Document security store, VRI, certificates, CRLs, OCSP responses |
| 2 | Document time stamp | A /DocTimeStamp signature and the field carrying it |
| 3 | Signature | An existing signature field receiving its value |
| 4 | Form field | A field value or its appearance |
| 5 | Annotation | Any annotation other than a widget |
| 6 | Document content | Anything that can change what the document shows |
| 7 | Could not be determined | Something in the revision could not be read; treat it as unsafe |
uses
System.SysUtils, PDFlibSmallBusiness;
procedure ListRevisions(const FileName, Password: WideString);
const
KindNames: array[0..7] of string = ('housekeeping', 'validation material',
'document time stamp', 'signature', 'form field', 'annotation',
'content', 'undetermined');
var
Pdf: TPDFlibSmallBusiness;
Count, I, J, Kind, Changes: Integer;
begin
Pdf := TPDFlibSmallBusiness.Create;
try
Count := Pdf.GetDocumentRevisionCount(FileName, Password);
if Count = 0 then
raise Exception.Create('the cross-reference chain could not be followed');
if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
Writeln('WARNING: history incomplete, every figure below is a lower bound');
for I := 0 to Count - 1 do
begin
Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
if (Kind < 0) or (Kind > 7) then
Kind := 7;
Writeln(Format('revision %d: bytes %d..%d, %s', [I,
Pdf.GetDocumentRevisionStart(FileName, Password, I),
Pdf.GetDocumentRevisionEnd(FileName, Password, I),
KindNames[Kind]]));
// one 'objnum,change,class,detail' line per object
Changes := Pdf.GetDocumentRevisionChanges(FileName, Password, I);
if Changes <> 0 then
try
for J := 1 to Pdf.GetStringListCount(Changes) do // items count from 1
Writeln(' ' + Pdf.GetStringListItem(Changes, J));
finally
Pdf.ReleaseStringList(Changes);
end;
end;
finally
Pdf.Free;
end;
end;
GetDocumentRevisionsComplete is the call to read before trusting any of the others. It returns 1 only when the chain was followed all the way back to the original section; a broken or cyclic /Prev, or a chain deeper than 256 links, leaves the oldest updates unseen, and every count and index then becomes a lower bound. A depth of 256 leaves room for the save-and-sign history of real files, and a chain that points back at itself still ends almost at once. Watch the indexing in the loop. GetStringListItem counts from 1 while revisions count from 0, and starting the inner loop at 0 drops the first changed object of every revision without any error
How a forged update passes itself off as validation material
PAdES long-term validation legitimately appends updates after the last signature: a document security store (/Type /DSS), per-signature /VRI dictionaries, certificate and revocation streams, document time stamps. Certification levels let them through, which makes validation material the most valuable label an update can wear. It is also the cheapest to fake. /Type is whatever the update chose to write. A viewer drawing a page never looks at the /Type of a content stream, a resource dictionary or a font, so an update can rewrite page 3's content stream, add /Type /DSS to its dictionary, and every renderer will still paint the new text while a classifier that believes /Type sees housekeeping. /VRI, /Sig and /DocTimeStamp work the same way
The PDFlibPas revision map ignores the label an update writes on an object it rewrites. A rewritten object counts as validation material, a time stamp or a signature only when the earlier version of that same object already was one. Certificate, CRL and OCSP response streams carry no type of their own, so they are recognised by the document security store that points at them — and only a store written in the revision being judged or an older one counts. A store added later cannot reach back and vouch for an object the signed revision wrote. Changes to the document's structure count as content whenever they change what a reader would resolve:
- replacing an existing object stream, because every compressed object inside it now resolves to the replacement (a brand-new container stays housekeeping, since each member gets its own entry and is judged there)
- adding a catalog or a page
- a trailer whose
/Rootnames a different catalog, even when the update redefines nothing the signature covered, since swapping the root replaces the whole document without touching a single signed object - a rewritten stream whose dictionary is byte-identical to the old one: an indirect
/Lengthor a replacement body padded to the same size keeps the dictionary unchanged while the drawing changes, so only objects without a stream body are treated as unchanged on that evidence
Object stream members have no header to strip
Since PDF 1.5 an update can store rewritten objects inside an object stream, and §7.5.7 is blunt about the format: only the object values are stored, and the obj and endobj keywords shall not be used. Code that reads an object at its location and removes the header by searching for the string obj therefore finds nothing at the front of a member and keeps searching into its body. Give a page dictionary rewritten after signing an innocent-looking string such as (obj<</Type/DSS>>) and that search stops inside the string. The decoder receives <</Type/DSS>> — a store dictionary the revision never wrote. The PDFlibPas revision map parses N G obj from the front of an uncompressed object, checks that the numbers name the object it expected, and treats a missing or mismatched header as unreadable instead of hunting further
Cross-reference streams carry a field-level ambiguity of their own. Under §7.5.8.3 (Table 18), only a type 1 entry holds a byte offset. The second field of a type 2 entry is the object number of the containing object stream and the third is the index within it, while a type 0 entry's second field is the next free object number. Read that object number as an offset and a compressed entry lands in whatever revision spans those small byte positions — nearly always the original document, which hides the rewrite inside the signed bytes. The revision map places every entry by the cross-reference section that declared it. The same clause says an entry type other than 0, 1 or 2 is a reference to the null object, so such entries neither add nor free anything and stay off the map
When should a signature audit refuse to answer?
Whenever the file withholds part of its own history. The revision map reports that in three ways: GetDocumentRevisionsComplete returns 0, GetDocumentRevisionKind returns 7 for some revision after the signature, or GetSignatureRevisionIndex returns -1. That last value covers a ByteRange that does not end on a revision boundary — bytes sit inside the range the signature claims to cover — and equally a field that does not exist, carries no signature value or sits in a file that could not be read. Send all three to manual review with the revision index and the GetDocumentRevisionChanges lines attached, so whoever opens the file starts from object numbers
// Checks the updates after one signature against a DocMDP-style policy.
// Kinds are ordered by severity, so each policy level is a ceiling:
// P 1 tolerates housekeeping, validation material and time stamps (0..2),
// P 2 adds signing and form filling (..4), P 3 adds annotations (..5).
function UpdatesWithinPolicy(Pdf: TPDFlibSmallBusiness;
const FileName, Password, FieldName: WideString; Level: Integer): Boolean;
const
Ceiling: array[1..3] of Integer = (2, 4, 5);
var
Signed, Count, I, Kind: Integer;
begin
Result := False;
if (Level < 1) or (Level > 3) then
Exit;
if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
Exit; // part of the history was never seen
Signed := Pdf.GetSignatureRevisionIndex(FileName, Password, FieldName);
if Signed < 0 then
Exit; // off a revision boundary, no such signed
// field, or the file could not be read
Count := Pdf.GetDocumentRevisionCount(FileName, Password);
for I := Signed + 1 to Count - 1 do
begin
Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
if (Kind = 7) or (Kind > Ceiling[Level]) then
Exit; // 7 is above every ceiling; stated for the reader
end;
Result := True;
end;
The ceilings follow ISO 32000-2 §12.8.2.2 (Table 257): P 1 permits no change, 2 permits form filling and signing, 3 adds annotations, and incremental updates that only add DSS data or document time stamps are not counted as changes at any level. ISO 32000-1 §12.8.2.2 defines the same three values without that exemption. Write the loop yourself when the policy is yours rather than the certifier's, for example an intake rule that rejects annotated contracts whatever P the file carries. When the policy should come from the file, GetSignatureModificationVerdict on the same class reads the certification level, applies the per-field locks described in detecting edits to FieldMDP-locked form fields, and returns 5 for the off-boundary case. Both answer a different question from classifying changes against a copy of the document you kept: the revision map needs nothing but the file in front of it
Appending follows the same chain. An incremental update's trailer must carry a /Prev entry giving the location of the previous cross-reference section (§7.5.6), and a document that could only be opened by rebuilding the cross-reference table from a full scan has no such location to give. AppendToFile returns 0 for it and leaves the target file untouched. Save it in full and sign the clean copy instead — a full rewrite does not carry existing signatures over
For an archive of signed contracts, run UpdatesWithinPolicy once per signature field before anything new is appended. A file whose later revision reads as kind 7 should reach a person first, ahead of any PDFlibPas signing or long-term validation step that would bury the unread revision under another update