M03 — Writing and round-trip fidelity
State: to do — Depends on: M01 — Output version policy set by ADR 40
Goal
Write what was read — as a full rewrite or as an incremental update, in PDF 1.7 or PDF 2.0 — so that the result is the same document in semantic terms, the same bytes on every run, and, when it is an update, the original bytes left exactly where they were.
Every milestone that changes a document ends in this writer: repair (M05), assembly (M06), stamps (M09), the HTML engine (M12), signing (M26). What it guarantees here — offsets only it knows, memory that follows the heaviest object rather than the file, determinism, and neither a signature nor a conformance claim broken in silence — is what they inherit.
Scope
In:
PdfWriter, forward-only: object numbers reserved ahead, bodies written later, streams copied encoded or compressed on the fly behind an indirect/Length, classic cross-reference tables and cross-reference streams, object streams on write.PdfDocument.SaveandSaveAsync: a full rewrite and an incremental update, with a report of what the save kept, dropped, raised or refused.- The change set: edits recorded explicitly on the document, and objects read from a file made read-only, so that an edit cannot vanish with a cache eviction.
- A deterministic
/ID, and the caller's own identifier when supplied (invariant 6). - The output version policy of ADR 40: the caller chooses PDF 1.7 or 2.0 — or nothing, and a document
read from a file keeps its declared version, a new one is written as 1.7 —, the writer computes the minimum
version the document needs and raises the output to it, reported;
/Extensionsread, written and unioned; in 2.0 output the document information dictionary carried into XMP. - Signature preservation: an incremental update never touches a signed byte; a full rewrite of a signed document is refused unless the caller insists, and then every signature it breaks is reported.
- The cancellation and progress convention for every long operation, applied here to
Open,OpenAsync,Save,SaveAsyncandPdfValidator.Validate, and recorded in an ADR before the first stable release freezes the API. - The change set, the refusal of a signed full rewrite and the cancellation convention were decided by the maintainer on 2026-09-27; each is recorded in an ADR by the slice that implements it (3, 5 and 9).
- #35, the public API baseline, put in place first (slice 0), before the writer adds any public API.
- Debt fixed on the way, each before the slice that needs it: #52 (a source that returns short reads
would cut the original bytes an update copies) and #36 (PDFDocEncoding, UTF-8 text strings and
language escapes, which the 2.0 slice needs to carry
/Infotext into XMP correctly).
Out, explicitly:
- Saving an encrypted document, and writing encryption — M16. Until then a save is refused with
PdfEncryptedException: strings and streams cannot be written back without the file key. - Linearized output — M23. A rewrite of a linearized input is not linearized, and says so.
- Deduplication, recompression, pruning of unused resources — M23 (M07 prunes on split and extract).
- Pages, cross-document copy and merge — M06; content streams and fonts — M08.
- Classifying an update's changes against a signature's
DocMDPandFieldMDPpermissions — M04, which adds that guard to this writer. - Signing and the patch of a signature placeholder — M26. M03 keeps the appended revision patchable until it is finished (ADR 18), and nothing more.
- The public XMP model — M14. M03 reads and writes XMP internally, for the nine properties the document information dictionary has an equivalent for.
- Appending an update in place to the file the document was read from — later; M03 writes to a new output.
- Per-operation time and memory budgets for server hosts — M23's container profile.
- PDF/UA-2, Well-Tagged PDF and PDF/A-4 output — M28 (ADR 40). M03's 2.0 output is plain ISO 32000-2.
Design
Layers
PdfDocument.Save / SaveAsync public: mode, options, change set, progress, cancellation; returns a PdfSaveResult
PdfSaveOptions immutable record: Mode, Version, CrossReference, ObjectStreams, DocumentId,
AllowInvalidatingSignatures
PdfSaveResult bytes written, version written, mode, objects written, and the save's own
PdfDiagnostics (the reader's stay the reader's)
DocumentRewriter internal: the traversal of a full rewrite
UpdateWriter internal: the copy of the original bytes and the appended revision
PdfWriter internal: forward-only output, object numbers, offsets, sections, trailer, /ID
PdfObjectSerializer internal: one COS object to bytes, into an IBufferWriter<byte>, allocation-free
PdfVersionRequirements internal: feature → minimum version, one row per feature, with its source
Mode is PdfSaveMode.FullRewrite unless the caller asks for Incremental. Only PdfWriter knows a byte
offset; it stays internal until a generator needs it, and M06 or M08 decides its public shape. The save's diagnostics use the write.* family of PdfDiagnosticCodes: write.version-raised,
write.version-unknown, write.unreferenced-dropped, write.dangling-reference,
write.linearization-dropped, write.metadata-moved-to-xmp, write.metadata-conflict,
write.metadata-not-moved, write.metadata-synchronized, write.signature-invalidated and
write.conformance-lost — the last two at ConformanceLoss severity.
Serialization
- Integers as they are. Reals as the shortest decimal that reads back to the same
double, expanded (never an exponent — ISO 32000 has none) and always with a decimal point, so that a real read as4.0stays a real rather than becoming an integer. A non-finite real is refused, typed: a number past a double's range reads as null, reported assyntax.number-out-of-range, so only a caller can build one. The0.####roundingCLAUDE.mdasks for belongs where a value is computed (content streams, generated geometry), never in the writer, which must not change a value it read: a/Matrixof0.0123457rounded on output is a different matrix. - Names are bytes: the reader carries them as Latin-1 characters, one per byte, and the writer writes
those bytes back, with
#xxfor every byte outside!to~, for#and for the delimiters. A name built from text goes throughPdfName.FromText, which encodes it as UTF-8 first, as ISO 32000-2 recommends; a string holding a character above U+00FF, which no single byte carries, is refused, typed. A#00read from a file (invalid, reported by M02) is written back as#00, never dropped. - Strings: their bytes as read, in the notation read — hexadecimal if read so; literal otherwise, with
\(,\),\\and\rescaped. A raw carriage return inside a literal string reads back as a line feed (ISO 32000-2 7.3.4.2), so writing it raw changes the string. Text strings the library creates are PDFDocEncoding when every character fits, UTF-16BE with a byte order mark otherwise, in 1.7 and 2.0 alike; a UTF-8 string read from a 2.0 file keeps its bytes. - Dictionaries in their enumeration order — the parse order, for an object read — and arrays in order. Nesting is written without recursion beyond the parser's own depth bound; an API-built object nested deeper is refused, typed.
- Streams:
stream, a line feed, the data, a line feed,endstream./Lengthdirect when the length is known before the data (a copied stream, an object stream, a cross-reference stream), indirect when the data is produced on the fly; the length object is written right after the stream, never inside an object stream. - Header:
%PDF-and the version written (1.4for a 1.4 input left at its version,1.7,2.0), then a comment of four bytes above 127, the binary marker PDF/A requires. Tables: rows of exactly 20 bytes, each ending in CR LF. Cross-reference streams:/Wminimal for the largest offset, number and generation they hold.
Full rewrite
- The traversal starts at the trailer:
/Root, then/Info. Object numbers are assigned in the order objects are first discovered — depth first, in dictionary and array order — so the output is compact and does not depend on the input's numbering. A number is reserved when a reference to it is written; the body follows when its turn comes. What waits is a list of identifiers, never objects: a few bytes per object, bounded by the index the reader already holds. - Streams travel encoded (architecture §3.1): their raw bytes are copied from the source through one
pooled 1 MB buffer, never materialized through
GetBytes()— the 14.9 MB image of the USGS topographic map costs one buffer — and written under their true length, so a lying/Lengththe reader recovered is not propagated. A stream uncompressed in the input stays uncompressed; recompression is M23's. - An object nothing reachable refers to is not written, and the count is reported
(
write.unreferenced-dropped, information); the linearization dictionary and hint streams fall out that way, reported aswrite.linearization-dropped. A reference to an object the file lacks — null, by the specification — is written asnulland reported (write.dangling-reference). - Nothing in the document is changed that the caller did not change: no
/Producer, no/ModDate, no XMP instance identifier, unless the caller put one in the change set.
Cross-reference output
PdfSaveOptions.CrossReference is Auto (default), Table or Stream; ObjectStreams is Auto, Never
or Generate.
Autofollows the input's newest section: a classic table in, a classic table out, without object streams; a cross-reference stream or a hybrid file in, a stream out, with object streams. A new document gets a stream. A hybrid file is never written: it served readers from before PDF 1.5.- A document that claims PDF/A-1 (
pdfaid:part1 in its XMP) is always written with a classic table and no object streams, which ISO 19005-1 predates; asked explicitly for streams, the writer obeys and reportswrite.conformance-lost(invariant 7). - An object stream takes non-stream objects of generation 0, never the
/Lengthof a stream written on the fly, a signature dictionary (its/Contentsmust stay at a byte position a/ByteRangecan exclude), the cross-reference stream or an encryption dictionary. At most 100 objects and 64 KB decoded per stream: constants, so the grouping is deterministic.
Incremental update
- The original bytes are copied verbatim, a piece at a time, from the source; when the file does not end on a
line end, one line feed goes first — an update glued to the previous
%%EOFis otherwise unreadable. - Appended: the change set's objects — a changed object under its own number and generation, a new one
numbered from the highest number any section defines, plus one, a removed one as a free entry with its
generation raised, never reused — then a section of the kind the newest section is: a classic table
after a table or a hybrid file (without
/XRefStm), a stream after a stream. An update writes no object stream: its revision is small, and a signature dictionary in it must stay where a/ByteRangecan reach. /Prevnames the offset where the newest section really is: the reader's relocated offset when the file'sstartxrefwas a byte or a line off, as in node-signpdf's and EU DSS's files.- Offsets are written in the frame the original's resolve in: when bytes precede
%PDF(a MacBinary header, a browser'sdata:prefix), the reader's header offset applies to the new section as to the old ones. - The trailer repeats every entry of the previous one except
/Prevand/XRefStm, then/Prev,/ID, and/Sizecomputed as the highest object number any section defines, plus one: a trailer that claimed one too many (the GPO certification) is not propagated, and one that claimed too few hides nothing. - An empty change set writes nothing: the output is the input, byte for byte.
- The appended revision is built in a pooled buffer and flushed at the end; a region reserved in it can be patched until then. That is ADR 18's "space reserved", the hook M26 signs through; the original bytes stream straight through.
The change set, and objects read from a file
The reader's cache evicts (FIFO, #37): a dictionary edited in place and then evicted is read again from the file, and the edit is gone without a word. Hence:
- Objects read from a file are read-only: mutating one throws
InvalidOperationExceptionnamingPdfDocument.SetObject.Clone()on a dictionary or an array gives a writable copy of its direct content, references kept as references. PdfDocument.SetObject(id, value),AddObject(value)returning its reference,RemoveObject(id), and the trailer's/Infothrough the same calls, form the change set. It pins what it holds and nothing else — memory follows the edits, not the document — andGetObjectreturns the change set's version when there is one.- A full rewrite applies the change set; an incremental update writes exactly it.
- It changes what previews shipped (ADR 30 permits it), so slice 5 records it in an ADR of its own.
The identifier
/ID [first second]. The first element is the input's first element when it has one — the permanent
identifier, the same in both trailers of DILA's signed notice, absent from five corpus documents — and the
second element otherwise. The second is the first 16 bytes of a SHA-256 over the bytes written before the
trailer (the cross-reference stream, when it carries the trailer); for an update, over the previous second
element followed by the appended bytes. SHA-256 rather than the customary MD5, which browser WebAssembly
does not provide and M23 runs the core there. PdfSaveOptions.DocumentId supplies both elements instead.
M16's encrypted output will need the first element before the first encrypted string, so it will take it
from the input or the caller, never from this hash.
Output version (ADR 40)
PdfSaveOptions.Version is PdfOutputVersion.Pdf17, Pdf20, or null — the input's own version, raised
only to the minimum its features need, for a full rewrite and an update alike; 1.7 for a new document.
| What sets a minimum | Minimum | Source |
|---|---|---|
The input's declared version: the later of its header and its catalog /Version | as declared | ISO 32000-2 7.5.2, 7.7.2 |
An /Extensions entry | its /BaseVersion | 7.12 |
| A cross-reference stream or an object stream the writer produces | 1.5 | 7.5.7, 7.5.8 |
An ISO_ developer extension (ISO/TS 32001 to 32004) | 2.0 | 7.12 |
| A UTF-8 text string | 2.0 | 7.9.2.2 |
Later milestones add their rows — AES in M16, optional content in M11 — since ADR 40 asks each feature to
state the version it needs. Associated files add none: ISO 19005-3 brought /AF and AFRelationship into
PDF 1.7-based files, so they raise nothing in 1.7 output (M06's attachments, M14's PDF/A-3 row).
- The written version is the larger of the caller's choice and the minimum; when the minimum wins,
write.version-raised(information) names the feature and the object that raised it. - A declared version that does not exist — a PDFMaker 11 header says 1.8 — is read as 1.7, one above 2.0 as
2.0, each reported
write.version-unknown(warning). A document is never written below its declared version: lowering one needs an analysis of every feature, which nothing here has. - A full rewrite writes the version in the header and leaves a catalog
/Versionas it was, since a reader ignores it unless it is later than the header. An update cannot change the header: it raises the version, when it must, through the catalog's/Version— which puts the catalog in the change set, reported, and is a change M04 classifies. /Extensionsis read into a model — a prefix to its base version, extension level, revision and URL, and theISO_array form of ISO 32000-2 — and written as read.Unionkeeps, per prefix, the highest level (for the array form, each level once), ordered by prefix: M06's merge calls it; M03 tests it.
PDF 2.0 output
- ISO 32000-2 deprecates every entry of the document information dictionary except
CreationDateandModDate. The others with an XMP equivalent —Titletodc:title,Authortodc:creator,Subjecttodc:description,Keywordstopdf:Keywords,Creatortoxmp:CreatorTool,Producertopdf:Producer,Trappedtopdf:Trapped, with the two dates kept in step asxmp:CreateDateandxmp:ModifyDate— are carried into the XMP metadata stream and dropped from/Info, reported (write.metadata-moved-to-xmp). XMP is authoritative (ADR 40): where it already holds a different value it wins, and the difference is reported (write.metadata-conflict, warning). Custom keys stay in/Infountil M14's XMP model can give them a schema. - XMP is read with
XmlReader, DTDs prohibited, no resolver, its size bounded by the stream's own limit — it comes from the file, hence hostile — and written by a minimal internal writer: thexpacketwrapper, a fixed property order, 2 KB of padding, no identifier or date the input or the caller did not supply. A document without XMP gets one. A packet that does not parse keeps its/Infoentries, reported (write.metadata-not-moved, warning): metadata is never dropped for want of a place to put it. - In 1.7 output, when the change set touches
/Infoor the metadata stream, the other is brought in step for the same nine properties (ADR 40), reported (write.metadata-synchronized). A round trip touches neither, and an input whose two forms disagree keeps its disagreement: M02's metadata rules report it, and the writer does not repair what nobody asked it to. - A document claiming PDF/A-1, 2 or 3, whose header those parts require to read
%PDF-1.n, loses its claim in 2.0 output: the writer obeys the caller and reportswrite.conformance-lost. That is why ADR 40 keeps the input's own version when none is chosen, and why the veraPDF acceptance runs on default output. - Constructs PDF 2.0 deprecates inside content — a non-embedded standard 14 font, a PostScript XObject — are kept, since a rewrite changes no content; M02's version-aware object-shape rules (ADR 44) report them when the caller validates the output.
Signatures on write
- Detection, minimal and internal, which M04 extends into the public model: signature fields (
/FT /Sig, inherited through the field tree, with a/V), found by an iterative walk of/AcroForm /Fieldswith a visited set — the corpus holds a signature widget that is its own/Parent—, and/Perms(/DocMDP,/UR3,/UR). - An incremental update leaves signed bytes untouched by construction.
- A full rewrite of a signed document is refused with
PdfSignatureInvalidationException, aPdfExceptionwhose message namesPdfSaveMode.IncrementalandAllowInvalidatingSignatures. With that option the rewrite proceeds and each signature it breaks — usage rights included — is reported aswrite.signature-invalidated. The caller asked for the impossible under the default, which the architecture's table answers with a typed exception; what the caller insists on is reported, never silent. Slice 3 records the refusal and its option in an ADR. - An unsigned placeholder — a
/ByteRangeof names and a/Contentsof zeros, as in node-signpdf's PDFKit file — signs nothing, and is rewritten without comment.
Cancellation and progress
The convention every long operation follows from here on:
- An operation whose work grows with the document takes
CancellationToken cancellationToken = defaultlast, and, when it can say how far it is,IProgress<PdfProgress>? progress = nulljust before. Neither goes into an options record: a token belongs to a call, options to a configuration. PdfProgressis areadonly record struct— a stage, units completed, units in total (−1 when unknown). The stage is an enumeration later milestones extend:Indexing,Rebuilding,Validating,Writing,Copyinghere; M04'sComparing, M06'sMerging, M12'sLayingOutafter.- The token is checked once per unit of work — an object, a page, a 1 MB piece of a stream or of a scan —
with
ThrowIfCancellationRequested: no registration, no closure, no allocation. Progress is reported at start, at end, and at most once per percent or per 1,000 units, so a reporter costs nothing in the hot loop;Progress<T>'s posting to a synchronization context is the caller's choice and cost. - Canceled, an operation throws
OperationCanceledException, and the document stays usable, its change set intact: the next save produces the bytes an uninterrupted one would have. A save to a path writes a temporary file beside the target and moves it into place, deleting it on cancellation or failure; saving over the file the document reads from is refused. A save to a stream leaves what it wrote for the caller to discard. - Asynchronous at the I/O edges only (architecture §5).
SaveAsyncflushes withWriteAsyncbetween objects and between the pieces of a copied stream, while serializing one object stays synchronous — so it never callsStream.Write, which ASP.NET Core refuses by default.OpenAsync(Stream)buffers a non-seekable stream asynchronously for the same reason, then reads from memory. - Recorded in an ADR by slice 9, and applied to
Open(the cross-reference chain, the rebuild's scan),OpenAsync,Save,SaveAsyncandPdfValidator.Validate. Every later milestone applies it to what it adds.
Memory
A full rewrite holds the reader's index and cache, the list of identifiers waiting to be written, the object being serialized, one object-stream group of at most 64 KB, one 1 MB copy buffer and the output buffer. Nothing grows with the pages written. An update holds the change set and the appended revision. Flatness is measured through the progress checkpoints — the retained heap at 10, 50 and 100 % — which is one reason progress is designed before the writer is finished.
Slices
Each ends on a green commit; #35 is slice 0, #52 lands before slice 6, #36 before slice 8.
- The public API baseline (#35). A checked-in baseline of the exported signatures of every package the solution then holds, one file per package, generated from the built assemblies and compared by a test that fails the build on any change not made to the baseline in the same commit. Every later milestone extends it with what it makes public, M12.1 with the packages M12 brings; #42, the package-validation baseline per package, stays M10's. Proved by the test failing on a deliberately added public member and on a removed one, and passing once the baseline is updated in the same commit. Leaves serialization.
- Serialization.
PdfObjectSerializerfor every COS type, allocation-free into anIBufferWriter<byte>. Proved by an FsCheck property — every generated object, names over all 256 byte values, strings with every byte including CR, reals from 1e-10 to 1e30 and −0 — parses back to itself through the reader's parser, which reads a real as the double nearest to its decimal (#186); a table of hostile values (non-finite reals, nesting past the bound) refused, typed;WriterBenchmarks.Serialize_objectsat 0 B allocated per object. Leaves the file structure. - The writer.
PdfWriter: header and binary marker, object numbers reserved ahead, streams with direct and indirect/Length, Flate compression on the fly, a classic table, trailer,startxref,%%EOF, over a pooled buffer. Proved by unit tests that write small documents and reopen them with no diagnostic, every table entry landing on itsN G obj; a 100 MB stream produced on the fly written with memory under 2 MB beyond the buffers; a number reserved and never written failingFinish, typed; integration:qpdf --checkpasses the synthetic outputs. Leaves existing documents. - Full rewrite.
SavewithPdfSaveMode.FullRewrite: the traversal, renumbering, streams copied in pieces, unreachable objects dropped and dangling references nulled — both reported —, the/ID, the refusal of encrypted and signed documents with theAllowInvalidatingSignaturespath and its ADR; classic tables only. Proved byCorpusRoundTripTestsover every committed document neither encrypted nor unsupported, compared by a graph comparer inTestSupportthat walks both documents from the trailer (streams by encoded bytes,/Lengthignored); determinism — two saves byte-identical, a save of the reopened output byte-identical (a fixed point), a 64-object reader cache giving the bytes the default one gives; integration: qpdf and poppler. Leaves streams. - Cross-reference streams and object streams.
CrossReferenceandObjectStreams,Autofollowing the input, PDF/A-1 kept on a table, the grouping constants. Proved by the round trip in both formats; integration: pikepdf, in a Python container, walks original and output from their trailers and finds the graphs isomorphic; veraPDF, in its container, upholds after the rewrite every PDF/A claim it upheld before. Leaves updates. - The change set. Read-only objects,
Clone,SetObject,AddObject,RemoveObject, applied by the full rewrite; its ADR. Proved by an edit that survives the eviction of its object (a 64-object cache, 10,000 objects read in between); an attempt to mutate a read object failing with the message that namesSetObject; the change set's memory proportional to the edits (a 1 MB document edited once against the 1000-page journal edited once); integration: pikepdf reads the edit in the rewritten file. Leaves updates. - Incremental update (#52 fixed first).
PdfSaveMode.Incremental: the copy, the appended revision in the newest section's kind, the trailer rules, the header-offset frame, the empty change set, the patchable region. Proved byCorpusIncrementalTestsover every committed document neither encrypted nor unsupported: the input is a byte-identical prefix of the output, an empty change set returns the input byte for byte, the edited output reopens without repair; integration:qpdf --checkreports nothing it did not report on the input; on the signed documents, pyHanko reports each signature's coverage as its whole revision, and poppler'spdfsigcalls valid every signature it called valid before. Leaves the version. - Output version and extensions.
Versionand its null default (the input's own, 1.7 for a new document),PdfVersionRequirements, the raise and its report, unknown versions, the catalog/Versionof an update, the/Extensionsmodel andUnion. Proved by a unit test per row of the table and per union case (two prefixes, one prefix at two levels, theISO_array); the corpus documents that declare extensions or versions keep them; integration: pikepdf'spdf_versionandextension_levelread what was written. Leaves 2.0 metadata. - PDF 2.0 output (#36 fixed first). The header,
/Infocarried into XMP, the conflicts, the malformed packet, 1.7 synchronization when the change set touches metadata. Proved by the round trip of every committed document in 2.0 output; a unit test per property and per conflict; a billion-laughs packet and a packet with an external entity read as malformed, not expanded; integration: pikepdf's XMP reader (open_metadata) finds every value/Infoheld,qpdf --checkpasses, and veraPDF still upholds the PDF/A-4 fixture rewritten from 2.0 to 2.0. Leaves the long-operation API. - Cancellation and progress.
PdfProgress, the parameters onOpen,OpenAsync,Save,SaveAsyncandValidate, the temporary file, the asynchronous edges; the convention's ADR. Proved by a property: canceled at any check, every operation throwsOperationCanceledException, leaves the document usable, and a following save equals an uninterrupted one; aSaveAsyncinto a stream whose synchronousWritethrows succeeds;WriterBenchmarkswith and without a live token and a reporter allocate the same. Leaves the budgets. - Budgets, remote corpus, documentation.
WriterBenchmarks.Rewrite_the_1000_page_journalandUpdate_the_1000_page_journalwithMemoryDiagnoser; the memory checkpoints; a throughput guard in CI; the round trip and the update over the remote corpus; the site. Proved byCorpusRoundTripTests.Rewriting_the_largest_document_holds_memory_flatand a greenRemote corpusrun, recorded indocs/status.mdwith its date. Leaves M04 to classify what an update changes.
Tests required
Unit
- Serialization: the round-trip property above; every escape of names and strings; reals that must stay reals; the refusal of what cannot be written.
- The writer: offsets exact; direct and indirect lengths; a table row of exactly 20 bytes;
/Wminimal; object-stream grouping at its constants; a reserved number never written refused atFinish; the region reserved in an update patchable until the flush and not after. - Full rewrite: renumbering in discovery order; a reference chain 100,000 objects long written without
recursion; unreachable objects and dangling references reported with their counts; a lying
/Lengthwritten as the true one; the output independent of the reader's cache capacity. - Incremental update: the prefix property; an update after a classic table, a stream and a hybrid file; an
update after a linearized file; bytes before the header; a file ending without a line end;
/Sizefrom the sections, not the trailer; a free entry's generation; the empty change set. - The change set: eviction, read-only objects,
Clonedepth, removal of an object still referenced (written as a free entry; the dangling reference reported on the next rewrite). - Versions: each requirement; the raise; 1.8 and 3.0 headers; an update's catalog
/Version; the union. - 2.0 metadata: each of the nine properties; the conflict; the malformed and the hostile packet; custom keys kept; no identifier invented.
- Signatures on write: detection through inherited
/FT, through/Perms, in a field tree with a cycle; the refusal and the insisted path; the unsigned placeholder rewritten. - Cancellation and progress: the property above; reports rate-limited; no report and no check allocate; the temporary file removed on cancellation; saving over the source refused.
- Hostile: an object graph that is one 100,000-deep chain, a dictionary of a million entries, a name of
every byte, a stream whose recovered length differs from its
/Length— each written or refused, typed, never a crash.
Integration — each tool in a container (ADR 27), on every output the acceptance conditions name:
- qpdf (
--check,--show-npages): the structural verdict, in 1.7 and in 2.0 output. - poppler (
pdfinfo,pdftotext): page count and extracted text equal before and after — a second engine, since pikepdf shares qpdf's parser. - pikepdf: the graph isomorphism between input and output, the version and extension level written, the XMP values after a 2.0 rewrite, the edit made through the change set.
- pyHanko: each existing signature's coverage after an update —
EmbeddedPdfSignatureand itsevaluate_signature_coverage(), no trust needed. - poppler's
pdfsig: each signature it called valid before an update, it calls valid after; its signed ranges unchanged. - veraPDF: every PDF/A claim it upheld before a rewrite or an update, it upholds after.
#39 first planned pdftotext for M15 and veraPDF for M20; invariant 7 needs them here, with
pikepdf, pyHanko and pdfsig, as #39 now says.
Acceptance conditions
| Documents | Behavior | Verified by |
|---|---|---|
Every committed document neither encrypted nor recorded as unsupported (148 on 2026-09-26), from every producer, damaged/* included | A full rewrite with default options reopens with no repair and no cross-reference or syntax diagnostic, the same page count, and the same object graph compared from the trailer — the graph the reader recovered, for a damaged input, which therefore comes back sound. A stream that failed to decode in the input fails alike in the output: it travels encoded | CorpusRoundTripTests.Every_document_survives_a_full_rewrite_with_its_object_graph_intact |
| The same documents, in 1.7 and in 2.0 output | qpdf --check reports no structural warning, and about content only what it reported on the input; poppler counts the same pages and extracts the same text; pikepdf finds the two graphs isomorphic | QpdfRoundTripTests.Every_rewritten_document_passes_qpdf_check, PopplerRoundTripTests.Rewriting_changes_no_page_and_no_text, PikepdfRoundTripTests.The_rewritten_graph_is_the_original_graph |
| The same documents, with tables and with streams | Two saves give identical bytes; saving the reopened output gives identical bytes again; a reader cache of 64 objects gives the bytes the default gives | CorpusRoundTripTests.Rewriting_is_deterministic_and_a_fixed_point |
The 18 encrypted committed documents — secured/qpdf-invoice-aes256.pdf, the RC4 and AES variants of the OPF format-corpus, the LiveCycle XFA forms under AES-128, the Distiller, PageMaker, PDFWriter and InDesign files under RC4 or AES | Every save refused with PdfEncryptedException naming M16; nothing written | CorpusRoundTripTests.Encrypted_documents_are_refused_until_m16 |
The signed committed documents: itext-govinfo-us-code-certified (a DocMDP P=1 certification with a FieldMDP lock, closing the second revision), fop-dictao-dila-signed-joafe-notice, pyhanko/acrobat-reader-signed-twice, node-signpdf/skia-chrome74-node-signpdf-reason-contains-trailer (startxref a line early), antenna-house-legilux-memorial-pades-lta, fop22-legilux-memorial-seal-renewed-timestamps, docusign-pdfkit-gsa-sf30-contract-modification (a seal in the first revision whose byte range ends at %%EOF without its line end) | An incremental update — an /Info edit — keeps the input as a byte-identical prefix; each /ByteRange covers the bytes it covered; pyHanko reports each signature as covering its entire revision; pdfsig calls valid every signature it called valid before, with the same signed ranges | CorpusIncrementalTests.Existing_signatures_still_cover_their_byte_range, PyHankoRefereeTests.Signatures_cover_their_revision_after_an_update, PdfsigRefereeTests.Signatures_valid_before_an_update_are_valid_after |
The same signed documents, and the usage rights of livecycle-irs-1040-2022-xfa-ur3, livecycle-es9-cerfa-14880-xfa-form and indesign-acrobat-hmcts-n208-form | A full rewrite is refused, typed; with AllowInvalidatingSignatures it is written and each signature, usage rights included, is reported as write.signature-invalidated. pdfkit-node-signpdf-unsigned-placeholder is rewritten without a report: it signs nothing | CorpusRoundTripTests.A_full_rewrite_never_breaks_a_signature_in_silence |
| Every committed document neither encrypted nor unsupported | An incremental update with an empty change set returns the input byte for byte; with an edit, the input is a prefix of the output, which reopens without repair, and pikepdf reads the edit | CorpusIncrementalTests.An_update_leaves_the_original_bytes_untouched |
The update shapes: hybrid Word files (word-invoice-fr with its empty update section, word2019-ccs-contract-schedule), InDesign's six updates over a hybrid index (indesign-irs-pub1-russian), linearized then updated (pdfmaker5-distiller5-va-select-agents, pdfmaker25-home-office-eia, illustrator-irs-pub1-english), a startxref before its keyword (node-signpdf), bytes before the header (damaged/invoice-junk-prefix), two unlinked sections (handwritten-dual-startxref), indirect lengths (quartz-word-mac2011-lorem-ipsum) | The appended section has the newest section's kind, its /Prev lands on it, its offsets resolve in the file's frame; qpdf and poppler count the pages they counted before | CorpusIncrementalTests.An_update_follows_the_shape_of_the_file |
Every committed document veraPDF upholds (conformanceValid: true): PDF/A-1a and 1b from OpenOffice.org 3.2, PDFMaker 9, Acrobat 11 and Antenna House; 2a and 2b from PDFlib, 3-Heights and BFO; 3b and 3u Factur-X and ZUGFeRD invoices; verapdf/pdfa4-metadata-pass | After a full rewrite with default options, and after an update that edits /Info, veraPDF upholds the same claim — the edit reaches the XMP too; the PDF/A-1 documents come out with a classic table and no object streams | VeraPdfRoundTripTests.Conformance_upheld_before_writing_is_upheld_after |
Documents that declare extensions or unusual versions: InDesign's PDF/UA chapter (indesign13-pdfua1-german-book-chapter, Adobe level 3 over a 1.4 header), livecycle-irs-1040-2022-xfa-ur3 (Adobe level 8), pdfmaker11-word-pdf17-header-says-18, the two PDF 2.0 fixtures verapdf/pdfa4-metadata-pass and verapdf/pdf20-version-mismatch; remote, Adobe level 5 (the NeoOffice seals, the Adobe Sign agreement) and ETSI's level 5 over a 1.4 header (the Maine DocuSign amendment) | Output version the larger of the choice and the minimum, each raise reported with its cause; /Extensions written as read; the 1.8 header written as 1.7 and reported; pikepdf reads the version and extension level written | CorpusVersionTests.No_document_is_written_below_the_version_it_needs |
| Every committed document neither encrypted nor unsupported, in 2.0 output | Header 2.0; /Info down to its two dates; every other value found by pikepdf's XMP reader; a conflict reported rather than resolved in silence; each PDF/A-1, 2 or 3 claim reported lost | CorpusVersionTests.Pdf20_output_carries_the_metadata_in_xmp |
stress/reportlab-journal-1000-pages | A full rewrite holds the retained heap flat — within 1 MB between the 10 % and the 100 % checkpoints, under a ceiling set from the first measurement and recorded in docs/status.md — and within a throughput guard set at three times the measured time | CorpusRoundTripTests.Rewriting_the_largest_document_holds_memory_flat |
Remote: the 9,302-page United States Code (signed), EU DSS's 48 updates with 25 signatures, the Word 2019 DIIA files with updates glued to %%EOF, the USGS topographic map and its 14.9 MB image stream, the 147 MB JPEG 2000 scan, the PDF 2.0 UTF-8 test file, WeasyPrint's PDF/A-4f Factur-X | Every row above that applies, in the nightly Remote corpus job; the map's image and the scan's plates copied with one buffer's memory; the milestone closes only on a green run recorded in docs/status.md | The same tests, over the remote entries |
Corpus
What the corpus holds
- Every shape of index the writer must read and write back:
classic-xref-tableandxref-table,xref-stream,object-streams,hybrid-xref(six committed),linearized(55 committed),incremental-updateandincremental-updates(47 committed, six updates over a hybrid index in InDesign's Russian publication),indirect-stream-length,size-off-by-one,no-trailer-idandsame-id-in-both-trailers,startxref-points-before-xref-keyword,empty-update-section,two-unlinked-xref-sections. - Every header version from 1.1 to 2.0 among the committed files — 66 in 1.4, 42 in 1.7, 20 in 1.3, 18 in
1.6, 11 in 1.5, seven in 1.2, two in 2.0, one in 1.1 and one that says 1.8 — and developer extensions:
Adobe's levels 3 and 8 committed, level 5 and ETSI's (
esic-extension-level-5-on-1.4-header) remote. - Signatures to preserve: the seven committed signed documents named in the acceptance conditions, three forms with usage rights, the unsigned PDFKit placeholder, and some 25 signed or sealed remote documents, from a single full save covering the whole file (Foxit) to 48 updates (EU DSS).
- Conformance to preserve: the PDF/A-1 to PDF/A-4 claims veraPDF upholds, and the ones it rejects, which must come out no better and no worse.
- Damage the rewrite must turn into sound output:
damaged/*(five derived from our invoice) and the vendored files with real damage. - Scale: the 1000-page journal committed; the 9,302-page United States Code, the USGS map's 14.9 MB image stream, the 147 MB JPEG 2000 scan and the 35,000-pixel CCITT image remote.
- Encrypted documents (18 committed), which M03 must refuse to write.
What it lacks
| Need | Why | Priority | Likely source |
|---|---|---|---|
A signed twin of our own contract, documents/contract/chromium-contract-fr.pdf: a PAdES B-B approval signature and a DocMDP P=2 certification, from a fictitious test PKI | The roadmap's acceptance says the corpus's contract is unsigned and lets its signed twin join with M04; the seven vendored signed files carry the behavior, but none is a modern signature over a document we can regenerate | 2 | Generated here: pyHanko, pinned in requirements.txt, over the Chromium contract, a derived variant recorded in build_corpus.py |
A committed PDF 2.0 document with UTF-8 text strings in /Info, outline titles and page labels, and an ISO_ extension entry | The only UTF-8 file is the PDF Association's, remote under CC BY-SA; the committed 2.0 fixtures carry little metadata, so the 2.0 path and #36 are tested on committed files only by unit tests | 2 | Generated here: pikepdf writing raw UTF-8 strings and the extension dictionary over our invoice |
A committed update glued to the %%EOF marker, and a committed signed hybrid file | Both exist only among the remote Word 2019 DIIA files, so the main CI job never sees them | 3 | Generated here: an update appended without a line end to a derived document; the remote files meanwhile |
| A committed document with one encoded stream larger than the 1 MB copy buffer | The piecewise copy of stream data meets a real stream past its buffer only in the remote corpus (the USGS map, the JPEG 2000 scan); the committed files stay below it | 3 | Generated here: ReportLab with a noisy, incompressible image of about 1.2 MB |
Traps
- Offsets have a frame. Bytes before
%PDFshift every offset, and qpdf adjusts without a word. An update that writes its offsets from the file's first byte, over a file whose old ones resolve from the header, produces a file no reader can follow. - A carriage return inside a literal string is not a carriage return once read back;
\ris. - A name is bytes, not text. The reader's Latin-1 string is only a carrier; a caller who writes
éinto a name means UTF-8's two bytes, not Latin-1's one, and onlyFromTextknows it. - A real is not an integer. Writing
4.0as4changes the object's type; writing0.0123457as0.0123changes its value. Round where a value is computed, never where it is written. - The update's trailer repeats the previous entries except
/Prevand/XRefStm; a copied/XRefStmpoints a hybrid-aware reader at a stream that describes the previous revision. /Sizeis computed, not copied: from the sections, so neither a trailer's error nor a missing section propagates.- A
/Lengthobject inside an object stream makes a stream's length depend on another stream's decoding: legal, and the chain T29 had to bound. Never write one. - Compression is deterministic per runtime, not across runtimes. .NET's zlib may change with a servicing release, and with it the bytes of every stream the writer compresses. Copied streams are unaffected; the promise is same inputs, same library, same runtime, same bytes, and the fingerprint tests pin the runtime.
- An edit made in place can vanish. The reader's cache evicts; only the change set is safe.
- The /ID hash is not MD5, and an encrypted output cannot take its key's
/IDfrom a hash of what it has not yet written. - Two trailers, one identifier. DILA's file keeps the same
/IDin both trailers, and five documents have none: an update keeps the first element and writes one where there was none. - Linearization is not repaired by writing. A rewrite drops it; an update leaves it stale, as every update of a linearized file does, which viewers handle; neither pretends otherwise.
- A header version can lie, or name a version that does not exist; the catalog's
/Versioncounts only when it is later than the header. - An
/Infoedit alone breaks PDF/A. Parts 1 to 3 require/Infoand XMP to agree, and veraPDF checks it: that, as much as ADR 40, is why 1.7 output brings the other form in step when the change set touches one. - PDF 2.0 metadata is a move, not a deletion. Dropping
/Infoentries without carrying them into XMP loses them; generating an XMP instance identifier or a modification date breaks determinism. - XMP is XML from a hostile file: DTD processing off, no resolver, bounded size.
- pikepdf is qpdf. Two tools on one parser are one opinion; poppler is the second.
- ASP.NET Core refuses synchronous I/O, on the request body as on the response: an
Open(Stream)or aSave(Stream)there fails at run time, not at build time.
Documentation
docs/website/docs/concepts/writing.md(new): a full rewrite and an incremental update, what a save keeps, drops, raises and refuses, the change set and read-only objects, the/ID, the memory figures.docs/website/docs/concepts/pdf-versions.md(new): the policy of ADR 40, the raise and its report, what 2.0 output does with metadata.docs/website/docs/reference/pdf-versions.md(new): the table of minimum versions, and/Extensions.docs/website/docs/concepts/cancellation-and-progress.md(new): the convention every long operation follows, with the asynchronous edges and what a canceled operation leaves behind.docs/website/docs/reference/diagnostics.md: thewrite.*codes and their severities.docs/website/docs/guides/saving-a-document.md(new): saving, with an example of each mode — a full rewrite and an incremental update —, choosing the output version, and cancelling a long save.docs/website/docs/introduction.md: saving added to what the library does;docs/website/sidebars.jsfor the new pages.docs/architecture.md: §3.2 as built, §5's asynchronous edges; three new ADRs — read-only objects and the change set, the refusal of a signed full rewrite andAllowInvalidatingSignatures, and the cancellation and progress convention.docs/corpus.md: the round trip's exclusions (encrypted documents, until M16) and the referees added.docs/status.md: the measurements, #35, #36 and #52 closed, #39 updated.
Exit criteria
- #35's API baseline is in place before any public API is added (slice 0), failing the build on an unrecorded change.
- #52 is fixed before slice 6, and #36 before slice 8, each with its regression test.
- Serialization, the writer, the full rewrite and the incremental update exist, in tables and in streams, with object streams on write.
- The change set and read-only objects exist, recorded in an ADR.
- The output version policy of ADR 40 is implemented: the choice, the minimum, the reported raise,
/Extensionsand its union, and 2.0 output with metadata carried into XMP. - The cancellation and progress convention is recorded in an ADR and applied to
Open,OpenAsync,Save,SaveAsyncandValidate. - No signature and no conformance claim is broken in silence: the refusal, the insisted path and the veraPDF check are green; the refusal is recorded in an ADR.
- The acceptance conditions above pass on the corpus, in CI, with no document skipped — the encrypted ones
asserted refused —, and on a green
Remote corpusrun recorded indocs/status.md. - Unit tests cover every behavior above, its degenerate and its hostile cases.
- Integration tests run qpdf, poppler, pikepdf, pyHanko,
pdfsigand veraPDF in containers. -
WriterBenchmarksmeasures serialization, the rewrite and the update of the 1000-page journal withMemoryDiagnoser; a memory budget and a coarse throughput guard are enforced in CI;docs/status.mdrecords the figures. - The documentation site publishes writing, versions, cancellation and progress, and the
write.*codes. - Every page of Documentation is written in its Diátaxis section, one mode per page (ADR 47).