diff --git a/README.md b/README.md
index 628674e..b957479 100644
--- a/README.md
+++ b/README.md
@@ -204,6 +204,39 @@ const result = await client.createBoundIntent(
);
```
+### QR placement (`buildVerifyUrl` + `resolveQrRect`)
+
+A verifiable document needs a scannable QR — encoding the verify URL — placed on
+it. This SDK gives you the canonical placement contract, shared verbatim with
+the Node and Python SDKs and the developer console's "Try it" tool, so a
+position picked once maps to the exact same spot everywhere.
+
+```ts
+import { buildVerifyUrl } from '@validpay/react-native-sdk';
+import QRCode from 'react-native-qrcode-svg'; // your QR component of choice
+
+const url = buildVerifyUrl(retrievalId, key); // key is base64url'd into the #fragment
+;
+```
+
+To stamp the QR onto a generated PDF, use the placement → points helper and hand
+the result to your PDF library (or do it server-side with `@validpay/node-sdk`'s
+`embedQr`, which mints the PDF for you):
+
+```ts
+import { resolveQrRect, type QrPlacement } from '@validpay/react-native-sdk';
+
+const placement: QrPlacement = { anchor: 'bottom-right', x: 36, y: 36, width: 90 };
+const { x, y, size } = resolveQrRect(placement, pageWidthPt, pageHeightPt); // bottom-left points
+```
+
+> **Why no on-device `embedQr`?** Editing PDF bytes on a phone isn't practical,
+> so React Native ships only the pure contract. Render the QR for display, or
+> seal the PDF where you build it. Keep the QR **≥ 72pt (1in)** so it scans once
+> printed (`MIN_RECOMMENDED_QR_PT`).
+
+`QrPlacement` fields: `anchor` (page corner — `top-left`/`top-right`/`bottom-left`/`bottom-right`, default `top-left`), `x`/`y` (insets from that corner), `width` (QR side), `units` (`pt`/`mm`/`in`, default `pt`), `page` (1-based).
+
### Errors
Every SDK and API failure is thrown as `ValidPayError`:
diff --git a/__tests__/pdf.test.ts b/__tests__/pdf.test.ts
new file mode 100644
index 0000000..926ed5c
--- /dev/null
+++ b/__tests__/pdf.test.ts
@@ -0,0 +1,90 @@
+import {
+ buildVerifyUrl,
+ resolveQrRect,
+ MIN_RECOMMENDED_QR_PT,
+ ValidPayError,
+} from '../src/index';
+
+const W = 612;
+const H = 792; // US Letter, points
+
+describe('buildVerifyUrl', () => {
+ it('builds the canonical /verify URL with the key in the fragment', () => {
+ expect(buildVerifyUrl('abc123', 'deadbeef')).toBe(
+ 'https://validpay.com/verify/abc123#key=deadbeef',
+ );
+ });
+
+ it('base64url-encodes the key and percent-encodes the id', () => {
+ expect(buildVerifyUrl('a/b c', 'K+K/m==')).toBe(
+ 'https://validpay.com/verify/a%2Fb%20c#key=K-K_m',
+ );
+ });
+
+ it('honors a custom base and strips trailing slashes', () => {
+ expect(buildVerifyUrl('id', 'k', { baseUrl: 'https://staging.validpay.com/' })).toBe(
+ 'https://staging.validpay.com/verify/id#key=k',
+ );
+ });
+
+ it('rejects missing args', () => {
+ expect(() => buildVerifyUrl('', 'k')).toThrow(ValidPayError);
+ expect(() => buildVerifyUrl('id', '')).toThrow(ValidPayError);
+ });
+});
+
+describe('resolveQrRect', () => {
+ it('top-left anchor maps inset-from-top into pdf bottom-left y', () => {
+ expect(resolveQrRect({ anchor: 'top-left', x: 400, y: 50, width: 90 }, W, H)).toEqual({
+ x: 400,
+ y: 652,
+ size: 90,
+ });
+ });
+
+ it('defaults to top-left + pt', () => {
+ expect(resolveQrRect({ x: 10, y: 20, width: 100 }, W, H)).toEqual({
+ x: 10,
+ y: H - 20 - 100,
+ size: 100,
+ });
+ });
+
+ it('bottom-right insets from the bottom and right edges', () => {
+ expect(resolveQrRect({ anchor: 'bottom-right', x: 36, y: 36, width: 90 }, W, H)).toEqual({
+ x: 486,
+ y: 36,
+ size: 90,
+ });
+ });
+
+ it('top-right and bottom-left', () => {
+ expect(resolveQrRect({ anchor: 'top-right', x: 40, y: 40, width: 80 }, W, H)).toEqual({
+ x: 612 - 40 - 80,
+ y: 792 - 40 - 80,
+ size: 80,
+ });
+ expect(resolveQrRect({ anchor: 'bottom-left', x: 40, y: 40, width: 80 }, W, H)).toEqual({
+ x: 40,
+ y: 40,
+ size: 80,
+ });
+ });
+
+ it('converts mm and in to points', () => {
+ const mm = resolveQrRect({ anchor: 'top-left', x: 25.4, y: 0, width: 25.4, units: 'mm' }, W, H);
+ expect(mm.size).toBeCloseTo(72, 5);
+ expect(mm.x).toBeCloseTo(72, 5);
+ expect(resolveQrRect({ anchor: 'bottom-left', x: 1, y: 1, width: 1, units: 'in' }, W, H)).toEqual({
+ x: 72,
+ y: 72,
+ size: 72,
+ });
+ });
+});
+
+describe('MIN_RECOMMENDED_QR_PT', () => {
+ it('is one inch', () => {
+ expect(MIN_RECOMMENDED_QR_PT).toBe(72);
+ });
+});
diff --git a/package.json b/package.json
index b20d549..4766342 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@validpay/react-native-sdk",
- "version": "1.1.0",
+ "version": "1.2.0",
"description": "ValidPay verification SDK for React Native — 8 patented protections for document authenticity",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -15,8 +15,8 @@
"prepublishOnly": "npm run build"
},
"peerDependencies": {
- "react-native": ">=0.70.0",
- "@react-native-async-storage/async-storage": ">=1.19.0"
+ "@react-native-async-storage/async-storage": ">=1.19.0",
+ "react-native": ">=0.70.0"
},
"peerDependenciesMeta": {
"react-native": {
@@ -26,13 +26,12 @@
"optional": true
}
},
- "dependencies": {},
"devDependencies": {
- "typescript": "^5.4.0",
+ "@types/jest": "^29.5.0",
+ "@types/node": "^20.0.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.0",
- "@types/jest": "^29.5.0",
- "@types/node": "^20.0.0"
+ "typescript": "^5.4.0"
},
"keywords": [
"validpay",
diff --git a/src/index.ts b/src/index.ts
index 5475098..ff8f0d3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -22,6 +22,18 @@ export {
setCryptoProvider,
} from './crypto';
export { computeTimeLockStatus, validateTimeLock } from './timelock';
+export {
+ buildVerifyUrl,
+ resolveQrRect,
+ MIN_RECOMMENDED_QR_PT,
+} from './pdf';
+export type {
+ QrAnchor,
+ QrUnit,
+ QrPlacement,
+ ResolvedQrRect,
+ VerifyUrlOptions,
+} from './pdf';
export type {
CreateIntentResult,
VerifyIntentResult,
diff --git a/src/pdf.ts b/src/pdf.ts
new file mode 100644
index 0000000..6753f4f
--- /dev/null
+++ b/src/pdf.ts
@@ -0,0 +1,127 @@
+/**
+ * QR placement contract (file mode add-on).
+ *
+ * Sealing a document returns `{ retrievalId, key }`. To verify it, a scannable
+ * QR encoding the verify URL must appear ON the document. This module gives you
+ * the canonical placement contract — identical to the Node and Python SDKs and
+ * the developer console's "Try it" tool — so a position picked once maps to the
+ * exact same spot everywhere.
+ *
+ * Unlike the Node SDK, there is no `embedQr` here: stamping an image onto a PDF
+ * on-device isn't practical in React Native. Instead:
+ *
+ * - To DISPLAY the QR in your app, pass `buildVerifyUrl(...)` to a QR
+ * component such as `react-native-qrcode-svg`.
+ * - To STAMP the QR onto a generated PDF, do it where you build the PDF —
+ * server-side with `@validpay/node-sdk`'s `embedQr`, or with your own PDF
+ * library using the points from `resolveQrRect`.
+ *
+ * Both helpers below are pure and dependency-free.
+ */
+import { ValidPayError } from './errors';
+
+/** Which page corner the (x, y) inset is measured from. */
+export type QrAnchor = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
+
+/** Units for placement values. 1pt = 1/72 inch (PDF's native unit). */
+export type QrUnit = 'pt' | 'mm' | 'in';
+
+/**
+ * Where to place the QR, the way people think about a page. `anchor` names a
+ * page CORNER; `x` / `y` are insets from that corner's edges; the QR's matching
+ * corner is pinned there. `{ anchor: 'bottom-right', x: 36, y: 36, width: 90 }`
+ * sits 36pt in from the bottom and right edges on any page size. The default
+ * `top-left` anchor reads like screen coordinates.
+ */
+export interface QrPlacement {
+ /** 1-based page number. Default `1`. */
+ page?: number;
+ /** Page corner the insets are measured from. Default `'top-left'`. */
+ anchor?: QrAnchor;
+ /** Horizontal inset from the anchor's vertical edge, in `units`. */
+ x: number;
+ /** Vertical inset from the anchor's horizontal edge, in `units`. */
+ y: number;
+ /** QR side length (it is square), in `units`. */
+ width: number;
+ /** Units for `x` / `y` / `width`. Default `'pt'`. */
+ units?: QrUnit;
+}
+
+/** A QR rectangle in PDF's bottom-left-origin point space. */
+export interface ResolvedQrRect {
+ /** Distance from the page's LEFT edge to the QR's left edge, in points. */
+ x: number;
+ /** Distance from the page's BOTTOM edge to the QR's bottom edge, in points. */
+ y: number;
+ /** QR side length, in points. */
+ size: number;
+}
+
+const UNIT_TO_PT: Record = { pt: 1, mm: 72 / 25.4, in: 72 };
+
+/**
+ * Smallest QR side that scans reliably from a printed page at arm's length
+ * (~72pt ≈ 1in ≈ 2.54cm). Keep `width` at or above this.
+ */
+export const MIN_RECOMMENDED_QR_PT = 72;
+
+export interface VerifyUrlOptions {
+ /** Web origin that serves `/verify`. Default `'https://validpay.com'`. */
+ baseUrl?: string;
+}
+
+/** base64 → base64url. Phone scanners + share-sheets mangle `+`, `/`, and `=`
+ * in URL fragments, so QR keys must be base64url. Idempotent; `/verify`
+ * accepts both. */
+function toBase64Url(b64: string): string {
+ return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+/**
+ * Build the canonical verify URL the QR encodes:
+ *
+ * /verify/#key=
+ *
+ * The key rides in the URL FRAGMENT (`#key=`), which browsers never send to
+ * any server. Feed the result to a QR component to display it.
+ */
+export function buildVerifyUrl(
+ retrievalId: string,
+ key: string,
+ opts: VerifyUrlOptions = {},
+): string {
+ if (!retrievalId) {
+ throw new ValidPayError('invalid_argument', 'retrievalId is required');
+ }
+ if (!key) {
+ throw new ValidPayError('invalid_argument', 'key is required');
+ }
+ const base = (opts.baseUrl ?? 'https://validpay.com').replace(/\/+$/, '');
+ return `${base}/verify/${encodeURIComponent(retrievalId)}#key=${toBase64Url(key)}`;
+}
+
+/**
+ * Convert a {@link QrPlacement} into PDF's bottom-left-origin point rectangle
+ * for a page of the given size. The EXACT conversion the website "Try it" tool
+ * and the other SDKs use, so copied coordinates land in the same place. Pure.
+ */
+export function resolveQrRect(
+ placement: QrPlacement,
+ pageWidthPt: number,
+ pageHeightPt: number,
+): ResolvedQrRect {
+ const unit = UNIT_TO_PT[placement.units ?? 'pt'];
+ const size = placement.width * unit;
+ const insetX = placement.x * unit;
+ const insetY = placement.y * unit;
+ const anchor = placement.anchor ?? 'top-left';
+
+ const leftAnchored = anchor === 'top-left' || anchor === 'bottom-left';
+ const topAnchored = anchor === 'top-left' || anchor === 'top-right';
+
+ const x = leftAnchored ? insetX : pageWidthPt - insetX - size;
+ const y = topAnchored ? pageHeightPt - insetY - size : insetY;
+
+ return { x, y, size };
+}