@prozorro/prozorro-pdf-stamp
v0.0.6
Published
Draws a stamp per signer onto a Prozorro econtract PDF
Readme
prozorro-pdf-stamp
Browser library that takes a link to a Prozorro econtract and returns that contract's PDF with one visual stamp per
signer drawn on it. Stamp content is read out of the contract's contractSignature documents.
The project is a viewer aid. It does not create a KEP/QES, does not verify one cryptographically, and must never present its output as proof of a valid signature.
Install
npm install @prozorro/prozorro-pdf-stamppdf-lib, @pdf-lib/fontkit and @prozorro/prozorro-eds come as dependencies. ESM and CJS builds ship, with
TypeScript declarations at dist/index.d.ts.
Browser only. The library uses fetch, Blob, URL.createObjectURL, document and window; it does not run under
Node without a DOM.
Quick start
import {
ProzorroPdfStampService,
PROZORRO_ENVIRONMENT_MODE,
PROZORRO_STAMP_TYPES,
ProzorroPdfStampError,
} from "@prozorro/prozorro-pdf-stamp";
await ProzorroPdfStampService.init(PROZORRO_ENVIRONMENT_MODE.PROD);
const doc = await ProzorroPdfStampService.setConfig({
type: PROZORRO_STAMP_TYPES.CONTRACT,
url: "https://public-api.prozorro.gov.ua/api/2.5/contracts/<id>",
});
const result = await doc.validate();
if (result.valid) {
await doc.open(); // new tab with the stamped PDF
} else {
result.failures.forEach(failure => console.warn(failure.reason, failure.message));
}Two steps, always in this order: init() configures the decode service once per page,
setConfig() returns one document instance per contract. Calling setConfig() before init() rejects with
INVALID_PARAMS / notInitialised before any network request.
API
ProzorroPdfStampService.init(environment, options?)
await ProzorroPdfStampService.init(PROZORRO_ENVIRONMENT_MODE.PROD, {
fonts: { bold: "https://your-host/tinos-bold.ttf" },
});| Argument | Type | Meaning |
| --------------- | --------------------------------- | -------------------------------------------------------------------------- |
| environment | PROZORRO_ENVIRONMENT_MODE | PROD ("production") or DEV ("development") decode service |
| options.fonts | { bold?: string \| Uint8Array } | Replaces the bundled stamp face. A string is fetched, bytes are used as-is |
Omit fonts and the bundled Tinos Bold subset is used with no request at all. One face is drawn, so bold is the
only field. Calling init twice re-initialises rather than failing.
ProzorroPdfStampService.setConfig(config)
const doc = await ProzorroPdfStampService.setConfig({
type: PROZORRO_STAMP_TYPES.CONTRACT,
url: "https://public-api.prozorro.gov.ua/api/2.5/contracts/<id>",
});type is PROZORRO_STAMP_TYPES.CONTRACT — the only implemented kind. url is the contract's API endpoint, not the
web page. Returns a StampDocument; nothing is fetched yet.
StampDocument
| Member | Returns | Does |
| ------------------------------------- | ---------------------------- | --------------------------------------------------- |
| validate() | Promise<ValidationResult> | Fetches the contract and applies the business rules |
| toBytes(config?) | Promise<Uint8Array> | The stamped PDF as bytes |
| save(config?, filename?) | Promise<void> | Triggers a browser download |
| open(config?) | Promise<void> | Opens the PDF in a new tab |
| getIframe(config?, parentElementId) | Promise<HTMLIFrameElement> | Renders into an <iframe> appended to that element |
| warnings | readonly Warning[] | The last render's warnings (getter, not a call) |
Every render method takes the same optional placement config:
type StampPlacementConfig = {
pages?: "first" | "last" | "every"; // default "first"
placement?: "top" | "bottom"; // default "bottom"
};pages selects which pages get stamped; placement picks the edge stamps grow from. Stamp size, margins and gap are
fixed — the library never shrinks a stamp to fit more signers.
save() without a filename derives one from the contract id and appends .pdf if you did not.
getIframe() needs the id of an element that already exists in the DOM; a missing element is INVALID_PARAMS /
incorrectInputFormat.
Each instance caches per contract: the contract JSON, the source PDF, the decoded signers, the font, and one render per
distinct (pages, placement) pair. Two concurrent calls with the same config join one run. Nothing is written to
localStorage, sessionStorage, IndexedDB, the Cache API or a service worker — state lives on the instance and dies
with it, so drop the instance to release the PDF.
Validation
Business-rule failures are returned as data, never thrown:
const { valid, failures } = await doc.validate();| failure.reason | Contract failed because |
| ------------------------------- | ------------------------------------------- |
| CONTRACT_STATUS_NOT_ALLOWED | status is not active or terminated |
| CONTRACT_TEMPLATE_MISSING | no contractTemplateName field |
| CONTRACT_NOTICE_NOT_FOUND | no contractNotice PDF among the documents |
| CONTRACT_SIGNATURES_NOT_FOUND | no contractSignature documents |
failure.message is Ukrainian UI copy and may change at any release — branch on reason, never on the string.
failure.path names the field the rule read.
validate() is evaluated once per instance. The render methods run it themselves, so an invalid contract makes
toBytes() / save() / open() / getIframe() throw VALIDATION_FAILED with the first failure as reason and the
whole list under error.context.failures. Calling validate() first is how you show all of them without an exception.
Errors
Everything that is not a business-rule failure throws ProzorroPdfStampError:
try {
await doc.toBytes({ pages: "every" });
} catch (error) {
if (error instanceof ProzorroPdfStampError) {
switch (error.code) {
case PROZORRO_PDF_STAMP_ERROR_CODES.SERVICE_UNAVAILABLE:
// retryable
break;
default:
error.logWithTrace();
}
}
}| Field | Use |
| --------------- | ------------------------------------------ |
| code | Category — decides which UI block you show |
| reason | The specific cause — what you branch on |
| message | Ukrainian UI copy, provisional |
| context | Extra detail (url, failures, …) |
| originalError | Whatever was caught underneath |
| timestamp | When it was constructed |
| code | reason |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| INVALID_PARAMS | incorrectInputFormat, notInitialised |
| VALIDATION_FAILED | contractStatusNotAllowed, contractTemplateMissing, contractNoticeNotFound, contractSignaturesNotFound, documentNotAvailable, noSigners |
| SERVICE_UNAVAILABLE | requestFailed, decodeServiceFailed |
| INVALID_SIGNATURE | signatureDecodeFailed |
| PDF_GENERATION_FAILED | invalidPdf, pdfWriteFailed |
| INTERNAL_ERROR | unexpected |
INVALID_SIGNATURE means a signature file could not be read, not that a signature is invalid. A file that failed to
download is a transport failure, and an unrecognised fault is INTERNAL_ERROR — never a fake outage.
Errors do not log themselves. Call error.logWithTrace() when you want the console output.
One unreadable signature file fails the whole render: a partial signer list would misrepresent who signed the contract.
Warnings
The second channel. A warning never fails a render — the PDF is still returned — and reports what the stamp had to do:
const bytes = await doc.toBytes();
doc.warnings.forEach(warning => console.info(warning.reason, warning.context));| warning.reason | Meaning |
| -------------------------- | ------------------------------------------------------------- |
| DUPLICATE_SIGNER_DROPPED | Two entries shared a certificate serial; one stamp was drawn |
| SIGNER_NAME_FALLBACK | No full name in the certificate — the common name was used |
| SIGNER_NAME_MISSING | No name at all; the stamp shows a blank area |
| SIGNER_CODE_MISSING | No ЄДРПОУ or РНОКПП; the line is omitted |
| STAMP_TEXT_TRUNCATED | Text was shrunk, then cut, to stay inside the stamp |
| STAMPS_TRUNCATED | Some stamps did not fit; context.page and context.skipped |
| PAGE_ROTATION_APPLIED | The page carries a rotation; the stamp was placed accordingly |
doc.warnings holds the most recent render's warnings, deduplicated — reading it before any render gives [].
Branch on reason; message is UI copy.
What the stamp shows
Per signer: the fixed heading, the signer's name (subjectFullName, falling back to subjectCN), and the
identification code (subjectEDRPOUCode, falling back to subjectDRFOCode). Signers are deduplicated by certificate
serial; an entry with no serial is never treated as a duplicate.
The seal is drawn, not embedded — stroked rings plus filled wordmark outlines — so it stays sharp at any zoom and the bundle carries no image. Geometry is in PDF points. Stamps may overlap the contract's own content: the PDF layer is write-only and cannot see what is already on the page.
Byte-for-byte determinism holds — the same contract and config produce the same PDF — and the source document's metadata is left untouched.
Requirements and limits
- The contract API, the document downloads and the decode service must all be reachable from the browser, with CORS
headers that allow your origin. A blocked request surfaces as
SERVICE_UNAVAILABLE/requestFailed. - Requests carry a 30 s timeout; signature files decode up to four at a time.
open()opens a tab — call it from a user gesture or the popup blocker stops it.
Local demo
npm install
npm run dev # demo page, all API methods wired to a URL field
npm run build # library + demo bundles
npm run check # typecheck, lint, tests, gatesDocumentation
- Requirements — FR-01 … FR-24, NFR-01 … NFR-06, ASR, error and warning model, open questions
- Architecture — layers, module contracts, processing flow, coordinate system, public API
- Decisions — one file per ADR, indexed
- Tasks — T-01 … T-22 across eight phases
- Test Cases — TC-01 … TC-52
Traceability is verified on every npm run check: no dangling references, no gaps in numbering, every FR covered by a
task or test, every TC owned by a task, and every ADR either referenced downstream or superseded by a later one.
Stack
TypeScript, Vite, Vitest, ESLint. pdf-lib pinned at 1.17.1 for writing, @prozorro/prozorro-eds for signature
decoding, Tinos Bold for Cyrillic stamp text. No pdfjs-dist — the PDF layer is write-only.
Two build artifacts: the npm package @prozorro/prozorro-pdf-stamp, and a single-file demo page.
Status
Implemented. T-01 … T-22 are done, npm run check is green, and the acceptance test takes Fixture A through the public
API to a reopened PDF.
