Reading page labels and /PieceInfo from AlvaStamp PDFs
Acrobat shows page labels. /PieceInfo is private page data — here are Python and Node scripts that print both.
AlvaStamp can write two kinds of page-level metadata when you stamp: page labels, which viewers already know how to display, and /PieceInfo, which they do not. This post explains the difference and lists the two small scripts we use to read both back out of a stamped PDF.
Summary
Problem. A visual Bates number or “CONFIDENTIAL” watermark is easy to check: open the file. Page labels are almost as easy — Acrobat, Preview, and most browsers show them in the page-number box. /PieceInfo is different. It is private, per-page application data in the PDF. No mainstream viewer prints it in File → Properties, and searching the rendered page will not find it. If you stamped a Bates string or a filing code into /PieceInfo for a document-management system, you need a script (or a PDF library) to confirm it is actually there.
Solution. Two equivalent readers, one in Python (pypdf) and one in Node (pdf-lib):
- Walk every page.
- Print the page label from the catalog
/PageLabelstree. - Print each /PieceInfo entry: application name,
/LastModified, and/Private.
Copy either script, install its one dependency, and point it at a stamped file.
Why page labels show up
Page labels live in the document catalog (/PageLabels). They replace the default 1, 2, 3… numbering in the viewer chrome — the same place you type a page number to jump. AlvaStamp writes a prefix-only label (no decimal style), so a Bates token such as {{bates_full}} becomes the label you see, for example ARC-000001.
That is why the Archive intake sample profile can put the Bates series in Page label and you can verify it without extra tools.
Why /PieceInfo does not
/PieceInfo is a page-piece dictionary (ISO 32000 §14.5), keyed by application name. AlvaStamp writes:
| Key | Meaning |
|---|---|
| Application name | Default AlvaStamp; you can set Name on the metadata mark |
/LastModified | PDF date string (D:YYYYMMDDHHmm00) |
/Private | The expanded stamp text (plain string, including variables such as {{bates_full}}) |
Existing entries from other applications are kept. Viewers are not required to display any of this. Adobe’s own apps use page-piece dictionaries internally (Illustrator, InDesign, and similar); they are a hiding place on purpose. File → Properties shows document Info (title, author, subject) — a different dictionary — not per-page /PieceInfo.
So if a downstream workflow is supposed to read the Bates code or a file-room token from /PieceInfo rather than OCR the page, you cannot proof it by looking. You parse the PDF.
The scripts
Both scripts print the same report. Use Python if that is already on the machine; use Node if you already have pdf-lib from other PDF work. They live next to the site notes as read-page-info.py and read-page-info.mjs.
Python
pip install pypdf
python read-page-info.py document.pdf
#!/usr/bin/env python3
"""Read page labels and /PieceInfo from a PDF stamped by AlvaStamp.
Page labels appear in Acrobat and most browsers. /PieceInfo is private
page-level data and needs a script like this.
pip install pypdf
python read-page-info.py document.pdf
"""
from __future__ import annotations
import sys
from typing import Any
from pypdf import PdfReader
from pypdf.generic import DictionaryObject, IndirectObject
def deref(obj: Any) -> Any:
if isinstance(obj, IndirectObject):
return obj.get_object()
return obj
def as_dict(obj: Any) -> DictionaryObject | None:
obj = deref(obj)
return obj if isinstance(obj, DictionaryObject) else None
def as_text(obj: Any) -> str | None:
obj = deref(obj)
if obj is None:
return None
if isinstance(obj, DictionaryObject):
parts = []
for key, value in obj.items():
parts.append(f"{key}={as_text(value)}")
return "{" + ", ".join(parts) + "}"
text = str(obj)
return text[1:] if text.startswith("/") and len(text) > 1 else text
def piece_info_entries(page) -> list[tuple[str, str | None, str | None]]:
piece = as_dict(page.get("/PieceInfo"))
if piece is None:
return []
rows: list[tuple[str, str | None, str | None]] = []
for key, data in piece.items():
data = as_dict(data)
name = str(key)
if data is None:
rows.append((name, None, as_text(piece.get(key))))
continue
rows.append(
(
name,
as_text(data.get("/LastModified")),
as_text(data.get("/Private")),
)
)
return rows
def main() -> int:
if len(sys.argv) != 2:
print("Usage: python read-page-info.py document.pdf", file=sys.stderr)
return 2
path = sys.argv[1]
reader = PdfReader(path)
labels = reader.page_labels
for index, page in enumerate(reader.pages):
print(f"# page {index + 1}")
print(f"label: {labels[index]}")
entries = piece_info_entries(page)
if not entries:
print("PieceInfo: (none)")
for name, modified, private in entries:
print(f"PieceInfo {name}:")
if modified:
print(f" LastModified: {modified}")
print(f" Private: {private}")
print()
return 0
if __name__ == "__main__":
raise SystemExit(main())
Node
npm install pdf-lib
node read-page-info.mjs document.pdf
#!/usr/bin/env node
/**
* Read page labels and /PieceInfo from a PDF stamped by AlvaStamp.
*
* Page labels appear in Acrobat and most browsers. /PieceInfo is private
* page-level data and needs a script like this.
*
* npm install pdf-lib
* node read-page-info.mjs document.pdf
*/
import { readFile } from "node:fs/promises";
import {
PDFArray,
PDFDict,
PDFDocument,
PDFHexString,
PDFName,
PDFNumber,
PDFString,
} from "pdf-lib";
function asText(obj) {
if (!obj) return null;
if (obj instanceof PDFString || obj instanceof PDFHexString) return obj.decodeText();
if (obj instanceof PDFName) {
const name = obj.asString();
return name.startsWith("/") ? name.slice(1) : name;
}
if (obj instanceof PDFNumber) return String(obj.asNumber());
if (obj instanceof PDFDict) {
return (
"{" +
[...obj.keys()]
.map((key) => `${asText(key)}=${asText(obj.lookup(key))}`)
.join(", ") +
"}"
);
}
return String(obj);
}
function labelForPage(labels, pageIndex) {
if (!(labels instanceof PDFDict)) return String(pageIndex + 1);
const nums = labels.lookup(PDFName.of("Nums"));
if (!(nums instanceof PDFArray)) return String(pageIndex + 1);
let rangeStart = 0;
let dict = null;
for (let i = 0; i + 1 < nums.size(); i += 2) {
const startObj = nums.lookup(i);
const start = startObj instanceof PDFNumber ? startObj.asNumber() : NaN;
if (Number.isNaN(start) || start > pageIndex) break;
rangeStart = start;
dict = nums.lookup(i + 1);
}
if (!(dict instanceof PDFDict)) return String(pageIndex + 1);
const prefix = asText(dict.lookup(PDFName.of("P"))) ?? "";
const style = dict.lookup(PDFName.of("S"));
if (!style) return prefix || String(pageIndex + 1);
const st = dict.lookup(PDFName.of("St"));
const n = (st instanceof PDFNumber ? st.asNumber() : 1) + (pageIndex - rangeStart);
const kind = asText(style);
if (kind === "D") return `${prefix}${n}`;
return prefix || String(n);
}
function pieceInfoEntries(page) {
const piece = page.node.lookup(PDFName.of("PieceInfo"));
if (!(piece instanceof PDFDict)) return [];
return [...piece.keys()].map((key) => {
const data = piece.lookup(key);
if (!(data instanceof PDFDict)) {
return { name: asText(key), modified: null, private: asText(data) };
}
return {
name: asText(key),
modified: asText(data.lookup(PDFName.of("LastModified"))),
private: asText(data.lookup(PDFName.of("Private"))),
};
});
}
const path = process.argv[2];
if (!path) {
console.error("Usage: node read-page-info.mjs document.pdf");
process.exit(2);
}
const pdf = await PDFDocument.load(await readFile(path), {
updateMetadata: false,
});
const labels = pdf.catalog.lookup(PDFName.of("PageLabels"));
const pages = pdf.getPages();
for (let i = 0; i < pages.length; i++) {
console.log(`# page ${i + 1}`);
console.log(`label: ${labelForPage(labels, i)}`);
const entries = pieceInfoEntries(pages[i]);
if (entries.length === 0) {
console.log("PieceInfo: (none)");
}
for (const entry of entries) {
console.log(`PieceInfo /${entry.name}:`);
if (entry.modified) console.log(` LastModified: ${entry.modified}`);
console.log(` Private: ${entry.private}`);
}
console.log();
}
Example output
A file stamped with page labels only (the Archive intake idea: Bates in the label, nothing in /PieceInfo) looks like this. The label is what Acrobat shows; the PieceInfo line confirms there is no private payload.
# page 1
label: ARC-000001
PieceInfo: (none)
# page 2
label: ARC-000002
PieceInfo: (none)
The same Bates series written as /PieceInfo instead (metadata mark placement /PieceInfo, name AlvaStamp) does not change the viewer page number. The script is how you see it:
# page 1
label: 1
PieceInfo /AlvaStamp:
LastModified: D:20260831143000
Private: ARC-000001
# page 2
label: 2
PieceInfo /AlvaStamp:
LastModified: D:20260831143000
Private: ARC-000002
You can use both on the same profile: a label for humans, /PieceInfo for software that should not have to OCR the page. The AlvaStamp manual covers how to add the metadata mark; these scripts are the check that the private side survived the stamp.