Append-only incremental update — ISO 32000-2 §7.5.6.
Module pdfIncrementalWriter | Source packages/front/office/pdf/src/document/incrementalWriter.js | Deps pdfErrors, pdfSerializer, pdfTokenizer, pdfParser, pdfXref, pdfTrailer | Worker-safe yes
appendIncremental(pdfBytes, opts) produces a new PDF that carries the
original bytes verbatim, followed by an incremental-update section:
originalBytes ‖ newObjects ‖ xref ‖ trailer (with /Prev) ‖ %%EOF. It locates
the previous startxref/trailer to chain /Prev and carry over /Root,
/Info, /Size, /ID unless the caller overrides them, then emits a fresh
cross-reference section covering only the newly-added object numbers (one
subsection per contiguous run — object 0's free-list head is always
re-emitted), in the form the section-selection rule below picks. A reader that follows the /Prev chain (e.g. pdfDocument)
sees the new entries win over older ones automatically. This is the
non-destructive counterpart of a full re-emit: existing objects are never
patched in place, only appended to — the mechanism behind PAdES levels
T/LT/LTA (pdfSign) appending DSS/DocTimeStamp updates.
Scope: no ObjStm grouping; hybrid-reference bases are refused (below).
Section forms and the selection rule
The update's cross-reference section takes the form of the base's newest
section, the one the final startxref designates:
| Newest base section | Update written | Trailer keys come from |
|---|---|---|
Classical xref table |
Classical table + trailer dict, /Prev = the base's startxref offset. Byte-identical to the output of earlier versions, which wrote only this form. |
The newest trailer dict alone. |
/Type /XRef stream (§7.5.8) |
One uncompressed /Type /XRef stream object (no /Filter), carrying /W, /Index, /Size, /Prev, /Root and, when known, /Info and /ID. No trailer keyword. The stream takes object number max(size, prevSize, newMaxNum + 1), and the written /Size is one more. |
The newest-first merge of every section's dict in the /Prev chain, as pdfDocument types it. So a linearized file whose /Root sits in only one of its two xref streams still resolves /Root. |
Hybrid-reference file: a classical trailer carrying /XRefStm, anywhere in the chain |
Refused, pdf/incremental/hybrid-base. |
— |
| Neither a table nor an xref stream | Refused, pdf/incremental/unsupported-base. |
— |
Hybrid-reference bases are refused on purpose. A hybrid file carries two cross-reference forms, so two conforming readers can resolve different objects in it, and an update over it could read differently from viewer to viewer. The refusal is reversible: support can be added once a real hybrid file is available to choose and test a form against.
The emitted stream is read back by this library's own reader: an update over
a stream base re-reads with both the base objects and the appended ones
reachable (tests/real-shapes.integration.test.js).
Resolve
const iw = runtime.resolve('pdfIncrementalWriter');
// Returns: { appendIncremental, appendIncrementalWithOffsets, readBaseTrailer }
API
| Method | Signature | Returns |
|---|---|---|
appendIncremental |
(pdfBytes: Uint8Array, opts: AppendOpts) => Uint8Array |
The original bytes plus one incremental-update section. |
appendIncrementalWithOffsets |
(pdfBytes: Uint8Array, opts: AppendOpts) => { bytes, offsets, xrefOffset } |
Same as appendIncremental: same options, same validation and refusals, and bytes is byte-identical to its return value. offsets is a Map<num, byteOffset> giving where each update's num gen obj header starts in bytes. xrefOffset is where the update's cross-reference section (table or stream object) starts, which is the offset the new startxref records. |
readBaseTrailer |
(pdfBytes: Uint8Array) => { form, xrefOffset, trailer } |
The trailer an update over pdfBytes starts from. Nothing is written. form is 'table' or 'stream' (the newest section's form). trailer is the typed newest-first merge of every section's dict, as pdfDocument types it, for classical bases too. It is null when no section supplies a usable /Size and /Root. The base is vetted as appendIncremental vets it, so a hybrid-reference base throws pdf/incremental/hybrid-base and a startxref that designates neither form throws pdf/incremental/unsupported-base. |
appendIncrementalWithOffsets and readBaseTrailer were added for
pdfSign. The signer reads the merged /Size
to number its signature object. It then patches the fixed-width /ByteRange
placeholder inside that object, found from its offset, without changing the
byte length. Every offset recorded in the cross-reference section therefore
stays valid.
AppendOpts
{
updates: [ { num, gen?, value }, … ], // required — num >= 1
root?: { num, gen }, // defaults to the previous /Root
info?: { num, gen }, // defaults to the previous /Info
id?: [ Uint8Array, Uint8Array ], // defaults to the previous /ID
size?: number, // defaults to max(prevSize, newMaxNum + 1)
encrypt?: { num, gen } // the base's /Encrypt, repeated in the update; never defaulted
}
opts.root is mandatory only when no section of the base supplies a usable
/Root (and /Size) — on a classical base, when the newest trailer does not.
opts.encrypt is the base's /Encrypt reference (the { num, gen } that
readBaseTrailer(...).trailer.encrypt reports). It is written as
/Encrypt n g R right after /ID, in the trailer or in the cross-reference
stream dictionary. An update over an encrypted document needs it to
conform (ISO 32000-2 §7.5.6): every trailer of an encrypted file carries
/Encrypt. It is not taken from the base by default. Without it the output
is byte-identical to what it was before the option existed. A direct
/Encrypt dictionary (a typed { type: 'dict' }) is written as it is in a
classical trailer. Over a cross-reference stream base it is refused with
pdf/xref/bad-stream-section.
Examples
Append a new indirect object to an existing PDF
const iw = runtime.resolve('pdfIncrementalWriter');
const updated = iw.appendIncremental(originalBytes, {
updates: [
{ num: 42, gen: 0, value: { type: 'dict', entries: {
Type: { type: 'name', value: 'Example' }
} } }
]
});
// `updated` re-parses via pdfDocument.readDocument, following /Prev.
Update the Catalog to point at a new object (e.g. DSS)
const nextNum = 43;
const updated = iw.appendIncremental(signedBytes, {
updates: [
{ num: nextNum, gen: 0, value: dssDict },
{ num: 1, gen: 0, value: updatedCatalogDict } // re-defines obj 1
],
root: { num: 1, gen: 0 }
});
Errors
| Code | Class | When |
|---|---|---|
pdf/incremental/bad-input |
RenderError |
pdfBytes is not a Uint8Array (every member). |
pdf/incremental/no-updates |
RenderError |
opts.updates is not an array. |
pdf/incremental/bad-update |
RenderError |
An update entry lacks a finite num >= 1. |
pdf/incremental/no-startxref |
ParseError |
pdfBytes has no startxref — not a valid PDF. |
pdf/incremental/no-root |
RenderError |
opts.root missing and no /Root can be read from the base (see the table above). |
pdf/incremental/hybrid-base |
RenderError |
A classical section in the base's /Prev chain carries /XRefStm (hybrid-reference file). Nothing is written. |
pdf/incremental/unsupported-base |
ParseError |
startxref designates neither a classical xref table nor a /Type /XRef stream. Nothing is written. |
pdf/xref/bad-stream-section |
RenderError |
Cross-reference stream base, and opts.encrypt is not an indirect reference (propagated from pdfXref.buildXrefStream). |
See also
pdfWriter— full re-emit (the{ num, gen, value }update shape mirrorswriteDocument'sindirects).pdfSign— appends every signature (all levels), the LT/LTA DSS and the LTA DocTimeStamp through this module.pdfDssBuilder— produces the DSS updates typically appended here.