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
/Contentsand/Resourcesto exactly what they were; - every mark written as an
/Artifactof 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
UserUnitnormalized,/Rotateflattened — 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,normalizeandimposeverbs.
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
/Stampannotation — 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
/PieceInfomarks, PDFStamp's,/Artifact /Watermarkcontent 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 content | The 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 decoded | The 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
/Rotategives it, at the sizeUserUnitgives 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
UserUnitof 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
/Resourcesis 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
/Resourcesof 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,FooterandWatermark; PDF 2.0 addsPageNumandBates. In 1.7 output a page number or a Bates number isHeaderorFooterby where it sits; in 2.0 output it isPageNumorBates. An exhibit stamp isHeaderorFooterby position, as ISO 32000-2 treats Bates numbers — navigation, not content./Subtypeis written only from PDF 1.7, and never raises the version. - Artifacts carry no MCID, so the structure tree, the parent tree,
/StructParentsand/Tabsare untouched. A separator page added to a tagged volume carries its text as aPaginationartifact as well; its title reaches assistive technology through the bookmark M18 gives the piece. - Word already writes its own headers as
Paginationartifacts, 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.
| Target | What the mark must respect | By default |
|---|---|---|
| Every PDF/A part | Fonts 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 part | Device color only in the family of the output intent's profile; DeviceGray where an output intent or a DefaultGray exists | Gray 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-1 | No transparency: CA and ca 1, no soft mask, blend mode Normal | A watermark with opacity is refused; the caller may choose an underlay or a lighter color |
| PDF/A-2, 3 and 4 | Transparency allowed; a page with transparency in a document without an output intent needs a page group with /CS | Added when needed |
| PDF/A-2 and later, PDF/UA | No reference to .notdef (M08 draws a box instead) | — |
| PDF/UA-1 | Everything drawn is real content or an artifact | Every 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 withPdfSignatureInvalidationException, naming the signature and its permission. WithAllowInvalidatingSignaturesit 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.Refuseis 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
digitswidens and is reported (stamp.bates-widened), or stops the run underRefuse. - Mirrored page labels express
ABC000123exactly: a decimal page-label range cannot pad, so the prefix carries the zeros, and a new range starts where the number gains a digit (ABC00000for 1–9,ABC0000for 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
ADCPuntil 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/Contentsand/Resourcesvalues — 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
/Contentsand/Resourcesfrom 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
cmin our prefix: the page's content is untouched. The new MediaBox is the target size at the origin; the CropBox becomes it;TrimBox,BleedBoxandArtBoxare transformed;UserUnitis 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;/RDand 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/BBoxof layout attributes in the structure tree. What is not understood —/VPmeasure 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
/BBoxonto 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
cmin our prefix; every box is rotated, width and height exchanged for 90° and 270°; and the page gets/Rotate 0written explicitly —/Rotateis 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 /Radjusted, so a regenerated appearance agrees; an annotation with theNoRotateflag 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
/BBoxis its visible box and whose/Matrixundoes 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 thestamp.andgeometry.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.
| Code | Severity | Meaning |
|---|---|---|
stamp.content-balanced | Information | The page's content popped below its start or left q open; the wrapper compensated |
stamp.content-closed | Information | The page's content ended inside a text object or marked content; closed before the marks |
stamp.content-unverified | Warning | The page's content could not be decoded; the plain wrapper was used |
stamp.after-signature | Warning | The marks lie outside every approval signature; a validator will show a later change |
stamp.usage-rights-broken | Warning | Usage rights no longer hold after this change |
stamp.dynamic-xfa | Warning | The page is a dynamic XFA placeholder; viewers rendering the XFA will not show the mark |
stamp.conformance-claim-removed | ConformanceLoss | A claim the marks break was removed, as the caller's policy asked |
stamp.bates-widened | Warning | A Bates number needed more digits than asked |
stamp.page-labels-replaced | Information | Page labels mirroring Bates numbers replaced the document's own |
stamp.foreign-mark | Warning | A marker that does not match our structure; left untouched |
geometry.box-clipped | Warning | A page box fell outside the MediaBox and was clipped |
geometry.annotation-not-transformed | Warning | A key holding coordinates we do not transform |
geometry.appearance-copied | Information | A shared appearance stream copied before being rotated |
geometry.contents-joined | Information | A page's several streams joined to become a form XObject |
geometry.annotations-dropped | Warning | Annotations not carried onto imposed sheets |
geometry.structure-dropped | ConformanceLoss or Warning | Logical 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.
- 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, andpdftotextand PyMuPDF find the original words plus the mark's on every page. Leaves placement. - Placement and stamps. Delivers
PdfMarkPositionin the displayed orientation,PdfStampwith 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. - Artifacts and conformance. Delivers the
/Artifact /Paginationmarking 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. - 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'spdfsigstill calls valid what it called valid. Leaves templates. - 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:pdftotextfinds on each page the footer pdf.js's page labels predict. Leaves overlays. - 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.
- Exhibit stamps. Delivers
PdfExhibitNumberwith 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:Parseis 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. - 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 aspdftotextreads it; pdf.js and qpdf reading the mirrored labels as the stamped numbers. Leaves updates. - 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
/Contentsand/Resourcesvalues 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. - 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--jsonpage 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. - Flattening rotation. Delivers
FlattenRotationwith appearances,/MK /R,NoRotate, explicit/Rotate 0, and the cross-engine threshold, recorded instatus.md. Proved by MuPDF rendering each rotated corpus page before and after, and qpdf's--flatten-rotationoutput, to the same pixels within the threshold; widget and link positions agreeing in PyMuPDF. Leaves imposition. - Imposition, the tool, budgets. Delivers
PdfImposerwith grids and booklets, the four verbs,StampBenchmarkswithMemoryDiagnoser, the memory checkpoints on the 1,000-page journal, the remote run, the site. Proved by sheets whose textpdftotextreads in imposition order;CorpusToolTestsfor each verb, the AOT binary agreeing with the API byte for byte; the budget tests; a greenRemote corpusrun recorded indocs/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
BTand 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;
/Rotateinherited 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 —
--checkon every output;--jsonfor boxes, pages and labels;--flatten-rotationas a second implementation. - pikepdf — the objects that differ between revisions; structure-tree walks before and after; content parsing.
- poppler —
pdftotextpage by page;pdfsigon 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 instatus.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-distunder 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.
| Documents | Behavior | Verified by |
|---|---|---|
| Every committed document, a text mark on every page, saved as an incremental update | The 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 else | CorpusStampTests.Stamping_changes_only_the_stamped_pages |
| The same documents, saved as a full rewrite | M03'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 identical | CorpusStampTests.A_rewritten_stamped_document_keeps_every_other_object |
| Every committed document with extractable text | On every page, the words pdftotext and PyMuPDF extract are the original page's words plus the mark's, as multisets; every textContains string still found | CorpusStampTests.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 compensation | CorpusStampTests.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 position | CorpusStampTests.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-pdf | With 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 intent | Refused, typed, naming the option and the clause; under RemoveClaim, written, the claim gone from the XMP and stamp.conformance-claim-removed reported | CorpusStampTests.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 stamping | VeraPdfStampRefereeTests.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.pdf | Every 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 upheld | VeraPdfStampRefereeTests.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.pdf | Refused 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 permit | CorpusStampTests.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.pdf | Stamped 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 refused | CorpusStampTests.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 true | stamp.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 refused | CorpusStampTests.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 M06 | pdftotext 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 upheld | CorpusExhibitTests.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.pdf | Numbers 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 qpdf | CorpusBatesTests.Bates_numbers_run_across_the_set |
| The same set, Bates numbers then removed | Every 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 extraction | CorpusBatesTests.Removing_bates_numbers_restores_every_content_stream |
| Any committed document, stamped twice with the same id and the same options | Identical bytes to stamping once | CorpusStampTests.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 included | CorpusStampTests.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.pdf | A footer {label} — {page}/{pages} shows on each page the label pdf.js reports | CorpusHeaderFooterTests.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.pdf | PyMuPDF's text trace draws the underlay first and the overlay last; pdftotext extracts both | CorpusOverlayTests.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 words | CorpusNormalizeTests.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 signatures | CorpusNormalizeTests.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 dropped | CorpusImpositionTests.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 time | CorpusBatesTests.Numbering_the_largest_documents_holds_memory_flat, StampBenchmarks |
| The 18 encrypted committed documents | Refused by M03's PdfEncryptedException, naming M16; nothing written | CorpusStampTests.Encrypted_documents_are_refused_until_m16 |
| Every committed document, through the tool | stamp, bates, normalize and impose give what the API gives, byte for byte, as a dotnet tool and as the AOT binary; refusals exit with 4 | CorpusToolTests.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
cmin every Chromium and ReportLab page (Chromium's is a flip and a 0.24 scale); an openqin PDFKit's page; pages ending insideBTindistiller4-congress-hr1904-enrolled-bill.pdf(page 11) and insideBTand marked content inpdfmaker707-word-law-library-iraq-legal-history.pdf(three pages);unbalanced-q-Qin a hand-written file;multiple-content-streamsin 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-streamsand 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;
/Rotatein four committed documents (90° and 270°), an indirect/Rotateand 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
/Resourcesevery page overrides (LibreOffice), forty pages sharing one dictionary (fop22-legilux-memorial-seal-renewed-timestamps.pdf),shared-resourcesin 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
Paginationartifacts (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
Paginationartifacts, Acrobat's watermark layer (acrobat-watermark-ocg), remote PDFStamp and Acrobat headers and footers (acrobat-headers-footers). - Labels: 32 committed documents with a
/PageLabelstree (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
| Need | Why | Priority | Likely source |
|---|---|---|---|
| A rotated page carrying internal links, a markup annotation with its popup and a form field | The 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 widget | 1 | Generated 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 band | The overlay and underlay acceptance needs the business case it exists for; no corpus document is a letterhead alone | 1 | Generated here: LibreOffice and Chromium from our own source, one page, with an embedded OFL face |
| A document whose pages share one content stream | Legal and a trap for anything that edits content in place; the wrapper must keep it shared, and no committed document has it | 2 | Generated here: derived with pikepdf from a committed document with identical pages, recorded |
| A PDF/A document veraPDF upholds with a CMYK output intent | The 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-conforming | 2 | A 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-2 | The PageNum and Bates artifact subtypes exist only in 2.0 output; no committed tagged document is 2.0 | 2 | A 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 thing | 3 | A contribution, anonymized |
| A document Bates-numbered by Acrobat | Acrobat 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 footers | 3 | A contribution |
| A large-format page (A1, A0) from a CAD or GIS producer, committed | Fit to A4 meets huge pages only in the remote corpus (the USGS map, Aspose.CAD's drawing) | 3 | A public source under an attribution license, under 2 MB |
Traps
- Chromium's pages never restore their opening
cm. A mark appended withoutq…Qdraws 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
Qtoo many pops the wrapper'sq; an openBTmakes the wrapper'sqillegal; an open marked-content sequence swallows the stamp into someone else's tag. The analysis is cheap; its absence is visible. /Rotateis inherited. Deleting a page's/Rotatedoes 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/Rotateis 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): everystamp.*andgeometry.*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: thestampsandexhibitsentries 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
Paginationartifact 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
/Contentsand/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 corpusrun recorded instatus.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.
-
StampBenchmarksmeasures stamping, Bates numbering, fitting and imposition withMemoryDiagnoser; the memory budget on the 1000-page journal is enforced in CI and recorded instatus.md. - The tool's
stamp,bates,normalizeandimposeverbs ship with documented JSON. - The documentation site publishes the pages above;
features.jsonandstatus.mdare brought in line. - Every page of Documentation is written in its Diátaxis section, one mode per page (ADR 47).