Skip to main content

M09 — Content on existing documents

State: to do — Depends on: M06, M07, M08 — Signed inputs guarded by M04; conformance preserved per ADR 17

Goal​

Act on a received PDF without regenerating it — watermarks, stamps, headers and footers, page numbers, exhibit stamps and Bates numbers, overlays, page geometry — so that nothing in the file changes except what the caller asked for, a tagged, PDF/A or PDF/UA document stays so or says exactly why not, and a certification is never voided in silence.

A case file is made of documents nobody here produced: a Word contract, a copier's scan, a signed notice, a PDF/UA brochure. Each must carry "Pièce n° 2.1", its page count and a Bates number without a byte of its own content being rewritten — because a rewritten content stream is where a stamp goes upside down on Chromium's output, where a tagged page stops being accessible, and where a signature stops covering what the reader sees.

Scope​

In:

  • the wrapper: marks added to a page by new content streams placed before and after its own — never by editing one — isolated from whatever graphics state the page leaves behind, using M08's balance analysis;
  • resources added without name collisions, the page given a resource dictionary of its own when the one it uses is inherited or shared;
  • stamps and watermarks — text (M08), boxes and rules, images (M07's passed-through image XObjects), any form XObject the caller supplies (the hook M10's barcodes use), a page of another PDF — placed in the page's displayed orientation, over or under the content, with opacity where the target allows it;
  • headers, footers and page numbering added after the fact, from templates whose fields include the page label (M06);
  • the exhibit stamp: firm, "Pièce n° 2.1", date; hierarchical piece numbers; per-piece page numbering; separator pages; for documents that are one piece each and for volumes whose pieces are page ranges;
  • Bates numbering across a set, with a document-to-range map, and page labels that mirror it on request;
  • our own marks recognized, so that stamping again updates rather than stacks, and removal restores each page's /Contents and /Resources to exactly what they were;
  • every mark written as an /Artifact of type /Pagination — header, footer, watermark, page number, Bates — so that a tagged or PDF/UA input stays so;
  • conformance on the way: the input's PDF/A and PDF/UA claims read and respected — embedded fonts, color spaces the output intent allows, no transparency in PDF/A-1 — or the loss refused, or reported;
  • signed inputs through M04: a certification refused unless the caller insists; approval signatures kept intact by an incremental update, the stamp reported as a change after signing;
  • page normalization: page boxes set, crop by rectangle or margins, fit to a size (A4 by default) with margins, inverted and offset boxes and UserUnit normalized, /Rotate flattened — annotations, links, destinations and widgets transformed alike;
  • N-up and imposition: grids and saddle-stitched booklets, onto a new document;
  • the command-line tool's stamp, bates, normalize and impose verbs.

Out, explicitly:

  • stamps rendered from an HTML fragment — M12.6, which renders the fragment to a form XObject this milestone's stamps accept unchanged;
  • barcodes themselves — M10, which paints them as form XObjects for the hook above;
  • a stamp placed as a /Stamp annotation — which a DocMDP P=3 certification permits where page content is forbidden —, print-only or screen-only marks through optional content ("COPIE"), and flattening annotations into content — M11;
  • the case-file model: piece numbers taken from an inventory, the bordereau, court-portal presets — M18, which drives this milestone's primitives from one model;
  • detecting or removing other tools' watermarks and stamps (Acrobat's /PieceInfo marks, PDFStamp's, /Artifact /Watermark content we did not write) — M19's sanitization; M09 leaves them untouched;
  • editing or deleting operators inside an existing content stream — M19's content-editing pipeline, grown from M11's marked-content filter; crop to the content's bounding box, which needs the interpreter — M15;
  • encrypted inputs — M16; until then M03's save refuses them with PdfEncryptedException;
  • bleed, printer's marks and PDF/X — M29; color conversion between output-intent families — M29's color-management satellite;
  • checking a stamp's appearance with our own rasterizer — M25; MuPDF, in a container, is the referee meanwhile.

Dependencies. The roadmap's table gives M06, M07 and M08: M07 for its page-selection grammar and the image XObjects its container parsers pass through. M09 also uses M04's write guard for signed inputs, which the execution order places before it, so nothing waits.

Design​

The rule: add around, never rewrite​

A stamped page's /Contents becomes an array — our prefix stream, the page's own streams referenced exactly as they were, our suffix stream — and nothing else changes in the page but /Contents and, when a mark needs resources, /Resources. No original content stream is decoded for writing, copied, re-encoded or edited; a stream two pages share stays shared, which is what M06's trap warned about.

What M08's balance analysis says about the page's own content decides the wrapper:

The page's contentThe wrapper
Changes the CTM or the graphics state outside any q (Chromium's opening flip, ReportLab's cm)Prefix q, suffix Q before the marks: they draw in the page's initial state
Pops k levels below its starting depth (unbalanced-q-Q)Prefix q repeated 1 + k times, so those Q pop ours, not the page's initial state; reported (stamp.content-balanced)
Leaves n q open (PDFKit's page)Suffix Q repeated to the depth actually reached, then the marks; reported
Ends inside a text object or inside marked content (damaged pages of Distiller 4 and PDFMaker 7.07)Suffix closes what is open, in the reverse order it was opened — ET for the text object, EMC for each marked-content sequence — before any Q: q is illegal in a text object, and an artifact nested inside another's marked content breaks the tag tree; reported (stamp.content-closed)
Cannot be decodedThe plain q / Q wrapper; stamp.content-unverified, warning — a viewer that cannot decode it either draws nothing of it
Absent (a page with no /Contents, as Foxit writes)The marks alone

Underlays go in the prefix, after its q and inside a q…Q of their own; overlays in the suffix, after the balancing operators. The analysis reads a page's streams as one sequence (M08), once, decoded under the reader's limits, and keeps four numbers.

Placing a mark where the reader sees it​

PdfMarkPosition anchor (nine positions), offsets, rotation, the box it is relative to (CropBox by default,
TrimBox or MediaBox on request), and whether it follows the displayed orientation (default)
  • The visible page is the CropBox intersected with the MediaBox (M06's effective boxes), in the orientation /Rotate gives it, at the size UserUnit gives it. "Top right, 10 mm from the edges" means top right as a reader sees the page: on a page rotated by 90°, the mark is drawn rotated by −90° in default user space so that it reads upright once the viewer turns the page.
  • Offsets and sizes are in points of 1/72 inch on paper; a page with a UserUnit of 2 gets them halved in its own units. A box whose origin is not (0, 0), or whose corners are given in the wrong order, is handled as M06's normalized rectangle, never as the array written.
  • A mark's matrix is computed once per distinct (box, rotation, user unit) and written with M08's four-decimal formatting.

Marks​

PdfStamp immutable: elements — text lines (a PdfTextStyle from M08), boxes, rules, images, a form
XObject supplied by the caller, a page of another document — laid out in a box; opacity and
blend mode; over or under the content; the pages it applies to (M07's selection grammar); an id;
an optional-content group or membership, written as /OC … BDC … EMC — the slot M11 fills
PdfStampText a template with fields: {page}, {pages}, {label}, {piece}, {piece-page}, {piece-pages},
{bates}, {date} (a value the caller supplies, formatted with the culture the caller names),
{title}, and literal text
PdfStamper Plan(document, marks, options) -> PdfStampPlan; the plan is applied when the document is saved
PdfStampOptions immutable: conformance policy, signed-document policy, output version (ADR 40), whether our
own marks with the same id are replaced (default) or refused
  • A mark compiles into a static form XObject — everything that is the same on every page, written once and shared — and a variable part: the fields that change per page, drawn directly in the page's suffix stream, a few dozen bytes. A thousand Bates numbers cost one font subset, one XObject and a thousand small streams.
  • Fonts come from M08's registry; the default is the OFL sans face, embedded and subset, so that the defaults never need a standard 14 font. M08 proposes that the core carry that one face; should its ADR keep every face out of the core, the default comes from the fonts package or a face the caller registers, and a mark with no embeddable face on a document with a claim is refused, never drawn in Helvetica.
  • Text is laid out by M08's simple path: explicit lines, greedy wrapping at spaces within the stamp's box, alignment, and shrink-to-fit when asked. A script that needs shaping is drawn and reported by M08 (text.shaping-required); HTML-fragment stamps (M12.6) are the way to set it properly.
  • A page of another PDF (a letterhead, a "COPIE" sheet) becomes a form XObject through M06's copier: its content stream's encoded bytes reused under a form dictionary, or, for a page of several streams, their decoded bytes joined and compressed once, reported (geometry.contents-joined).
  • Opacity is an ExtGState (M08), deduplicated per document.

Resources without collisions​

  • The page's effective resources come from M06's inheritance. When the page's /Resources is inherited from a page-tree node, or is an indirect dictionary another page also uses, the page gets a dictionary of its own: every category of the effective one referenced as it was, and only the categories a mark adds to — /Font, /XObject, /ExtGState — copied shallowly and extended. The shared dictionary is never modified, so the forty pages of the Luxembourg memorial that share one keep sharing it.
  • New names avoid every name in the effective resources and every name token in the page's content (M08's PdfResourceScope): a name the content uses and the resources lack — a broken page, but a real one — is never given a meaning it did not have. Names are deterministic: /AdcF1, /AdcX1, /AdcGS1, the first free.
  • A form XObject without /Resources of its own uses the page's (legal before PDF 1.2 and still written); the page's new dictionary is a superset, so it keeps working.

Tagged inputs​

  • Every mark is written inside /Artifact << /Type /Pagination /Subtype … /Attached […] >> BDC … EMC, tagged input or not — it costs a few bytes, makes the stamp recognizable to M15's extraction and M19's sanitizer, and keeps a document that is later tagged honest.
  • Subtypes follow the output version (ADR 40): PDF 1.7 defines Header, Footer and Watermark; PDF 2.0 adds PageNum and Bates. In 1.7 output a page number or a Bates number is Header or Footer by where it sits; in 2.0 output it is PageNum or Bates. An exhibit stamp is Header or Footer by position, as ISO 32000-2 treats Bates numbers — navigation, not content. /Subtype is written only from PDF 1.7, and never raises the version.
  • Artifacts carry no MCID, so the structure tree, the parent tree, /StructParents and /Tabs are untouched. A separator page added to a tagged volume carries its text as a Pagination artifact as well; its title reaches assistive technology through the bookmark M18 gives the piece.
  • Word already writes its own headers as Pagination artifacts, and PDFlib and InDesign tag theirs: a stamp that lands beside them is a sibling artifact, never nested in theirs.

Conformance on the way​

The input's claims are read from its XMP (pdfaid, pdfuaid); each mark is checked against them before a byte is written.

TargetWhat the mark must respectBy default
Every PDF/A partFonts embedded, with widths consistent and, for levels u and a, a ToUnicode (M08)The default face satisfies it; a standard 14 font asked for is refused
Every PDF/A partDevice color only in the family of the output intent's profile; DeviceGray where an output intent or a DefaultGray existsGray by default; an RGB color on a CMYK intent (the EU's 2015 consolidated text) is refused — converting it needs color management (M29)
PDF/A-1No transparency: CA and ca 1, no soft mask, blend mode NormalA watermark with opacity is refused; the caller may choose an underlay or a lighter color
PDF/A-2, 3 and 4Transparency allowed; a page with transparency in a document without an output intent needs a page group with /CSAdded when needed
PDF/A-2 and later, PDF/UANo reference to .notdef (M08 draws a box instead)—
PDF/UA-1Everything drawn is real content or an artifactEvery mark is an artifact

PdfConformancePolicy decides what a conflict does: Refuse (the default) throws a typed PdfConformanceException naming the option, the claim and the clause — the caller asked for the impossible, before anything was written; RemoveClaim writes the mark, removes the claim from the XMP, and reports stamp.conformance-claim-removed at ConformanceLoss severity — a claim the library knows to be false is never left in the file (invariant 7). Metadata is otherwise untouched: M03 changes no date the caller did not supply.

Signed and certified inputs​

Before writing, M09 asks M04's WouldBreakSignatures what the change set is: a stamp changes page content, class Other.

  • A certification (/Perms /DocMDP, any P) forbids it: the plan is refused with PdfSignatureInvalidationException, naming the signature and its permission. With AllowInvalidatingSignatures it is written as an incremental update, which leaves the certified revision byte for byte, and each signature it voids is reported (write.signature-invalidated).
  • Approval signatures allow any change by the specification. The stamp is written as an incremental update — M03 refuses a full rewrite of a signed document — so each signature still covers exactly its revision, and the report says the mark lies outside every signature (stamp.after-signature, warning, naming each): a validator will show it as a change made after signing, which is what it is. PdfSignedDocumentPolicy.Refuse is there for a caller who would rather not.
  • Usage rights (/Perms /UR3) do not survive a change to page content in Acrobat Reader: reported (stamp.usage-rights-broken), and removed only by M16.
  • A dynamic XFA form's only page is a placeholder the viewer replaces: stamping it is reported (stamp.dynamic-xfa), since no XFA viewer will show the mark (ADR 37).

Headers, footers and page numbers​

PdfHeaderFooter left, center and right slots for the header and for the footer, each a PdfStampText;
margins from the visible box; odd and even pages; first page skipped; the pages it applies to

Page {page} of {pages}, {label} — the page label as viewers show it, from M06's PdfPageLabels —, a document title, a date the caller supplies. Existing headers and footers are not detected (that needs M15's interpreter): the margins are the caller's to choose, and the trap is written below.

The exhibit stamp​

PdfExhibitNumber a piece number: components parsed from "2.1", an optional ordinal suffix ("12 bis",
"12 ter", the Latin ordinals listed as data), compared piece by piece (2.10 after 2.9),
a suffixed number after the number's sub-pieces and before the next number
(12 < 12.1 < 12 bis < 13); Parse total; formatted with the separator the caller chooses
PdfExhibitStamp immutable: lines ({firm}, "Pièce n° {piece}", {date}, anything else), a box (none, rectangle,
rounded, double), the pages it goes on (first, or every), per-piece page numbering
("p. {piece-page}/{piece-pages}"), position (top right by default), style
PdfPiece a piece: its number, its title, and either a document of its own or a page range of a volume
PdfSeparatorPage a page inserted before a piece, the size of the piece's first visible page (or A4), carrying
the number and the title centered
PdfExhibitStamper Stamp(pieces, stamp, options, sink) -> one report per piece, written one piece at a time
  • A volume — the output of M06's assembly — is stamped piece by piece through page ranges: per-piece page numbers restart at each range, and a separator page is inserted at the start of each range through M06's page edits, which keep the outline and labels pointing where they did.
  • The French wording is the default template, not code: every line is a template, and M18 keeps the presets.
  • Nothing here numbers pieces: the caller gives each piece its number. M18 derives them from the inventory.

Bates numbering​

PdfBatesOptions prefix, suffix, digits (zero padding), start, position, style, overflow (Widen or Refuse),
page labels mirrored (off by default)
PdfBatesNumberer Number(sources, options, sink) -> PdfBatesRangeMap
PdfBatesRangeMap per source: first and last number, page count; serializes to stable JSON
  • The counter runs across the sources in the order given, one number per page, separator pages included when the caller includes them. A number that needs more digits than digits widens and is reported (stamp.bates-widened), or stops the run under Refuse.
  • Mirrored page labels express ABC000123 exactly: a decimal page-label range cannot pad, so the prefix carries the zeros, and a new range starts where the number gains a digit (ABC00000 for 1–9, ABC0000 for 10–99…). Existing labels are replaced and reported (stamp.page-labels-replaced); the catalog then joins the change set.
  • The map is what M18's inventory and an e-discovery load file read.

Our own marks: update and removal​

  • Our prefix and suffix streams carry, in their stream dictionaries, a second-class key under the library's developer prefix (ISO 32000-2 Annex E) — a working prefix ADCP until the PDF Association's registry grants one, before the first stable release. The suffix's entry records the ids of the marks it draws and the page's original /Contents and /Resources values — references and small direct objects, never content.
  • One pair of streams per page, whatever the number of marks: stamping again with a new id rewrites our suffix to draw both; stamping with an id already there replaces that mark (the default) or is refused; removing an id rewrites the suffix without it; removing the last restores /Contents and /Resources from the record and drops our objects. In a full rewrite they are no longer reachable and are not written; in an incremental update the page is redefined with its original values.
  • A marker is trusted only when the page's shape matches it — our prefix first, our suffix last, the recorded values resolving. Anything else is someone else's mark, or ours damaged: left alone and reported (stamp.foreign-mark).
  • The key travels with the stream: a page stamped, merged by M06 and split by M07 is still ours to update.

Page normalization​

PdfPageGeometry SetBoxes(media, crop, bleed, trim, art), Crop(rectangle or margins), FitTo(size, margins,
mode: Fit, ShrinkOnly, Fill; alignment), NormalizeOrigin(), FoldUserUnit(), FlattenRotation()
  • Boxes are written on the page itself, never on a page-tree node that other pages inherit from, and clipped to the MediaBox as ISO 32000-2 §14.11.2 requires; a box that falls outside is reported (geometry.box-clipped). A page with no MediaBox anywhere gets US Letter written explicitly, as readers assume.
  • Fitting scales uniformly and translates through a cm in our prefix: the page's content is untouched. The new MediaBox is the target size at the origin; the CropBox becomes it; TrimBox, BleedBox and ArtBox are transformed; UserUnit is folded into the scale and removed. Orientation follows the displayed page.
  • Everything that holds a page coordinate is transformed alike: annotation /Rect, /QuadPoints, /InkList, /Vertices, /L, /CL; /RD and border widths scaled; destinations that name this page — /XYZ, /FitH, /FitV, /FitR, /FitBH, /FitBV — in link actions, outline items, the open action and the named-destination tree; article beads' /R; the /BBox of layout attributes in the structure tree. What is not understood — /VP measure dictionaries, 3D views — is left and reported (geometry.annotation-not-transformed), never silently wrong.
  • Appearance streams need nothing when the scale is uniform: a viewer maps an appearance's /BBox onto the annotation's /Rect. A form field whose appearance a viewer regenerates from /DA (/NeedAppearances) keeps its font size and may differ — reported for each field on such a page.

Flattening /Rotate​

  • The rotation becomes a cm in our prefix; every box is rotated, width and height exchanged for 90° and 270°; and the page gets /Rotate 0 written explicitly — /Rotate is inherited, and removing the page's own entry would hand it its parent's.
  • Annotations: rectangles, quadrilaterals and lists transformed as above; appearance streams, which a viewer would have turned with the page, get the rotation in their /Matrix — copied first when another annotation shares them (geometry.appearance-copied); a widget's /MK /R adjusted, so a regenerated appearance agrees; an annotation with the NoRotate flag keeps its appearance upright and only its anchor corner moves, as the specification says a viewer draws it.
  • The result must look exactly as it did: MuPDF renders the page before and after to the same pixels within the threshold, and so does qpdf's own --flatten-rotation, a second implementation to compare with.

N-up and imposition​

PdfImposition Grid(columns, rows, order) or Booklet (saddle stitch); sheet size, margins, gutters,
borders, scale per cell (fit, keeping aspect), automatic cell rotation for landscape pages
PdfImposer Impose(source, imposition, options, output) -> report; a new document
  • Each source page becomes a form XObject (as for overlays above) whose /BBox is its visible box and whose /Matrix undoes its /Rotate; a sheet draws its cells. Memory is one source page at a time, through M06's copier and M03's writer.
  • A booklet pads the page count to a multiple of four with blank pages, ordered for folding (last, first; second, second last…), reported.
  • Link annotations are carried and transformed onto the sheet, their destinations remapped to sheets through M06's page map; other annotations are dropped and reported (geometry.annotations-dropped) until M11 can flatten them. The logical structure is not carried — marked content inside a form XObject would need a new parent tree — so a tagged input yields an untagged output, its PDF/UA claim removed and reported.

The plan, the report and memory​

  • Like M06's edits, stamps and geometry are a plan applied when the document is saved: the writer asks the plan for each page it reaches, the plan produces the page's new dictionary and its two small streams, and forgets them. Nothing is held per page but the report's entries; fonts are finalized last (M08). Memory follows the heaviest page's content analysis plus the subsets' (glyph, text) maps — never the page count.
  • The report is M06's PdfOperationReport — entries of code, document, page, object and message, bounded with exact counts by code, stable JSON — under the stamp. and geometry. families, public from the first stable release and disjoint from reader diagnostics and validation rules. Font and text diagnostics from M08 are carried in it.
  • Cancellation and progress follow M03's convention, counted in pages; a set (Bates, exhibits) reports per document.
  • Determinism: the same inputs, marks, options, caller-supplied dates and identifiers give the same bytes.
CodeSeverityMeaning
stamp.content-balancedInformationThe page's content popped below its start or left q open; the wrapper compensated
stamp.content-closedInformationThe page's content ended inside a text object or marked content; closed before the marks
stamp.content-unverifiedWarningThe page's content could not be decoded; the plain wrapper was used
stamp.after-signatureWarningThe marks lie outside every approval signature; a validator will show a later change
stamp.usage-rights-brokenWarningUsage rights no longer hold after this change
stamp.dynamic-xfaWarningThe page is a dynamic XFA placeholder; viewers rendering the XFA will not show the mark
stamp.conformance-claim-removedConformanceLossA claim the marks break was removed, as the caller's policy asked
stamp.bates-widenedWarningA Bates number needed more digits than asked
stamp.page-labels-replacedInformationPage labels mirroring Bates numbers replaced the document's own
stamp.foreign-markWarningA marker that does not match our structure; left untouched
geometry.box-clippedWarningA page box fell outside the MediaBox and was clipped
geometry.annotation-not-transformedWarningA key holding coordinates we do not transform
geometry.appearance-copiedInformationA shared appearance stream copied before being rotated
geometry.contents-joinedInformationA page's several streams joined to become a form XObject
geometry.annotations-droppedWarningAnnotations not carried onto imposed sheets
geometry.structure-droppedConformanceLoss or WarningLogical structure not carried onto imposed sheets

The command-line tool​

stamp IN -o OUT (--text T | --image F | --pdf F[:PAGE] | --plan PLAN.json) [--position] [--pages]
[--under] [--opacity] [--id ID] | --remove ID
bates IN... -o DIR --prefix P --digits N [--start N] [--position] [--map MAP.json] [--labels]
normalize IN -o OUT [--fit A4] [--margins] [--crop] [--boxes] [--flatten-rotation]
impose IN -o OUT (--grid CxR | --booklet) [--sheet A4] [--order]

Every verb takes --json and M06's exit codes; a refusal by policy — a certification, a conformance conflict — is exit code 4. impose is a fourth verb beside the three the roadmap names, since the track rule gives every operation its verb.

Slices​

Each slice ends on a green commit, with its acceptance rows passing and the report codes it introduces documented.

  1. The wrapper. Delivers the prefix and suffix streams from M08's balance analysis, the page's own resource dictionary, collision-free names, the plan applied at save in both modes, and the M04 guard; one trivial mark (a text line in the default face). Proved by unit tests over every row of the wrapper table and every shape of /Resources (inherited, indirect and shared, direct, absent); every committed document stamped on every page, in an incremental update — M04's revision comparison and pikepdf both find exactly the stamped pages and the new objects changed — and in a full rewrite, compared with M03's graph comparer; integration: qpdf --check, and pdftotext and PyMuPDF find the original words plus the mark's on every page. Leaves placement.
  2. Placement and stamps. Delivers PdfMarkPosition in the displayed orientation, PdfStamp with its elements, the static XObject and the variable part, opacity, images and pages of other documents as XObjects. Proved by unit tests of the placement matrix for every rotation, user unit and box origin; integration: PyMuPDF's word boxes for the mark, taken through the page's rotation matrix, lie in the requested corner on the rotated, landscape, offset and user-unit pages of the corpus; MuPDF renders the mark upright. Leaves artifacts and conformance.
  3. Artifacts and conformance. Delivers the /Artifact /Pagination marking by output version, the claim checks of the conformance table, PdfConformancePolicy, the page group for transparency. Proved by unit tests per rule and per policy; integration: veraPDF on every committed PDF/A and tagged document before and after — no new failure, every upheld claim still upheld, every refused option refused with its clause; pikepdf finds each structure tree unchanged. Leaves signatures.
  4. Signed, certified and XFA inputs. Delivers the certification refusal and the insisted path, approval signatures stamped after, usage rights and dynamic XFA reported, PdfSignedDocumentPolicy. Proved by unit tests over M04's model; integration: pyHanko reports each approval signature still covering its revision and finds the insisted stamp on the certified document a modification its permission does not allow; poppler's pdfsig still calls valid what it called valid. Leaves templates.
  5. Headers, footers and numbering. Delivers PdfHeaderFooter, the fields, page labels, dates formatted in the caller's culture. Proved by unit tests per field and slot; integration: pdftotext finds on each page the footer pdf.js's page labels predict. Leaves overlays.
  6. Overlays and underlays. Delivers pages of other documents over and under, the joined-contents path, the letterhead case. Proved by PyMuPDF's text trace showing the underlay's text first and the overlay's last on each page, and both texts extracted; the joined streams decoding to the concatenation of the originals. Leaves exhibits.
  7. Exhibit stamps. Delivers PdfExhibitNumber with its ordinal suffixes, PdfExhibitStamp, PdfPiece, separator pages, per-piece numbering, pieces as documents and as ranges of a volume. Proved by unit tests of number parsing and ordering (FsCheck: Parse is total, and the order is total and transitive over numbers with and without suffixes); M18 numbers its pieces with this type; the case-file set below, each piece's first page carrying its number and every page its "p. k/n"; separator pages counted by qpdf and titled as asked; the outline of an assembled volume still resolving in pdf.js after separators are inserted. Leaves Bates.
  8. Bates numbering. Delivers PdfBatesNumberer, the range map and its JSON, widening, mirrored page labels. Proved by unit tests of padding, overflow and the label ranges at each digit boundary; the Bates set below numbered continuously as pdftotext reads it; pdf.js and qpdf reading the mirrored labels as the stamped numbers. Leaves updates.
  9. Update and removal. Delivers the marker, one pair per page, replacement, removal and restoration, foreign marks. Proved by stamping twice giving the bytes of stamping once; removing every mark restoring each page's /Contents and /Resources values and, in a full rewrite, a graph equal to the unstamped rewrite's; our removal leaving Acrobat's, Word's and PDFStamp's marks where they were. Leaves geometry.
  10. Boxes, crop and fitting. Delivers SetBoxes, Crop, FitTo, origin and user-unit normalization, and the transformation of annotations, destinations, beads and layout boxes. Proved by unit tests per key and per destination kind; integration: qpdf's --json page boxes; PyMuPDF's words and link rectangles moved by the computed matrix within half a point; pdf.js resolving every link to the same point on its page. Leaves rotation.
  11. Flattening rotation. Delivers FlattenRotation with appearances, /MK /R, NoRotate, explicit /Rotate 0, and the cross-engine threshold, recorded in status.md. Proved by MuPDF rendering each rotated corpus page before and after, and qpdf's --flatten-rotation output, to the same pixels within the threshold; widget and link positions agreeing in PyMuPDF. Leaves imposition.
  12. Imposition, the tool, budgets. Delivers PdfImposer with grids and booklets, the four verbs, StampBenchmarks with MemoryDiagnoser, the memory checkpoints on the 1,000-page journal, the remote run, the site. Proved by sheets whose text pdftotext reads in imposition order; CorpusToolTests for each verb, the AOT binary agreeing with the API byte for byte; the budget tests; a green Remote corpus run recorded in docs/status.md.

Tests required​

Unit — tests/AdCodicem.Pdf.Tests:

  • The wrapper: every row of its table, alone and combined (a page that pops two levels, ends inside BT and marked content, and changes the CTM at top level); a shared content stream staying shared; a page with no /Contents; an undecodable stream.
  • Resources: inherited, shared indirect, direct, absent; a name the content uses and the resources lack; a form XObject without resources; FsCheck — for any page's names and content tokens, the names chosen collide with none, and are the same on every run.
  • Placement: every anchor under every /Rotate, UserUnit, offset and inverted box; FsCheck — the mark's box, mapped to the displayed page, lies in the requested region.
  • Artifacts: subtypes by output version and position; nothing tagged with an MCID.
  • Conformance: each row of the table under each policy; the typed exception's content; the claim removal in XMP.
  • Signatures: certification at P=1, 2 and 3 refused; insisted; approval signatures stamped after; usage rights; dynamic XFA — over M04's model.
  • Templates: every field; dates in two cultures; a missing label.
  • Exhibits: number parsing, ordering (2.10 after 2.9, 2 before 2.1), formatting; pieces as documents and as ranges; separators.
  • Bates: padding, overflow in both modes, label ranges across 9→10, 99→100 and 999,999→1,000,000; the range map's JSON.
  • Update and removal: replace, add beside, remove one, remove all; a forged marker; a marker on a page M06 merged and M07 split.
  • Geometry: each annotation key; each destination kind; /Rotate inherited from a node; NoRotate; a shared appearance; boxes outside the MediaBox; fit, shrink-only and fill.
  • Imposition: grid orders, booklet padding and order, landscape cells, links carried, other annotations dropped.
  • Hostile: pages whose content the balance analysis cannot finish, resource trees with cycles, annotation arrays of absurd length, destinations naming no page — each ends in a report entry inside the time and allocation budgets.
  • Determinism: FsCheck over random plans on corpus documents — two saves, identical bytes.

Integration — tests/AdCodicem.Pdf.IntegrationTests, every referee in a container (ADR 27):

  • qpdf — --check on every output; --json for boxes, pages and labels; --flatten-rotation as a second implementation.
  • pikepdf — the objects that differ between revisions; structure-tree walks before and after; content parsing.
  • poppler — pdftotext page by page; pdfsig on signed inputs.
  • PyMuPDF — word and link positions, the text trace's drawing order.
  • MuPDF (mutool draw) — rendered pages compared before and after, within the cross-engine threshold, defined here because M09 is the first milestone to compare renderings, fixed by slice 11 and recorded under that name in status.md: at 150 dpi, a pixel differs when a channel differs by more than 24 of 255 and no pixel within one pixel of it in the other raster matches it, which absorbs anti-aliased edges; two renderings agree when at most 1 % of the pixels of the region compared differ and no 8-connected region of differing pixels exceeds 144 pixels, a 2 mm square, which catches one moved word that a percentage lets through. Images are compared in the referee container with Pillow and NumPy. M11, M24 and M25 use it as it stands; M12 adds a tighter regression threshold for our own approved images, and M25 one for scans.
  • pdf.js, pinned pdfjs-dist under Node — page labels and destinations as a viewer resolves them.
  • veraPDF — every PDF/A and PDF/UA-1 verdict before and after.
  • pyHanko — signature coverage and the modification level after stamping signed and certified documents.

Acceptance conditions​

"Every committed document" means every document under documents/ and vendor/ the reader opens and M03 can save: the 18 encrypted ones are asserted refused until M16, the certified one is asserted refused by its own row.

DocumentsBehaviorVerified by
Every committed document, a text mark on every page, saved as an incremental updateThe input is a byte-identical prefix of the output; the appended revision redefines exactly the stamped pages and adds only new objects — M04's revision comparison and pikepdf list the same set, and nothing elseCorpusStampTests.Stamping_changes_only_the_stamped_pages
The same documents, saved as a full rewriteM03's graph comparer finds every object equal but the stamped pages' /Contents and /Resources and the objects we added; every original content stream's encoded bytes identicalCorpusStampTests.A_rewritten_stamped_document_keeps_every_other_object
Every committed document with extractable textOn every page, the words pdftotext and PyMuPDF extract are the original page's words plus the mark's, as multisets; every textContains string still foundCorpusStampTests.Extraction_finds_the_page_and_the_stamp_and_nothing_else, PopplerStampRefereeTests, PyMuPdfStampRefereeTests
The awkward pages: documents/invoice/chromium-invoice-fr.pdf and documents/report/chromium-report-fr.pdf (a top-level flip), vendor/node-signpdf/pdfkit-node-signpdf-unsigned-placeholder.pdf (an open q), vendor/pdf-association/handwritten-inline-image-abbreviations.pdf (unbalanced-q-Q), vendor/opf-format-corpus/distiller4-congress-hr1904-enrolled-bill.pdf and pdfmaker707-word-law-library-iraq-legal-history.pdf (damaged pages ending inside BT, and inside marked content), vendor/us-federal/distiller3-mac-msha-crusher-dust-card-1997.pdf (eight streams on a page), vendor/opf-format-corpus/groff-distiller405-mac-usgs-gps-noise-spectra.pdf (twelve pages of several streams)The mark lands upright, at its size, where it was asked; MuPDF renders it; the report names each compensationCorpusStampTests.A_stamp_survives_whatever_the_page_leaves_behind, MuPdfRenderRefereeTests
The geometry the corpus has: rotated pages (pdfmaker7-powerpoint-va-cancer-database-course.pdf, 53 at 90°; distiller7-pscript5-census-housing-units-2005.pdf; one page of 56 in distiller952-pscript5-kb-pdf-risk-inventory.pdf; vendor/us-federal/docusign-pdfkit-gsa-sf30-contract-modification.pdf at 270°), landscape MediaBoxes (print-to-pdf-excel-dod-fcf-rates-2021.pdf, ibooks-author11-quartz-lorem-ipsum.pdf), a negative origin with a CropBox (xerox-workcentre-5755-ocr-hud-fonsi-mrc.pdf), a UserUnit (handwritten-compacted-syntax.pdf), two page sizes in one scan (acrobat3-import-irs-1040-1988-scan.pdf)A mark anchored top right, 10 mm in, is there as the reader sees the page: PyMuPDF's word boxes for it, through the page's rotation matrix, lie within half a point of the computed positionCorpusStampTests.A_stamp_lands_where_the_reader_sees_it, PyMuPdfStampRefereeTests
Every committed document whose PDF/A claim veraPDF upholds — the veraPDF fixtures (pdfa1b-annotations-pass, pdfa2b-content-pass, pdfa3b-embedded-pass, pdfa4-metadata-pass), OpenOffice.org 3.2's and Acrobat's PDF/A-1 (openoffice32-*, pdfmaker9-word-distiller-pdfa1b-test-document, the three acrobat11-image-conversion-*), the EU publications (pdflib-oj-exchange-rates-greek, antenna-house-oj-exchange-rates-2019, 3heights-eu-consolidated-regulation-2026), the invoices (mustang-zugferd*, itext-pdfbox-weclapp-facturx-en16931-invoice, gnuaccounting-mustang10-zugferd-rc-invoice, fop-factur-x-visualization, aspose-d365-facturx-extended-invoice, pdflib-pps-kraxi-pdfa2a-pdfua1-invoice), bfo-pdfa2b-embedded-pdfWith the default options — exhibit stamp, footer, Bates number — veraPDF upholds the same claim, in the version M03 keeps for an update (the document's own: 2.0 for the PDF/A-4 fixture)VeraPdfStampRefereeTests.Default_marks_keep_every_pdfa_claim
The same documents with an option their claim forbids: a standard 14 font; opacity on the PDF/A-1 ones; an RGB color on vendor/eu-publications/distiller10-eu-consolidated-regulation-2015.pdf, the one CMYK output intentRefused, typed, naming the option and the clause; under RemoveClaim, written, the claim gone from the XMP and stamp.conformance-claim-removed reportedCorpusStampTests.A_mark_never_breaks_a_claim_in_silence
The documents whose claim veraPDF rejects (pdfa1b-forms-fail, pdfa2b-actions-fail, pdfa3b-embedded-fail, distiller10-eu-consolidated-regulation-2015, imagemagick-false-pdfa1b-jpx, pypdf2-facturx-python-false-pdfa3b)No rule veraPDF did not already fail fails after stampingVeraPdfStampRefereeTests.Default_marks_add_no_failure
Every committed tagged document (a /StructTreeRoot: 66, and seven more encrypted until M16), and the three PDF/UA-1 claims veraPDF upholds — pdflib-pps-kraxi-pdfa2a-pdfua1-invoice.pdf, indesign13-pdfua1-german-book-chapter.pdf, indesign15-pdfua1-form.pdfEvery mark inside a Pagination artifact; the structure tree, parent tree and every MCID unchanged (pikepdf); veraPDF's PDF/UA-1 profile reports no failure it did not report before, and the three claims stay upheldVeraPdfStampRefereeTests.Tagged_documents_stay_as_accessible_as_they_were
vendor/us-federal/itext-govinfo-us-code-certified.pdf (DocMDP P=1); 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.pdfRefused with PdfSignatureInvalidationException naming the certification; insisted, an incremental update that leaves the certified revision intact, write.signature-invalidated reported, and pyHanko calls the change one the certification does not permitCorpusStampTests.Certified_documents_are_refused_unless_the_caller_insists, PyHankoStampRefereeTests
The approval-signed committed documents: pyhanko/acrobat-reader-signed-twice.pdf, fop-dictao-dila-signed-joafe-notice.pdf, antenna-house-legilux-memorial-pades-lta.pdf, fop22-legilux-memorial-seal-renewed-timestamps.pdf, skia-chrome74-node-signpdf-reason-contains-trailer.pdf, docusign-pdfkit-gsa-sf30-contract-modification.pdfStamped in an incremental update; pyHanko reports each signature covering exactly its revision, pdfsig calls valid what it called valid; stamp.after-signature names each; a full rewrite refusedCorpusStampTests.Signed_documents_are_stamped_after_their_signatures, PyHankoStampRefereeTests, PdfsigRefereeTests
Usage rights in vendor/uk-ogl/indesign-acrobat-hmcts-n208-form.pdf, vendor/us-federal/livecycle-irs-1040-2022-xfa-ur3.pdf, vendor/fr-licence-ouverte/livecycle-es9-cerfa-14880-xfa-form.pdf; remote dynamic XFA, remote/canada/livecycle-es9-cfia-fish-export-license-dynamic-xfa.pdf and the certified livecycle-es10-ircc-imm1344-certified-dynamic-xfa.pdf — both AES-128 with an empty user password, so refused by M03's save until M16 lifts the refusal, and stamp.dynamic-xfa proven meanwhile on a unit-test document with /NeedsRendering truestamp.usage-rights-broken reported; the two dynamic forms refused with PdfEncryptedException naming M16 — once M16 opens them, stamp.dynamic-xfa reported and the certified one refusedCorpusStampTests.Usage_rights_and_dynamic_forms_are_reported
A case file of pieces: 1 chromium-contract-fr.pdf, 2 word2019-ccs-contract-schedule.pdf, 2.1 xerox-workcentre-5335-ocr-hud-fonsi-linearized.pdf, 2.2 libreoffice-invoice-fr.pdf, 3 indesign13-pdfua1-german-book-chapter.pdf, 4 pdfmaker7-powerpoint-va-cancer-database-course.pdf — as six documents, then as one volume assembled by M06pdftotext finds "Pièce n° 2.1" and the firm on each piece's first page, "p. k/n" on every page with n its piece's count; a separator page before each piece, titled; qpdf counts 84 pages plus six separators; the volume's outline still resolves in pdf.js; the chapter's PDF/UA-1 claim still upheldCorpusExhibitTests.Every_piece_carries_its_number_and_its_pages
The Bates set: the case file above, distiller4-congress-hr1904-enrolled-bill.pdf, pdfmaker707-word-law-library-iraq-legal-history.pdf, acrobat3-import-irs-1040-1988-scan.pdf, distiller3-mac-msha-crusher-dust-card-1997.pdf, handwritten-compacted-syntax.pdf, pdfkit-node-signpdf-unsigned-placeholder.pdfNumbers ABC000001 onwards run without a gap or repeat across the set as pdftotext reads them, page after page; the range map's ranges match the manifest's page counts; mirrored labels read the same in pdf.js and qpdfCorpusBatesTests.Bates_numbers_run_across_the_set
The same set, Bates numbers then removedEvery page's /Contents and /Resources values are the original's, every content stream byte-identical; after a full rewrite, the graph equals the unstamped rewrite's; renumbering from another start leaves no trace of the first numbers in extractionCorpusBatesTests.Removing_bates_numbers_restores_every_content_stream
Any committed document, stamped twice with the same id and the same optionsIdentical bytes to stamping onceCorpusStampTests.Stamping_again_updates_rather_than_stacks
Marks by others: vendor/uk-ogl/pdfmaker21-ozev-sample-invoice.pdf (Acrobat's watermark in a layer), documents/invoice/word-invoice-fr.pdf and word2019-ccs-contract-schedule.pdf (Word's own pagination artifacts); remote remote/opf-format-corpus/jhove-hul-28-dvipdfm-pdfstamp-physics-article.pdf (PDFStamp) and remote/us-states/powerbi-pdfium-acrobat-covid-dashboard-nine-updates.pdf (Acrobat's headers and footers)Our marks added then removed: extraction equals the original's, their marks includedCorpusStampTests.Removing_our_marks_leaves_everyone_elses
vendor/opf-format-corpus/word9-distiller405-usgs-nwql-volatile-organics-methods.pdf (labels from index 7: lower roman, then decimal) and distiller952-pscript5-kb-pdf-risk-inventory.pdfA footer {label} — {page}/{pages} shows on each page the label pdf.js reportsCorpusHeaderFooterTests.Footers_show_the_labels_viewers_show
A letterhead page (not in the corpus, below) under documents/invoice/libreoffice-invoice-fr.pdf and chromium-invoice-fr.pdf; a page of documents/report/chromium-report-fr.pdf over reportlab-invoice.pdfPyMuPDF's text trace draws the underlay first and the overlay last; pdftotext extracts bothCorpusOverlayTests.Underlays_are_drawn_first_and_overlays_last
Fit to A4: the Letter documents (finereader8-frb-sr0115-examiner-guidance.pdf with its links, pdfmaker707-word-va-esig-developer-guide.pdf with 80 internal links), ibooks-author11-quartz-lorem-ipsum.pdf, the dust card, the mixed-size scan, handwritten-compacted-syntax.pdf (UserUnit), xerox-workcentre-5755-ocr-hud-fonsi-mrc.pdf (negative origin), reader10-openoffice32-annotated-object-streams.pdf (a highlight and a note with its popup)Every MediaBox and CropBox is A4 (qpdf); every word and link rectangle moved by the computed matrix within half a point (PyMuPDF); every link and outline item lands on the same point of its page (pdf.js); the highlight's quadrilaterals still cover their wordsCorpusNormalizeTests.Fitting_to_a4_moves_text_and_links_together
Flattening /Rotate on the four rotated committed documents above, and the rotated page with links, markup and a field (not in the corpus, below); remote remote/ocrmypdf/epson-scan-indirect-rotate.pdf (an indirect /Rotate in a nested tree), remote/eu-dss/dss-signed-widget-self-parent-startxref-past-eof.pdf, remote/maine-legislature/ricoh-docusign-itextsharp-state-contract-amendment.pdf/Rotate 0 on every page; MuPDF renders each page as before, and as qpdf's --flatten-rotation output, within the threshold; link and widget positions agree in PyMuPDF; signed inputs flattened after their signaturesCorpusNormalizeTests.Flattening_rotation_changes_no_pixel, MuPdfRenderRefereeTests
Two-up documents/report/chromium-report-fr.pdf (4 pages, internal links), four-up distiller952-pscript5-kb-pdf-risk-inventory.pdf (56 pages, one rotated), a booklet of indesign13-pdfua1-german-book-chapter.pdf (21 pages, tagged)Two and fourteen sheets, and a booklet of twelve two-up pages — six sheets printed on both sides — padded with three blank pages; pdftotext reads each sheet's cells in imposition order; the report's links land on the right sheet; the chapter's structure and PDF/UA claim reported droppedCorpusImpositionTests.Sheets_hold_their_pages_in_order
documents/stress/reportlab-journal-1000-pages.pdf, a footer and Bates numbers on every page; remote remote/govinfo/us-code-2023-title42.pdf (9,302 pages, signed, so an incremental update)Retained memory flat between the 10 % and 100 % checkpoints, under a budget set from the first measurement and recorded in docs/status.md; one font subset and one static XObject per mark; throughput within a guard set at three times the measured timeCorpusBatesTests.Numbering_the_largest_documents_holds_memory_flat, StampBenchmarks
The 18 encrypted committed documentsRefused by M03's PdfEncryptedException, naming M16; nothing writtenCorpusStampTests.Encrypted_documents_are_refused_until_m16
Every committed document, through the toolstamp, bates, normalize and impose give what the API gives, byte for byte, as a dotnet tool and as the AOT binary; refusals exit with 4CorpusToolTests.Stamp_matches_the_api, CorpusToolTests.Bates_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​

  • Content that fights back, found by M08's balance analysis over every committed page: top-level cm in every Chromium and ReportLab page (Chromium's is a flip and a 0.24 scale); an open q in PDFKit's page; pages ending inside BT in distiller4-congress-hr1904-enrolled-bill.pdf (page 11) and inside BT and marked content in pdfmaker707-word-law-library-iraq-legal-history.pdf (three pages); unbalanced-q-Q in a hand-written file; multiple-content-streams in some thirty documents, eight streams on one page of the dust card and twelve pages of several in the groff report; remote, graphics-state-split-across-streams and pages with no /Contents (page-without-contents).
  • Geometry: 73 committed documents in Letter, 63 in A4, and 1024 × 748, 792 × 612, 842 × 595, 581 × 294, 500 × 500, 900 × 900, 999 × 999, 264 × 612, 648 × 864, 300 × 144 and 2717 × 3701 points besides; /Rotate in four committed documents (90° and 270°), an indirect /Rotate and an inverted MediaBox remote; cropbox, cropbox-differs-from-mediabox, mediabox-negative-origin, user-unit, bleedbox-trimbox, trimbox-bleedbox-artbox, fractional-a4-mediabox, landscape-mediabox; huge single pages remote (the USGS map, a 35,000-pixel scan).
  • Resources: a root /Resources every page overrides (LibreOffice), forty pages sharing one dictionary (fop22-legilux-memorial-seal-renewed-timestamps.pdf), shared-resources in the 1000-page journal, missing-resources.
  • Claims to keep: 30 committed PDF/A claims — 22 upheld by veraPDF, six rejected, two without a verdict —, one CMYK output intent, 73 tagged documents (seven of them encrypted), three PDF/UA-1 claims veraPDF upholds; remote, a PDF/A-4f invoice in PDF 2.0 and the AbleDocs PDF/UA-1 textbook scan, whose page images are already Pagination artifacts (page-image-as-pagination-artifact).
  • Signatures: a DocMDP P=1 certification committed and three more remote; six approval-signed committed documents; usage rights in five (two encrypted); dynamic XFA remote, and encrypted.
  • Other tools' marks: Word's Pagination artifacts, Acrobat's watermark layer (acrobat-watermark-ocg), remote PDFStamp and Acrobat headers and footers (acrobat-headers-footers).
  • Labels: 32 committed documents with a /PageLabels tree (M06's count), among them the USGS report starting at index 7.
  • Scale: the 1000-page journal committed; the 9,302-page United States Code remote.

What it lacks​

NeedWhyPriorityLikely source
A rotated page carrying internal links, a markup annotation with its popup and a form fieldThe roadmap's "flatten /Rotate with annotations and links transformed alike" has no committed page that is rotated and annotated: the rotated documents carry nothing but DocuSign's signed widget1Generated here: derived with qpdf's --rotate from chromium-report-fr.pdf (links), reader10-openoffice32-annotated-object-streams.pdf (note, popup, highlight) and reportlab-subscription-form.pdf (fields), the transformation recorded in build_corpus.py
A letterhead page to underlay: logo, address block, a colored bandThe overlay and underlay acceptance needs the business case it exists for; no corpus document is a letterhead alone1Generated here: LibreOffice and Chromium from our own source, one page, with an embedded OFL face
A document whose pages share one content streamLegal and a trap for anything that edits content in place; the wrapper must keep it shared, and no committed document has it2Generated here: derived with pikepdf from a committed document with identical pages, recorded
A PDF/A document veraPDF upholds with a CMYK output intentThe one committed CMYK intent is on a claim veraPDF rejects, so the refusal of RGB marks is tested on a document that was already non-conforming2A public source: print-ready government or EU publications archived as PDF/A with a CMYK intent; failing that, generated here should Ghostscript join the container's producers (-dPDFA=2 with ECI's freely redistributable CMYK profile)
A tagged PDF 2.0 document, ideally PDF/UA-2The PageNum and Bates artifact subtypes exist only in 2.0 output; no committed tagged document is 2.02A public source: the PDF Association's PDF/UA-2 and Well-Tagged PDF examples, remote if their license forbids committing
A case-file exhibit stamped by the tools French lawyers use (Kleos, Hub-Avocat, Acrobat's stamp tool)The conventions of position, wording and per-piece numbering are copied from the tools' documentation, not from their output; M18's presets want the real thing3A contribution, anonymized
A document Bates-numbered by AcrobatAcrobat marks its Bates numbers with its own /PieceInfo and artifacts; our removal must leave them, and the remote Power BI file has only headers and footers3A contribution
A large-format page (A1, A0) from a CAD or GIS producer, committedFit to A4 meets huge pages only in the remote corpus (the USGS map, Aspose.CAD's drawing)3A public source under an attribution license, under 2 MB

Traps​

  • Chromium's pages never restore their opening cm. A mark appended without q…Q draws at a quarter of its size, upside down, at the bottom of the page — the first defect every naive stamper ships.
  • Unbalanced content is real. One Q too many pops the wrapper's q; an open BT makes the wrapper's q illegal; an open marked-content sequence swallows the stamp into someone else's tag. The analysis is cheap; its absence is visible.
  • /Rotate is inherited. Deleting a page's /Rotate does not unrotate it when a node above says 90; write /Rotate 0.
  • Shared resources are shared. Adding a name to the dictionary forty pages use gives all forty the name — and, with a per-page Bates XObject, the wrong number on thirty-nine of them. Each stamped page gets its own dictionary.
  • A name the content uses but the resources lack is a broken page, and a trap for collision checks that look only at the resources: the mark would give the content something to draw.
  • Where the reader sees top right is not where default user space has it. On a rotated page, on a page whose origin is negative, on a page with a UserUnit, a mark placed in raw coordinates lands elsewhere, sideways, or twice its size.
  • An underlay can be invisible. Scans are opaque images; some producers paint a white rectangle first. The library cannot know before M15; the caller chooses, and overlays are the default.
  • Existing headers and footers are where ours want to go. Word, Acrobat and InDesign already put text in the margins; M09 does not detect it (M15 could), so the margins and anchors are the caller's to set.
  • A stamp after a signature is a change after a signature. It is legal, and it is exactly what an incremental saving attack looks like to a validator; reporting it is honesty, not noise. Signing after stamping is the answer, and M26's.
  • Usage rights break silently in Reader. Acrobat Reader warns only when the user opens the form; the report must say it first.
  • PDF/A-1 forbids transparency outright; a 30 % gray "COPIE" watermark is a conformance loss there, and an opaque light gray behind the content is not.
  • Bates numbers are text with zeros. Page labels cannot pad; a label range that carries the zeros in its prefix must restart where the number gains a digit.
  • Appearance streams follow /Rect, not the page. Scaling is free; rotation is not — the viewer turned the appearance with the page before, and nothing turns it once /Rotate is gone.
  • A second-class name is a promise. The developer prefix must be registered before a stable release writes it into documents that will outlive the library's current version.
  • French wording is data. "Pièce n°" with a non-breaking space, a date format, a court's preference — M18 keeps them as presets; hard-coding them here would make the next jurisdiction a code change.

Documentation​

  • docs/website/docs/concepts/content-on-existing-pages.md (new): add around, never rewrite; the wrapper; what a mark changes and what it never touches; update and removal.
  • docs/website/docs/guides/stamps-and-watermarks.md (new): stamps, watermarks, overlays, headers and footers, placement in the displayed orientation, conformance and signed inputs.
  • docs/website/docs/guides/exhibits-and-bates-numbers.md (new): pieces, exhibit stamps, separator pages, Bates numbering and its range map — the case-file workflow before M18.
  • docs/website/docs/guides/page-geometry.md (new): boxes, crop, fit to A4, flattening rotation, N-up and booklets, and what is transformed.
  • docs/website/docs/reference/stamp-report-codes.md (new): every stamp.* and geometry.* code.
  • docs/website/docs/reference/tool/index.md: the four verbs.
  • docs/website/docs/introduction.md: stamping added to what the library does.
  • docs/architecture.md: the plan applied at save for page transformations; the developer prefix.
  • docs/corpus.md: the derived documents this milestone adds, and the referees it brings (MuPDF, PyMuPDF).
  • docs/features/features.json: the stamps and exhibits entries brought to their state.
  • docs/status.md: the measurements.

Exit criteria​

  • Marks are added by prefix and suffix streams only; no original content stream is rewritten, and the balance analysis drives the wrapper.
  • Resources are added without collision, on a dictionary of the page's own when the original is inherited or shared.
  • Stamps, watermarks, overlays, underlays, headers, footers, exhibit stamps and Bates numbers exist, placed in the displayed orientation.
  • Every mark is a Pagination artifact with the subtype its output version allows; tagged inputs keep their structure untouched.
  • Conformance claims are respected, refused with a typed exception, or removed and reported; never broken in silence.
  • Certified documents are refused unless the caller insists; approval-signed documents are stamped after their signatures and reported.
  • Our marks are recognized, updated without stacking, and removed to the original /Contents and /Resources.
  • Boxes, crop, fit to A4, origin and user-unit normalization, rotation flattening and imposition exist, with annotations, destinations and widgets transformed alike.
  • The developer prefix is registered, or its registration is recorded as a blocker of the first stable release.
  • The acceptance conditions above pass on the corpus, in CI, with no document skipped, and the remote rows on a green Remote corpus run recorded in status.md.
  • Unit tests cover each behavior, its degenerate and its hostile cases; FsCheck properties hold for names, placement and determinism.
  • Integration tests run qpdf, pikepdf, poppler, PyMuPDF, MuPDF, pdf.js, veraPDF and pyHanko in containers.
  • StampBenchmarks measures stamping, Bates numbering, fitting and imposition with MemoryDiagnoser; the memory budget on the 1000-page journal is enforced in CI and recorded in status.md.
  • The tool's stamp, bates, normalize and impose verbs ship with documented JSON.
  • The documentation site publishes the pages above; features.json and status.md are brought in line.
  • Every page of Documentation is written in its Diátaxis section, one mode per page (ADR 47).