M06 — Pages and case-file assembly
State: to do — Depends on: M03, M04
Goal
Compose a case file from generated pages and third-party PDFs — pages, links, bookmarks, page labels, form fields, layers, attachments and logical structure — and say exactly what could not be carried.
This is the first business priority. The failure it exists to prevent is the ordinary one: a merge that
keeps the pages and quietly loses everything the catalog held — the labels the viewer shows, the named
destinations the links rely on, the field that two forms both call Nom and that now shares one value, the
layer that was hidden and is now printed. Invariant 7 forbids the silent version of any of these; this
milestone makes each one either carried or reported, structure by structure, and proves it on real files.
Scope
In:
- the page tree with its inherited attributes resolved (
Resources,MediaBox,CropBox,Rotate), and aPdfPageCollectionthat inserts, removes, reorders, rotates and extracts, saved as a full rewrite or as an incremental update (M03); - cross-document deep copy, streams passed through encoded, with resource deduplication;
- a merge that carries every catalog structure a merge can lose, each under a written policy: page labels
(read, written, remapped), the name trees and
/Destswith key renaming,/AcroFormwith collision-safe renaming of fully qualified names,/OCProperties,/Extensions, output intents, viewer preferences, the open action, and — because ADR 17 already decided that structure trees are recombined — the logical structure tree; - an attachments and associated-files API at document, page and annotation level, streamed;
- typed viewer preferences, page mode, page layout and open action;
- signed and certified inputs handled through M04's coverage and change classification: a merge or an edit that would void a certification is refused, or reported when the caller insists;
- a high-level assembly API with a plan that can be inspected before anything is written, and a report;
- the command-line tool,
AdCodicem.Pdf.Tool:info,validate,repair,merge,pages, andattachmentsunder the track rule of the roadmap.
Out, explicitly:
- split strategies, the full page-selection grammar, collation, images to pages, pruning of unreferenced
resources, outline editing and JSON,
GoToRandGoToErewriting, and portfolio unpacking — M07; M06 has page ranges only (1-3,7,z), enough for its own verbs; - anything drawn on a page — stamps, headers and footers, Bates numbers, N-up, flattening
/Rotate, fitting to A4 — M09; - decrypting an encrypted input — M16; until then an encrypted part is refused at analysis with the
PdfEncryptedExceptionthe reader already raises; - filling, flattening or regenerating form fields, and any XFA handling beyond removing it from a merge — M16; appearance generation and annotation flattening — M11;
- consolidating different subsets of one font across inputs, and linearized output — M23;
- actively preserving PDF/A through a merge (bringing the output into conformance) — M20; M06 keeps a conformance claim only when it stays true, and otherwise removes it and says so;
- signing the assembled volume — M26; any cryptographic check — M27;
- the case-file model, exhibit stamps, the inventory and court presets — M18; a rendered table of contents page — M12 and M18.
Design
Pages
PdfPage one page of an open document, read-only: effective MediaBox, CropBox (intersected
with the MediaBox), BleedBox, TrimBox and ArtBox (defaulting to the CropBox), Rotate
(normalized to 0, 90, 180 or 270), UserUnit, Resources; its index and its object
PdfPageCollection the pages of a document: Count, indexer, and edits — Insert (a blank page of a given
size, or pages copied from another document), Add, Remove, Move, Rotate, Extract
PdfRectangle an immutable box, normalized (lower-left, upper-right)
- Inheritance covers exactly the four keys ISO 32000 marks inheritable. Values are taken from the
ancestors met while walking down from the root when the tree is indexed, never by following
/Parentupwards: on a damaged file a/Parentcan point anywhere. A page with noMediaBoxanywhere on its path gets US Letter, the default readers apply, and the validator's finding (M02) is what reports it. - A kid that is null, or names an object the file lacks, is a page: blank, US Letter, with no resources —
the page qpdf, poppler and PDFium show, and the count M02 settles and the manifest records (30, 13 and 65 for
the three JHOVE files whose
unsupportedreason names this API). M02's finding reports each; extracting one yields a blank page, reported. - Edits are a plan, applied when the document is saved: nothing in the source changes until then. A full
rewrite writes a fresh balanced page tree; an incremental update writes only the page-tree nodes and pages
that changed. Rotation writes the page's own
/Rotate, so that an inherited value is never changed for the page's siblings. - Removing a page asks every owner of a reference to it to rewrite or drop that reference: outline
items, link destinations, named destinations, the open action, structure elements (
/Pg, marked-content references, object references), widgets and their fields, article beads. Each drop is reported. - Signed and certified documents. Saving page edits on a document that carries signatures goes through
M04's write guard, which classifies the pending change set as the next revision. A page-tree change is
class
Other, which noDocMDPlevel allows (P2 and P3 allow instantiating page templates, nothing more), so page edits on a certified document are refused withPdfSignatureInvalidationExceptionunless the caller insists through M03'sAllowInvalidatingSignatures; then they are written as an incremental update, which leaves the certified revision intact, and reported as voiding the certification. After an approval signature the edit is saved and reported with the class M04 gives it.
Deep copy and deduplication
PdfObjectCopier (internal) copies an object's closure from a source document into the writer's
number space: one remap table per source, one deduplication index per output
- What a copy never follows.
/Parent,/P, the outline links (/Prev,/Next,/First,/Last),/Pg, and any reference to a page object. Each of those is rewritten by the structure that owns it, through the page map — source page to target page — whose target numbers are reserved ahead for every page the plan includes, so that a link to a later page needs no second pass. A reference to a page the plan excludes resolves to nothing, and its owner decides what that means. - How it walks. An explicit stack, never recursion; each object visited once; a reference to an object
the source lacks copied as null (M02, T27); every read under the source's
PdfReaderLimits. - Streams travel encoded. The raw bytes are copied from the source to the writer with an indirect
/Length— never decoded, never recompressed, never held whole. Objects that lived in object streams are written by the M03 writer's own policy. - Deduplication is exact and deterministic. A stream's key is a SHA-256 (the BCL's, which Native AOT and
trimming keep) of its encoded bytes and of its dictionary without
/Length; a dictionary's or an array's key is computed over its canonical form, each reference replaced by its target's key, so that two font dictionaries pointing at equal font programs through different object numbers are equal. A cycle falls back to identity. Color profiles are keyed on their decoded bytes: the corpus carries one sRGB profile under four different encodings from PDFlib, Antenna House, 3-Heights, Acrobat and Distiller, sometimes under different condition identifiers. - What is shared. Objects reached from resources, appearance streams, file specifications and output
intents. Never page objects, page content streams, or anything carrying
/StructParentor/StructParents: M09 edits content streams in place and must not change two pages at once, and a structure key belongs to one part's parent tree. - Cost. The first occurrence of a stream is hashed while it is copied; a later candidate is hashed first and copied only if new. A stream is read at most twice and never held. The index holds a 32-byte key and a target number per shared object; the first occurrence wins.
Catalog structures: one written policy per key
Merging B into a volume whose first part is A (the base, or whichever part the caller names):
| Catalog entry | Policy | Reported as |
|---|---|---|
/Pages | Rebuilt as a balanced tree, at most 32 kids per node, over the plan's pages | — |
Header and /Version | The minimum version the output's features need, raised when a part requires it (ADR 40) | the writer's write.version-raised (M03), carried into the report |
/Extensions | Unioned: per developer prefix, the highest level; the PDF 2.0 array form when one prefix has several | — |
/PageLabels | Rebuilt from the page-label plan (below) | — |
/Dests and /Names /Dests | One name tree; the base keeps its keys, a later colliding key is renamed, every reference to it rewritten | assembly.destination-renamed |
/Names /EmbeddedFiles | Merged; colliding keys renamed, file names (/UF, /F) untouched | assembly.attachment-key-renamed |
/Names /AP, /Templates, /Pages, /Renditions, /AlternatePresentations | Merged; colliding keys renamed and references rewritten | assembly.name-renamed |
/Names /JavaScript | Merged, renamed on collision, never run (ADR 37) | assembly.active-content-carried |
/Names /IDS, /URLS | Unioned (Web Capture) | — |
/Outlines | Per part: kept at the top level, nested under a new item titled by the caller, or dropped; items whose target is excluded removed | assembly.outline-item-dropped |
/AcroForm | Forms (below) | assembly.field-renamed, assembly.xfa-removed |
/OCProperties | Groups and membership dictionaries unioned, distinct objects kept distinct even under one /Name; in /D, ON, OFF and Locked unioned, /Order grouped per part under the part's title, /RBGroups concatenated, /AS merged by event and category; /Configs concatenated | — |
/OutputIntents | Deduplicated by decoded profile; two different profiles for one subtype is a conflict, and the base's is kept | assembly.output-intent-conflict |
/StructTreeRoot, /MarkInfo, /Lang | Structure (below) | assembly.untagged-part |
/Metadata and the trailer's /Info | The base's or the caller's; a conformance claim kept only while it stays true (below) | assembly.conformance-claim-removed |
/ViewerPreferences, /PageMode, /PageLayout | The base's, overridden by the caller's typed values; unknown keys preserved | — |
/OpenAction | The base's; a destination remapped through the page map, an action carried as it is | — |
/AA | The base's; the others' document actions dropped | assembly.document-action-dropped |
/URI | The base's; a relative URI action from another part is made absolute against that part's own base first | assembly.uri-base-resolved |
/Threads | Carried when their beads' pages are included; beads on excluded pages cut out | assembly.thread-shortened |
/Perms | /DocMDP: the certification policy (below); /UR3: removed, as no merge keeps usage rights valid | assembly.usage-rights-removed |
/DSS, /Legal | Dropped: they belong to signatures the output does not carry | assembly.signature-material-dropped |
/Collection | A portfolio part contributes its cover pages only; M07 unpacks portfolios | assembly.portfolio-cover-only |
/AF | Unioned; more than one part with document-level associated files is reported, since a consumer such as Factur-X expects exactly one | assembly.associated-files-combined |
/Requirements | Unioned | — |
/PieceInfo, /SpiderInfo, /NeedsRendering, /DPartRoot, and any other key | The base's kept; each other part's dropped, by name | assembly.catalog-entry-dropped |
The last row is the rule the table exists for: no key leaves a part without a report entry naming it.
Inside copied pages, owners rewrite what points outside a page: an annotation's /P, a markup's /Popup
and the popup's /Parent, replies' /IRT, link /Dest and /A /D, a widget's /Parent into its field
tree, /StructParent and /StructParents, article beads /B. Page /AA, /Thumb, /Metadata, /AF,
/Group, /Tabs, /UserUnit, /VP and /PieceInfo are copied as they are. An annotation object shared
by two pages, which the specification forbids and producers write, is copied once per page and reported.
Destinations and outlines
- An explicit destination is remapped through the page map. One whose page is excluded makes its link meaningless: the link annotation is removed, the outline item loses its destination, and both are reported.
- Named destinations from the catalog's
/Dests(name keys, PDF 1.1) and from the name tree (string keys) are merged into one name tree. A reference by name object is rewritten as a string when its key moves into the tree. The base keeps its keys; a later colliding key takes the smallest free suffix~2,~3… that collides with nothing. The rename map — part, old key, new key — is part of the report: M07 uses it to rewriteGoToRlinks and#nameddest=parameters. - Structure destinations (
/SD, PDF 2.0) are remapped through the structure map. - Outlines are walked with a bound on items and depth and a visited set, since outlines with cycles
exist in the corpus.
/Countis recomputed, with its sign for the open state;/C,/Fand/SEare kept. A part's outline is kept at the top level, nested under a new item carrying the part's title (the case-file default), or dropped.
Page labels
PdfPageLabels the ranges of a document; the label of page i
PdfPageLabelRange StartIndex, Style, Prefix, FirstNumber
PdfPageLabelStyle Decimal, UpperRoman, LowerRoman, UpperLetters, LowerLetters, None
PdfPageLabelPlan Keep (each part's own labels), Continuous (one decimal run across the volume),
PerPart (a prefix per part, numbering restarting at 1), or explicit ranges
- Reading walks the number tree with bounds on depth, kids and entries; keys past the last page are ignored, a duplicated key keeps the last value. When the tree has no entry for page 0 — the USGS methods report's starts at index 7 — the pages before the first entry have an empty label, which is what an entry with no style gives; the specification requires the entry, and the writer adds it when it rebuilds a tree.
- Remapping: labels are materialized per page (style, prefix, number) as pages move, then compressed into the fewest ranges that reproduce them. A sequence no range can express — descending numbers after a reorder — costs one range per page. The output tree is balanced.
- Letters run A to Z, then AA to ZZ, then AAA: the letter is repeated, not counted in base 26. Roman
numerals past 3,999 repeat
M, as pdf.js does. - Prefixes are text strings, decoded and re-encoded for the output version; #36 must be fixed first, since PDFDocEncoding, UTF-8 in PDF 2.0 and UTF-16LE seen in the wild all occur in label prefixes.
Forms
PdfFieldCollisionPolicy Rename (default), Nest, Join, Refuse
- A field's fully qualified name is built from the decoded
/Tof it and its ancestors, walked with a bound and a visited set (a field tree with a cycle exists in the remote corpus). Two names collide when they are equal, or when one is the other plus a.-separated tail (a terminalain one part,a.bin another). - Rename gives the colliding top-level field of the later part the smallest free suffix
_2,_3…; Nest puts each part's fields under a new non-terminal field named by the caller, so every name in part k becomespiece-k.…; Join makes equal names share one value, the legacy behavior, only when asked, and refuses two fields of different types; Refuse stops the plan. Every rename is in the report, old name and new. - A field tree is copied with its widgets; kids whose widgets sit on excluded pages are pruned, and a field left with none is dropped and reported.
/DRis unioned. A resource name that means different objects in two parts — nearly every form in the corpus defines its own/Helv— is renamed in the later part, and the/DAstrings of that part's fields rewritten:/DAis a content-stream fragment, tokenized, and only the operand ofTfchanges./NeedAppearancesis true if any part says so — viewers then regenerate every field, the base's too, so it is reported./SigFlagsis recomputed from what the output carries rather than or'd./COis concatenated in plan order./XFAis removed: one template cannot describe the merged fields, and two cannot coexist. The AcroForm of a hybrid form is kept. A dynamic-XFA placeholder page is merged as it is (ADR 37). Both are reported.- Values are carried untouched; no appearance is regenerated.
Signed, certified and portfolio inputs
A signature covers bytes, and a merged file is new bytes: no signature survives into a merged output, and none is claimed to.
PdfSignedPartPolicy CarryUnsigned (default), AttachOriginal, Refuse
PdfCertifiedPartPolicy Refuse (default), Insist
- Approval signatures:
CarryUnsignedkeeps the field and its widget's appearance and removes the signature value, reportingassembly.signature-not-carriedwith the field's name.AttachOriginaldoes the same and adds the part's original file as a page-level associated file withAFRelationship /Sourceon the part's first page, so the evidence stays verifiable and travels with the part when M07 splits the volume. - Certification (
/Perms /DocMDP): the plan lists the part as refused, and writing a plan with a refusal throws a typed exception.Insisttreats it as an approval signature and reportsassembly.certification-voided. Refusal by default is decided (the maintainer, 2026-09-27), as is M07's default for a portfolio's certified member. /DSSand/Legalare dropped and reported. Signature fields keep taking part in name collisions.- Portfolios: a part with a
/Collectioncontributes its cover pages, and its members are reported, pointing at M07's unpacking. - Nothing here needs cryptography: M04 says what each signature covers and its
PdfSignatureKind— approval, certification, document time stamp, usage rights (ADR 41).
Logical structure (ADR 17)
- The output's structure root holds a
Documentelement; each tagged part's top-level elements go under aPartelement carrying the part's title, or directly underDocumentwhen the caller asks. - Parent tree: each part's keys are offset by the running next key; pages'
/StructParentsand annotations' and XObjects'/StructParentrewritten;/ParentTreeNextKeyrecomputed. - Role map and class map: unioned; a custom type mapped differently by two parts is renamed in the later
one (
Heading1becomesHeading1_2) and its elements'/Srewritten; class names alike, with/C. - ID tree: colliding element identifiers renamed. Namespaces (PDF 2.0): unioned.
- Language: when a part's catalog
/Langdiffers from the output's, it is set on that part'sPartelement, so no text loses its language. - Excluded pages: marked-content and object references to them removed; elements left empty pruned, with a bound on the walk.
- Untagged parts: in a volume whose other parts are tagged, an untagged part's content is outside the
structure;
/MarkInfo /Markedstays true for the rest, and the part is reported. A PDF/UA claim does not survive it. PdfStructurePolicy.Dropremoves the structure, clears/Marked, removes the claims, and says so.
Conformance claims
A PDF/A or PDF/UA claim in the output's XMP is written only when every part makes the same claim, the
output intents did not conflict, and — for PDF/UA and levels a — every part was tagged. Otherwise it is
removed and assembly.conformance-claim-removed names the claim and the part that broke it. M06 never
repairs conformance (M20), and never leaves a claim it has reason to doubt.
Attachments and associated files
PdfAttachments the files a document carries, at every level: document (EmbeddedFiles
name tree and catalog /AF), page (/AF and FileAttachment annotations),
annotation
PdfAttachment read side: key, file name (/UF over /F, decoded), description, MIME type,
size, dates, checksum, relationship, level and owner; OpenRead() streams
the decoded bytes under the document's reader limits
PdfAttachmentSpec write side: file name, a Stream read forward once, MIME type, description,
relationship, dates supplied by the caller — never the clock
PdfAssociatedFileRelationship Source, Data, Alternative, Supplement, EncryptedPayload, FormData, Schema,
Unspecified
- Writing streams the content: compressed on the fly with an indirect
/Length;/Params(size and checksum) is an indirect object reserved ahead and written after the stream, when both are known. /CheckSumis an MD5 computed by a managed implementation in the core, not the BCL's: browser WebAssembly has no MD5 (M23 runs the core there), and output must not depend on the platform (invariant 6).- The MIME type is written as a name with
/escaped as#2F. Adding a key that exists is refused or renamed, as the caller chooses. Name-tree keys stay sorted by bytes. /AFandAFRelationshipare PDF 2.0 keys that ISO 19005-3 brought into PDF 1.7 for PDF/A-3; they are written in 1.7 output without raising the version, so M03's version table gains no row for them — the choice M14 relies on for its PDF/A-3 invoices.- Related files (
/RF, Mac resource forks) are read as absent and reported.
Viewer preferences and open action
PdfViewerPreferences immutable: HideToolbar, HideMenubar, HideWindowUI, FitWindow, CenterWindow,
DisplayDocTitle, NonFullScreenPageMode, Direction, PrintScaling, Duplex,
PickTrayByPdfSize, PrintPageRange, NumCopies, Enforce; unknown keys preserved
PdfPageMode UseNone, UseOutlines, UseThumbs, FullScreen, UseOC, UseAttachments
PdfPageLayout SinglePage, OneColumn, TwoColumnLeft, TwoColumnRight, TwoPageLeft, TwoPageRight
PdfOpenAction a destination (remapped on merge) or an action carried as it is
The assembly API defaults for a case file are UseOutlines and DisplayDocTitle, since a case file opens on
its bookmarks and PDF/UA-1 requires the title shown.
The assembly API
PdfAssembly the facade: Add(source, part options) in order; Analyze() -> PdfAssemblyPlan;
WriteAsync(plan, output, cancellation, progress) -> PdfOperationReport
PdfAssemblySource a file path, a stream factory, or an open PdfDocument
PdfAssemblyPartOptions immutable: page ranges, title (its bookmark and its Part element), outline
placement (Keep, Nest, Drop), labels for this part
PdfAssemblyOptions immutable: field collision, signed-part, certified-part and structure policies;
page-label plan; deduplication; output version (ADR 40); document identifier,
dates and metadata supplied by the caller; viewer preferences; the base part
PdfAssemblyPlan what analysis found, per part: page count, collisions and their resolution,
refusals, the version the output will need — inspectable before a byte is written
PdfOperationReport entries (code, document or part, page or object, message); counts by code;
serializes to stable JSON — the one report every operation on existing
documents returns: M06's assembly and page edits, M07's, M09's, M11's, M16's
- Analysis opens each part in turn, keeps what the plan needs — page count, name-tree keys, fully qualified field names, the outline, the labels, the catalog's keys, the signatures and their kind (M04), the minimum version — and closes it.
- Writing opens each part again, in plan order, and copies it forward: one part open at a time. The page tree, outline, name trees, fields, structure root and catalog are written last, from numbers reserved ahead. Memory is the largest page's object closure, plus the plan's metadata, plus the deduplication index.
- Determinism: plan order, the source's key order, deterministic suffixes, first-occurrence deduplication and reserved numbering give the same bytes for the same inputs and options.
- Cancellation and progress follow M03's convention —
IProgress<PdfProgress>then aCancellationToken, checked without allocating — with one stage for analysis (Analyzing) andMerging, the stage M03 named for writing, both counted in pages. - The writer stays internal. M03 left
PdfWriter's public shape to M06 or M08; M06 needs none, since the assembly API is the public way to write a document made of parts. - Report codes are
assembly.plus a kebab-case name, public and stable from the first stable release, disjoint from reader diagnostic codes and validation rule identifiers. A reader diagnostic raised while a part is read is carried into the report under that part. - Editing one document in place and merging share the copier and every policy:
PdfPageCollection.Insertwith pages from another document is a two-part assembly.
The command-line tool
AdCodicem.Pdf.Tool a dotnet tool, and a Native AOT binary per platform; command adpdf
info FILE version, pages, encryption, producer, labels, outline summary,
fields, attachments, revisions and signature coverage (M04), claims
validate FILE [--profile] the M02 report
repair IN -o OUT [--mode] M05
merge -o OUT IN[:RANGES]... the assembly API; --plan PLAN.json for every option; --report
pages IN -o OUT select|remove|rotate|move|insert-blank RANGES
attachments FILE list|add|extract|remove
- Every verb has
--json, documented and stable. Exit codes: 0 success, 1 findings at error severity, 2 usage, 3 unreadable input, 4 refused by policy, 5 undecided — nothing failed, and a verdict waits for a person, for missing material or for a check that could not run (M20'svalidate, M27'sverify) —, 6 done at a lower level than asked, as the caller allowed (M21'spdfa convert). One table for every verb; a later verb uses it and never gives a code a second meaning. No network, no telemetry, no clock in outputs. - Native AOT and trimming warnings are build errors. The argument parser is System.CommandLine 2 or a small
parser of our own, whichever gives the smaller AOT binary with no warning; the slice records the
measurement. The package identifier follows ADR 24;
the command is
adpdf, recorded among the ADR index's decisions too small for a record. repairneeds M05, which the execution order places before M06; if M05 has not closed, the verb lands with M05.
Slices
Each slice ends on a green commit, with its acceptance rows passing and the report codes it introduces documented.
- Pages and inheritance. Delivers
PdfPage, the effective boxes, inherited attributes resolved from the indexed path,PdfPageCollectionread side. Proven by unit tests on hand-built trees (inheritance at every level, a/Parentthat lies, a tree deeper than the bound, a page with noMediaBox) and byPageTreeRefereeTests: pikepdf, which pushes inherited attributes down, in a container, agreeing page by page on every committed document. Leaves every write for later. - Page edits in place. Delivers rotate, remove, move and insert-blank on one document, saved as a full
rewrite and as an incremental update; references to a removed page dropped by their owners and reported;
the report type and its first codes; M04's write guard on certified documents. Proven by unit tests per owner
of a page reference, by qpdf's
--jsonpage list after each edit on the corpus's multi-page documents, and byvendor/us-federal/itext-govinfo-us-code-certified.pdfrefusing a rotation. Leaves copying between documents. - Deep copy, extraction and deduplication. Delivers
PdfObjectCopier, the page map, extraction of pages into a new document, a pages-only merge, and structural deduplication. Every catalog structure not yet carried is dropped with a report entry, so the slice is honest before it is complete. Proven by unit tests (cycles, self-references, references to missing objects, a stream longer than a window, a hostile depth), by extracting every page of every committed document and joining them back (qpdf--check, pdftotext page by page, both in containers), and by the size measurement on the Chromium report and its two qpdf rewrites. Leaves links, names, labels, forms, layers, attachments and structure. - Destinations, links and outlines. Delivers the page map applied to explicit destinations, the merged
name tree with renaming and the rename map, outline placement, removal of what points at excluded pages,
and the base's open action remapped. Proven by unit tests (name-object and string keys, a collision
chain, a cyclic outline, an outline item whose destination is a named destination that does not exist),
and by pdf.js (
getOutline,getDestination,getPageIndex) and pikepdf in containers resolving every link and bookmark of the merged corpus sets to the expected page. LeavesGoToRto M07. - Page labels. Delivers
PdfPageLabels, the plans, remapping on edit and merge. Addsexpect.pageLabelsto the manifest and its schema — "the label of each page, in page order, as qpdf's--jsonreports it (M06)" — for every document with a/PageLabelstree, written bybuild_corpus.pyfrom qpdf's--json. Proven by unit tests (letters past Z, roman past 3,999, a tree without key 0, a reorder that forces one range per page, hostile trees) and by pdf.jsgetPageLabelsand qpdf agreeing on every label of the merged sets. Leaves label-range splitting to M07. - Interactive forms. Delivers the AcroForm merge: fully qualified names on decoded text, the four policies,
/DRrenaming with/DArewriting, pruning,/SigFlags,/CO,/NeedAppearances, XFA removal. Fillsexpect.formFieldsfor the committed forms, which the schema already says are asserted from M06. Proven by unit tests (prefix collisions, a type conflict under Join, a field tree with a cycle,/DAwith unusual spacing) and by pikepdf listing every field, its name, type and value after the two Cerfa forms are merged. Leaves filling and appearances to M11 and M16. - Layers, output intents, extensions and the rest of the catalog. Delivers the
/OCPropertiesmerge, output-intent deduplication and conflicts,/Extensionsunion, typed viewer preferences, page mode and layout,/AA,/URI,/Threads,/Perms /UR3, the conformance-claim rule, and the policy for every remaining key — the catalog table complete. Proven by unit tests per key, pdf.js'sgetOptionalContentConfigfor layer visibility, qpdf's--jsonfor extensions, and veraPDF in a container for every PDF/A claim the output keeps. Leaves associated files. - Attachments and associated files. Delivers
PdfAttachmentsread and write at every level, streamed, with the managed MD5, and the merge of embedded-file trees and/AFarrays. Relies onexpect.attachments, which the schema already says is asserted from M06. Proven by unit tests (a file spec with both/Fand/UF, a name tree whose/AFpoints at another file specification, a huge declared/Size, a duplicate key) and by poppler'spdfdetachand pikepdf in containers listing and extracting the same files, byte for byte. Theattachmentsverb waits for slice 12. - Logical structure. Delivers the structure merge:
DocumentandPart, parent-tree renumbering, role and class map collisions, ID tree,/Langper part, pruning, untagged parts,PdfStructurePolicy.Drop. Proven by unit tests (colliding role maps, an XObject with/StructParentsreached from two parts, an element whose content is all on excluded pages) and by veraPDF's PDF/UA-1 profile on the merged tagged set, plus a pikepdf walk that finds every marked-content identifier reachable exactly once. - Signed, certified and portfolio parts; the version. Delivers the signed-part and certified-part
policies with
AttachOriginal,/DSSand/Legalremoval, portfolio covers, and M03's minimum version (ADR 40) applied across parts, its raise carried into the report. Proven by unit tests over M04's signature model, and by pyHanko in a container: the original attached to a part validates as intact once extracted bypdfdetach, and no signature in a merged output is reported valid. - The assembly facade. Delivers
PdfAssembly,PdfAssemblyPlan,PdfOperationReportand its JSON, cancellation and progress, the determinism guarantee, andAssemblyBenchmarks. Proven by the full acceptance table, by a test that saves every merge twice, and by the memory test over ten, fifty and one hundred documents. Leaves nothing in the core. - The command-line tool. Delivers
AdCodicem.Pdf.Toolwith its six verbs,--json, exit codes, the dotnet tool package and the Native AOT binaries built in CI. Proven by unit tests of each verb's handler in-process (arguments, exit codes, a JSON snapshot per verb) and byCorpusToolTests: the AOT binary, run over every committed document, agrees with the manifest and with the dotnet tool. Leavessplit,collate,images,outlineandunpackto M07.
Tests required
Unit — tests/AdCodicem.Pdf.Tests:
- Inheritance: each inheritable key at each level; a value overridden below; a
/Parentpointing elsewhere; a tree whose depth passes the bound; a page with noMediaBoxon its path. - Every page-reference owner: removing the page a link, an outline item, a named destination, the open action, a structure element, a widget and an article bead point at, each rewritten or dropped and reported.
- The copier: cycles, a self-referencing dictionary, a reference to a missing object, 10,000 nested arrays,
a stream with an indirect
/Lengththat lies, the same font reached through two object numbers (deduplicated), two fonts that differ by one byte (not), an object with/StructParents(never shared). - Every row of the catalog table: carried, renamed or dropped exactly as written, with its report entry.
- Collisions: named destinations (name object and string), embedded-file keys, field names (equal, prefix,
type conflict),
/DRnames with/DArewriting, role map and class map entries, ID tree keys; each suffix chosen so the result collides with nothing, and the same suffix every run. - Page labels: every style, letters past Z, roman past 3,999, a prefix in each text encoding, a tree without key 0, remapping after every edit; FsCheck: for any sequence of edits, each page's label after compression equals its materialized label.
- Attachments: add, list, read, replace and remove at each level; streaming verified by a source that refuses to be read twice; the managed MD5 against RFC 1321's test vectors; FsCheck: add then read returns the bytes written.
- Signed, certified, portfolio and encrypted parts: each policy's outcome and report entry; an encrypted part refused at analysis.
- Determinism: FsCheck over random plans drawn from corpus documents — two writes, identical bytes.
- Hostile: page-label trees, outlines, field trees, name trees and structure trees with cycles, excessive depth and absurd counts end in a report or a typed exception, inside time and allocation budgets.
- The tool: each verb's arguments, exit codes and JSON, in-process.
Integration — tests/AdCodicem.Pdf.IntegrationTests, every referee in a container (ADR 27):
- qpdf —
--checkon every output;--jsonfor pages, labels, outlines, form fields, attachments and extensions. - pikepdf — pushed-down page attributes; link and outline destination resolution; the field list with names, types and values; optional content; a structure-tree walk.
- pdf.js, pinned
pdfjs-distunder Node — page labels, outline, named and explicit destinations, optional-content visibility: what a viewer shows. - poppler —
pdftotextpage by page, andpdfdetachfor attachments. - veraPDF — every PDF/A or PDF/UA claim an output keeps; PDF/UA-1 on merged tagged documents.
- pyHanko — the original attached under
AttachOriginalstill validates as intact; no merged output claims a valid signature.
Acceptance conditions
"Every committed document" means every document under documents/ and vendor/ the reader opens,
encrypted ones excepted until M16.
| Documents | Behavior | Verified by |
|---|---|---|
| Every committed document | Every page's effective MediaBox, CropBox, Rotate and resources equal what pikepdf reports once it has pushed inherited attributes down | CorpusPageTests.Every_page_resolves_what_it_inherits, PageTreeRefereeTests |
vendor/opf-format-corpus/pdfmaker9-word-distiller-pdfa1b-test-document.pdf (its MediaBox only on the page tree's root), vendor/opf-format-corpus/distiller3-dea-cfr-damaged.pdf (19 pages two levels down, their MediaBox on intermediate nodes), the LibreOffice invoice and reports (a root /Resources every page overrides) | A page extracted alone, moved to another place or rotated keeps the box and the resources it inherited, as its own entries, and a rotation changes only its own /Rotate; qpdf agrees. Dropping the resources it never uses is M07's | CorpusAssemblyTests.Extracted_pages_keep_what_they_inherited |
| Every committed document | Extracting every page and joining them back gives the same page count, each page's text as pdftotext extracts it from the source, and a file qpdf --check accepts | CorpusAssemblyTests.Extracting_and_rejoining_every_page_changes_nothing_visible |
documents/contract/chromium-contract-fr.pdf, then as appendices vendor/uk-ogl/word2019-ccs-contract-schedule.pdf and vendor/pdf-association/indesign13-pdfua1-german-book-chapter.pdf (26 internal links, named destinations, a seven-item outline), with documents/report/chromium-report-fr.pdf as the generated pages | qpdf accepts the volume; pdf.js and pikepdf resolve every link and bookmark to the page its source named, shifted by its part's offset | CorpusAssemblyTests.Merged_contract_keeps_every_link_and_bookmark_on_its_page |
documents/report/chromium-report-fr.pdf, documents/report/qpdf-objstm-report.pdf, documents/archival/qpdf-linearized-report.pdf — the same eight names in each catalog's /Dests | Sixteen names renamed and listed in the report; each copy's eight links land in its own copy | CorpusAssemblyTests.Colliding_named_destinations_are_renamed_and_links_follow |
vendor/opf-format-corpus/word9-distiller405-usgs-nwql-volatile-organics-methods.pdf (86 pages, 14 top-level items), pdfmaker707-word-va-esig-developer-guide.pdf (80 internal links, 5 items), pdfmaker7-powerpoint-va-cancer-database-course.pdf (52 items, every page rotated 90°), pdfmaker707-word-law-library-iraq-legal-history.pdf (a destination name tree), vendor/eu-publications/3heights-eu-consolidated-regulation-2026.pdf (a destination name tree, 55 URI links) | In one volume, every item and internal link resolves in pdf.js to its expected page, URI links are untouched, rotations kept | CorpusAssemblyTests.Outlines_and_links_resolve_after_merge |
vendor/opf-format-corpus/reader10-openoffice32-annotated-object-streams.pdf (a note and a highlight with their popups, a named appearance tree), pdfmaker9-word-distiller-embedded-quicktime.pdf (a screen annotation and a rendition action) | Popups stay attached to their markup, the appearance tree is merged, rich media is carried and reported (ADR 37) | CorpusAssemblyTests.Annotations_keep_their_popups_and_active_content_is_carried |
vendor/fr-licence-ouverte/libreoffice-cerfa-13983-form.pdf (20 fields) and vendor/fr-licence-ouverte/pdfmaker-acrobat-cerfa-12156-form.pdf (406 fields, a calculation order, document JavaScript) — Code postal, Commune, Nom and Prénom in both | 426 distinct fully qualified names in pikepdf, the four renamed and reported, none joined; the two /Helv fonts shared when equal and kept apart when not; the JavaScript carried and reported | CorpusAssemblyTests.Colliding_form_fields_are_renamed_not_joined |
| Two fillings of one form with different values — not in the corpus (below) | Merged, both values survive, each under its own name | CorpusAssemblyTests.Merging_two_fillings_of_one_form_keeps_both_values |
word9-distiller405-usgs-nwql-volatile-organics-methods.pdf (labels from index 7 only: lower roman, then decimal from 78), vendor/docentric/fop-factur-x-visualization.pdf (a redundant second range), vendor/pdf-association/handwritten-utf16le-strings.pdf (a UTF-16LE prefix), vendor/opf-format-corpus/distiller952-pscript5-kb-pdf-risk-inventory.pdf (56 pages) | Under Keep, every page shows its source label in pdf.js and qpdf; under Continuous and PerPart, the labels the caller asked for, in both | CorpusAssemblyTests.Page_labels_follow_their_pages, CorpusAssemblyTests.Continuous_pagination_shows_the_labels_asked_for |
vendor/uk-ogl/indesign-acrobat-hmcts-n208-form.pdf and vendor/uk-ogl/pdfmaker21-ozev-sample-invoice.pdf (two different layers both named Watermark), vendor/pdf-association/handwritten-utf16le-strings.pdf (two groups off by default, UTF-16LE names), pdfmaker707-word-law-library-iraq-legal-history.pdf (a group on but absent from its empty /Order), and the decrypted twin of vendor/us-federal/livecycle-uscis-ar11-xfa-form.pdf — not in the corpus (below): print-only and view-only groups with usage and /AS; the original once M16 opens it | Each layer distinct, with its default visibility and its usage, listed in /Order under its part, as pdf.js reports | CorpusAssemblyTests.Layers_keep_their_identity_and_visibility |
vendor/pdf-association/pdflib-pps-kraxi-pdfa2a-pdfua1-invoice.pdf and vendor/eu-publications/pdflib-oj-exchange-rates-greek.pdf (PDF/A-2a, one sRGB profile, same bytes); pdflib-oj-exchange-rates-greek.pdf and vendor/eu-publications/3heights-eu-consolidated-regulation-2026.pdf (the same profile, differently encoded, under another identifier); vendor/verapdf/pdfa1b-annotations-pass.pdf and vendor/verapdf/pdfa2b-content-pass.pdf (Adobe RGB and Apple RGB) | One output intent where the decoded profiles are equal; a conflict reported otherwise; never a claim veraPDF rejects — kept and upheld, or removed and reported | CorpusAssemblyTests.Conformance_claims_are_kept_only_while_they_stay_true |
indesign13-pdfua1-german-book-chapter.pdf (ADBE extension level 3 under a 1.4 header) and vendor/us-federal/livecycle-irs-1040-2022-xfa-ur3.pdf (ADBE level 8, usage rights, XFA) | The output declares 1.7 with ADBE level 8; usage rights and XFA removed, each reported | CorpusAssemblyTests.Extensions_are_unioned_and_what_cannot_survive_is_reported |
vendor/opf-format-corpus/pdfmaker9-word-distiller-embedded-quattro-pro-spreadsheet.pdf (a document-level file), pdfmaker10-word-file-attachment-annotation.pdf (an annotation's file only), vendor/bfo/bfo-pdfa2b-embedded-pdf.pdf (a PDF inside), documents/invoice/qpdf-invoice-with-facturx-xml.pdf, and the Factur-X invoices of vendor/zugferd/ and vendor/docentric/ (/AF with AFRelationship; in pypdf2-facturx-python-false-pdfa3b.pdf, a name tree and an /AF naming different file specifications) | Every attachment listed with its level, name, MIME type and relationship as pikepdf lists it, its bytes equal to pdfdetach's; expect.attachments met | CorpusAttachmentTests.Every_attachment_is_listed_and_read_as_referees_see_it |
| The same documents, merged in one volume | Every attachment survives with its relationship; two factur-x.xml files keep their names under renamed keys, and the combination is reported | CorpusAttachmentTests.Attachments_survive_a_merge |
pdflib-pps-kraxi-pdfa2a-pdfua1-invoice.pdf, indesign13-pdfua1-german-book-chapter.pdf, vendor/pdf-association/indesign15-pdfua1-form.pdf — three PDF/UA-1 claims veraPDF upholds, in en, de-DE and en-GB | veraPDF's PDF/UA-1 profile finds no failure on the volume that no input already had; each part's language kept on its Part element; every marked-content identifier reachable once | CorpusAssemblyTests.Merged_tagged_documents_stay_tagged |
vendor/us-federal/itext-govinfo-us-code-certified.pdf (DocMDP P=1) | Rotating a page in place, or merging it, is refused; insisted, it proceeds and is reported, and under AttachOriginal the original taken out by pdfdetach validates as intact in pyHanko | CorpusAssemblyTests.Certified_documents_are_refused_unless_the_caller_insists |
vendor/pyhanko/acrobat-reader-signed-twice.pdf, vendor/lu-legilux/antenna-house-legilux-memorial-pades-lta.pdf, vendor/us-federal/docusign-pdfkit-gsa-sf30-contract-modification.pdf, with libreoffice-cerfa-13983-form.pdf (its empty Signature1 and Signature2) | Signature fields carried unsigned and renamed where they collide; /DSS dropped; each reported; pyHanko finds no valid signature in the output, and no report claims one | CorpusAssemblyTests.Signed_parts_are_carried_unsigned_and_reported |
The Chromium report and its two qpdf rewrites; documents/invoice/chromium-invoice-fr.pdf with qpdf-invoice-with-facturx-xml.pdf; documents/stress/reportlab-journal-1000-pages.pdf with itself | The output is no larger than the sum of the inputs minus the deduplicated bytes, within a stated overhead for the rebuilt trees; each font program written once | CorpusAssemblyTests.Merged_size_accounts_for_every_deduplicated_resource |
| Ten, fifty, then one hundred committed documents | Peak memory follows the largest page and the plan's metadata, not the number or size of the documents; budget recorded in status.md | CorpusAssemblyTests.Assembling_one_hundred_documents_holds_its_memory_budget, AssemblyBenchmarks |
| Every merge above | Two writes give identical bytes | CorpusAssemblyTests.Assembly_is_deterministic |
Remote: remote/opf-format-corpus/jhove-hul-29-wpdf-book-chapter-scan.pdf (a null kid), jhove-hul-35-atypon-pdfplus-journal-article.pdf and jhove-hul-35-pdfium-urban-planning-report.pdf (kids naming objects the file lacks) | PdfPageCollection holds the manifest's 30, 13 and 65 pages, each such kid a blank US Letter page as qpdf shows it; extracting one yields a blank page, reported | CorpusPageTests.Missing_kids_are_blank_pages_as_qpdf_counts_them |
Remote: remote/opf-format-corpus/acrobat9-portfolio-signed-3d.pdf | Its cover page merged, its five members reported as not unpacked | CorpusAssemblyTests.A_portfolio_part_contributes_its_cover |
vendor/us-federal/illustrator-irs-pub1-english.pdf (an incremental save that removed text); remote, remote/us-states/powerbi-pdfium-acrobat-covid-dashboard-nine-updates.pdf (ten revisions) | Merged as the latest revision shows it: the removed text does not come back in pdftotext, and no object only an earlier revision reaches is in the output | CorpusAssemblyTests.Only_the_latest_revision_is_carried |
Remote: remote/govinfo/us-code-2023-title42.pdf (9,302 pages, signed) | Twenty pages extracted with memory flat; its signature reported as not carried | CorpusAssemblyTests.Extracting_from_the_largest_document_holds_its_budget |
Remote: remote/pdfcpu/quartz-avow-certified-docmdp-md5-sigref.pdf, remote/eu-dss/pdfmaker11-nbu-sk-qualified-seal-docmdp-fieldmdp.pdf, remote/pdfium-tests/foxit-phantompdf-certification-signature-visible.pdf | Refused as certified by default, and each reported when insisted | CorpusAssemblyTests.Certified_documents_are_refused_unless_the_caller_insists |
Remote: remote/eu-dss/dss-signed-widget-self-parent-startxref-past-eof.pdf (a widget that is its own parent) | Merged without a hang; the field tree's cycle reported | CorpusAssemblyTests.Hostile_field_trees_end_in_a_report |
| Every committed document, through the tool | info --json gives the manifest's page count and encryption; merge of the contract set yields what the API yields; the AOT binary and the dotnet tool agree byte for byte | CorpusToolTests.Info_matches_the_manifest, CorpusToolTests.Merge_matches_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
- Page trees: every committed document the reader opens, to compare with pikepdf page by page;
inherited
MediaBoxin two (one from the root, one from intermediate nodes), a root/Resourcesoverridden by every page in the LibreOffice output;/Rotate90 or 270 inpdfmaker7-powerpoint-va-cancer-database-course.pdf,distiller7-pscript5-census-housing-units-2005.pdf,distiller952-pscript5-kb-pdf-risk-inventory.pdf(one page of 56) anddocusign-pdfkit-gsa-sf30-contract-modification.pdf;/CropBoxdiffering from theMediaBoxin dozens;UserUnitinhandwritten-compacted-syntax.pdf. - Destinations and outlines: the catalog
/Destsof the Chromium report and its qpdf derivatives (eight identical names — a ready-made collision); destination name trees in seven vendored files; eight documents with two or more top-level bookmarks, among them out-of-order items and two items on one page;handwritten-cyclic-toc.pdf, whose outline and links form a cycle. - Page labels: 32 committed documents with a
/PageLabelstree, four of them encrypted until M16, mostly decimal; lower roman in the USGS report and with the UTF-16LE prefix; remote,handwritten-pdf20-utf8-strings.pdfwith a UTF-8 prefix and outline titles, and long labeled documents (pdfmaker81-word-va-kernel-systems-guide.pdf, 464 pages). - Forms: thirty AcroForm dictionaries, four with XFA; real name collisions between the two Cerfa
forms, and between the signature fields of five signed files and the Cerfa's two empty ones;
/Helvin nearly every/DR;/COin two;/NeedAppearancesin none; a cyclic field tree in the remote corpus. - Layers: nine documents with
/OCProperties, two different layers namedWatermark, two groups off by default in the PDF Association's hand-written file, print-only and view-only usage in the AR-11 form (AES-128, until M16 or its decrypted twin), and three PDFMaker files whose/OCPropertieslack the/DISO 32000 requires, which M02's shape rules report. - Output intents and claims: 30 committed PDF/A claims, 28 with veraPDF's verdict, one sRGB profile under four encodings and two condition identifiers, Adobe RGB, Apple RGB, CGATS; three PDF/UA-1 claims veraPDF upholds.
- Extensions: ADBE levels 3 and 8, one under a 1.4 header.
- Attachments: document-level, annotation-level only, a nested PDF, six Factur-X and ZUGFeRD invoices with
/AF, one whose name tree and/AFdisagree; remote, an attachment added by an incremental update and one withAFRelationship /Source. - Signatures: a DocMDP P=1 certification committed, four more remote that open without a password;
approval signatures, PAdES B-LTA, DocuSign,
/DSS; usage rights in five files. - Active content: document JavaScript, a screen annotation with a rendition, a JavaScript open action.
- Scale: the 1000-page journal committed; US Code Title 42 and the ten-revision dashboard remote; the Acrobat 9 portfolio remote.
What it lacks
| Need | Why | Priority | Likely source |
|---|---|---|---|
| Two fillings of one AcroForm with different, fictitious values | The roadmap's "merging two forms that both define a field of the same name keeps both values" needs values; no committed form is filled in two ways | 1 | Generated here: reportlab-subscription-form.pdf filled twice with pypdf, a derived variant recorded in build_corpus.py |
The decrypted twin of vendor/us-federal/livecycle-uscis-ar11-xfa-form.pdf (M11's gap as well) | The only committed groups whose state differs between screen and print are AES-128 encrypted, which the reader opens in M16; the layer row above needs them now | 1 | Derived here: qpdf --decrypt, a recorded transformation in build_corpus.py |
| A contract whose appendices are third-party PDFs, with bookmarks and cross-references between body and appendices | The corpus's contract has no link, no bookmark and no appendix; the acceptance above substitutes a set of separate files | 2 | Generated here: LibreOffice from an ODT with an annex, headings exported as bookmarks and cross-reference fields as links; a Word-made annex as a contribution |
A page tree that inherits /Rotate, /CropBox and /Resources from intermediate nodes | Only MediaBox is inherited in the committed corpus, from two files, one damaged | 2 | A public source first (older Distiller and Ghostscript output); failing that, derived with pikepdf by a recorded restructuring |
Page labels in upper roman and letters, with real prefixes (A-1, Annexe 2-) | Committed labels are decimal or lower roman; letters and prefixes are what case files use | 2 | Generated here: qpdf --set-page-labels on a committed document |
| Two tagged documents whose role maps map one custom type differently, and colliding class names | The role-map collision rule has no real case; the corpus's role maps do not conflict | 3 | A contribution, or two producers' tagged output with their own style names (Word and InDesign) |
A document with a /URI base and relative URI links | The rule that resolves a part's relative links against its own base has no real case | 3 | Generated here: LibreOffice with relative link export, the base added by a recorded transformation |
| An intact document with article threads | Threads are carried only by a damaged remote file | 3 | A public source: magazine and newsletter PDFs from Distiller-era publishing in GovDocs1 |
| A PDF/UA-1 document from another producer than InDesign and PDFlib | The merged-structure acceptance rests on two producers | 3 | A public source (the PDF/UA Reference Suite's remaining members are remote), or a contribution |
Traps
- A merge is where libraries lose data quietly. The test of this milestone is not that the output opens, but that every structure named in the catalog table is either present or in the report. A test that checks only page count and text passes on the worst merge.
/Parentis not a way up. Following it drags a whole source page tree into the copy, and on damaged files it loops or points at the wrong node. Inheritance comes from the indexed path; copies never follow it.- Names in
/Destsare name objects; in the name tree they are strings. Moving the first into the second changes how every link refers to them. - The same profile, compressed differently, is still the same profile; different profiles under the same condition identifier are not. Compare decoded bytes.
- Sharing a content stream between two pages is legal and a trap for M09, which appends to content in place. Deduplication stops at resources.
- An XObject with
/StructParentscannot be shared between parts: its key belongs to one parent tree. - Field names are text:
Prénomin PDFDocEncoding and in UTF-16BE is one name. #36 comes first. /Helvis not one font: every producer writes its own under that name, and viewers regenerate appearances from/DRby name./NeedAppearancestrue in one part changes how every other part's fields look.- A signed input cannot stay signed. Anything that suggests otherwise — keeping
/V, keeping/DSS, keepingSigFlagsAppendOnly — misleads the next reader into validating a signature that covers nothing. - A page-label tree without key 0 is common; readers disagree less than they seem to, since a range with no style gives an empty label, which is what pdf.js shows.
- Two Factur-X invoices in one volume make neither a Factur-X invoice. The core does not know Factur-X; it reports combined document-level associated files, and M14 decides.
- Resource names inside
/DAstrings are the one place a resource name hides in a string rather than a name object.
Documentation
docs/website/docs/tutorials/first-case-file.md— assembling two received PDFs into one, with a bookmark for each and their page labels kept, step by step; its code a sample undersamples/, held to the tutorial by a test (ADR 47). Assembling case files is the first business priority, and the path a newcomer should learn by doing.docs/website/docs/concepts/pages.md— the page tree, inheritance, effective boxes, edits as a plan.docs/website/docs/concepts/merge-fidelity.md— what a merge carries, the catalog table, conformance claims, signed inputs; what M07 and M09 add.docs/website/docs/guides/assembling-a-case-file.md— the assembly API end to end: parts, titles, labels, collisions, the plan, the report.docs/website/docs/guides/attachments.md— attachments and associated files at every level.docs/website/docs/reference/assembly-report-codes.md— everyassembly.*code, its meaning and what to do about it.docs/website/docs/guides/installing-the-tool.md— installing the tool, as a dotnet tool or a Native AOT binary.docs/website/docs/reference/tool/index.md— each verb and its options,--json, exit codes.docs/website/docs/introduction.md— assembly and the tool added to what the library does.docs/architecture.md— the copier, the page map and deduplication inDocuments/; the tool package.docs/releasing.md— the tool's package and its AOT binaries.docs/corpus.md—expect.pageLabels, andformFieldsandattachmentsnow asserted from M06.
Exit criteria
-
PdfPageandPdfPageCollectionexist, with inheritance resolved from the indexed path and edits saved as full rewrite and incremental update. - The copier passes streams through encoded, deduplicates exactly, and never shares page content or structure-keyed objects.
- Every row of the catalog table is implemented, with its report entry, and no catalog key leaves a part unreported.
- Page labels, forms, layers, output intents, extensions, attachments and structure merge as designed.
- Signed, certified, portfolio and encrypted parts follow their policies; certification is never voided silently.
- #36, which M03 fixes before its slice 8, is closed before slice 5 — fixed here first should M03 not have done it.
- 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; FsCheck properties hold for labels, attachments and determinism.
- Integration tests confirm the outputs through qpdf, pikepdf, pdf.js, poppler, veraPDF and pyHanko, each in a container.
-
AssemblyBenchmarksmeasures merging and extracting withMemoryDiagnoser, and the memory budget of the one-hundred-document assembly is enforced in CI and recorded instatus.md. - The tool ships as a dotnet tool and as Native AOT binaries with no trimming or AOT warning, and its JSON outputs are documented.
- The documentation site publishes the pages listed above;
status.mdrecords the measurements. - Every page of Documentation is written in its Diátaxis section, one mode per page (ADR 47).