Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ All notable changes to the ValidPay Python SDK will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.4.0] - 2026-06-17

### Added

- **QR placement — `embed_qr()`** plus the pure helpers `build_verify_url()`
and `resolve_qr_rect()`, and the `QrPlacement` contract (anchor + x/y insets
+ width + units + page). Stamps a scannable verify QR onto a PDF so
integrators stop hand-rolling QR rendering and guessing coordinates; the
coordinate vocabulary is identical to the Node SDK and the developer
console's "Try it" tool. Warns below the ~72pt scannable minimum and raises
on off-page placement.
- New optional extra: `pip install "validpay[pdf]"` (`qrcode`, `Pillow`,
`reportlab`, `pypdf`). The core SDK stays dependency-light — these load only
when `embed_qr` is called.

## [1.3.0] - 2026-06-16

### Added
Expand Down
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,57 @@ print(verification.issuer_verified) # True
print(verification.status) # "active"
```

### Placing the QR on a document (`embed_qr`)

To verify a document, a scannable QR encoding the verify URL has to appear **on**
it. `embed_qr` builds that QR and stamps it onto a PDF for you — no QR library
wiring, no base64url juggling, and no fighting PDF coordinates (PDFs measure from
the bottom-left; everything else from the top-left).

It needs the optional PDF extras — the core SDK needs none of them:

```bash
pip install "validpay[pdf]"
```

```python
from validpay import ValidPayClient, QrPlacement, embed_qr

client = ValidPayClient(api_key="vp_live_...")

with open("invoice.pdf", "rb") as f:
original = f.read()

res = client.create_file_intent(
document_type="invoice", file=original, file_content_type="application/pdf",
)

sealed = embed_qr(
original, res.retrieval_id, res.key,
# 90pt (1.25in) QR, 36pt in from the bottom-right corner.
QrPlacement(anchor="bottom-right", x=36, y=36, width=90),
)
with open("invoice-sealed.pdf", "wb") as f:
f.write(sealed)
```

**The placement contract** — identical to the Node SDK and the developer
console's "Try it" tool, so a position you pick in the UI maps here 1:1:

| field | meaning | default |
| -------- | ------- | ------- |
| `anchor` | which page **corner** the insets are measured from (`top-left`/`top-right`/`bottom-left`/`bottom-right`) | `top-left` |
| `x` | horizontal inset from that corner's vertical edge | — |
| `y` | vertical inset from that corner's horizontal edge | — |
| `width` | QR side length (it's square) | — |
| `units` | `pt` (1/72in) / `mm` / `in` | `pt` |
| `page` | 1-based page number | `1` |

Keep the QR **≥ ~72pt (1in)** so it scans once printed — `embed_qr` warns below
that and raises if the placement runs off the page. Using a different PDF
library? The pure helpers `build_verify_url(...)` and
`resolve_qr_rect(placement, page_w_pt, page_h_pt)` have no dependencies.

### Time-Locked Verification (Patent D)

Restrict when a document can be verified by specifying a validity window:
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "validpay"
version = "1.3.0"
version = "1.4.0"
description = "Official ValidPay Python SDK — client-side AES-256-GCM encryption + ValidPay API client"
readme = "README.md"
license = { file = "LICENSE" }
Expand Down Expand Up @@ -39,6 +39,7 @@ dev = [
"pytest>=7.0",
]
binding = ["Pillow>=10.0", "numpy>=1.24", "scipy>=1.10"]
pdf = ["qrcode>=7.0", "Pillow>=10.0", "reportlab>=4.0", "pypdf>=4.0"]

[project.urls]
Homepage = "https://validpay.com"
Expand Down
153 changes: 153 additions & 0 deletions tests/test_pdf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""Tests for validpay.pdf — verify URL, placement math, and embed_qr.

The embed_qr tests need the optional PDF extras; they're skipped if the
deps aren't installed so the core suite still runs everywhere.
"""

import importlib.util

import pytest

from validpay import QrPlacement, build_verify_url, resolve_qr_rect
from validpay.errors import ValidPayError

W, H = 612.0, 792.0 # US Letter, points

_HAS_PDF = all(
importlib.util.find_spec(m) is not None
for m in ("qrcode", "reportlab", "pypdf", "PIL")
)
pdf_only = pytest.mark.skipif(not _HAS_PDF, reason="validpay[pdf] extras not installed")


# ── build_verify_url ────────────────────────────────────────────────────────

def test_build_verify_url_basic():
assert build_verify_url("abc123", "deadbeef") == (
"https://validpay.com/verify/abc123#key=deadbeef"
)


def test_build_verify_url_base64url_key_and_encoded_id():
# "K+K/m==" -> base64url "K-K_m"; id is percent-encoded.
assert build_verify_url("a/b c", "K+K/m==") == (
"https://validpay.com/verify/a%2Fb%20c#key=K-K_m"
)


def test_build_verify_url_custom_base_strips_slash():
assert build_verify_url("id", "k", "https://staging.validpay.com/") == (
"https://staging.validpay.com/verify/id#key=k"
)


def test_build_verify_url_requires_args():
with pytest.raises(ValidPayError):
build_verify_url("", "k")
with pytest.raises(ValidPayError):
build_verify_url("id", "")


# ── resolve_qr_rect ─────────────────────────────────────────────────────────

def test_resolve_top_left():
r = resolve_qr_rect(QrPlacement(anchor="top-left", x=400, y=50, width=90), W, H)
assert (r.x, r.y, r.size) == (400, 792 - 50 - 90, 90)


def test_resolve_default_anchor_is_top_left():
r = resolve_qr_rect(QrPlacement(x=10, y=20, width=100), W, H)
assert (r.x, r.y, r.size) == (10, H - 20 - 100, 100)


def test_resolve_bottom_right():
r = resolve_qr_rect(QrPlacement(anchor="bottom-right", x=36, y=36, width=90), W, H)
assert (r.x, r.y, r.size) == (612 - 36 - 90, 36, 90)


def test_resolve_top_right_and_bottom_left():
tr = resolve_qr_rect(QrPlacement(anchor="top-right", x=40, y=40, width=80), W, H)
assert (tr.x, tr.y) == (612 - 40 - 80, 792 - 40 - 80)
bl = resolve_qr_rect(QrPlacement(anchor="bottom-left", x=40, y=40, width=80), W, H)
assert (bl.x, bl.y) == (40, 40)


def test_resolve_unit_conversion():
mm = resolve_qr_rect(QrPlacement(anchor="top-left", x=25.4, y=0, width=25.4, units="mm"), W, H)
assert mm.size == pytest.approx(72)
assert mm.x == pytest.approx(72)
inch = resolve_qr_rect(QrPlacement(anchor="bottom-left", x=1, y=1, width=1, units="in"), W, H)
assert (inch.x, inch.y, inch.size) == (72, 72, 72)


def test_resolve_rejects_bad_anchor_units():
with pytest.raises(ValidPayError):
resolve_qr_rect(QrPlacement(x=1, y=1, width=1, anchor="middle"), W, H)
with pytest.raises(ValidPayError):
resolve_qr_rect(QrPlacement(x=1, y=1, width=1, units="cm"), W, H)


# ── embed_qr (needs extras) ─────────────────────────────────────────────────

def _blank_pdf(pages: int = 1) -> bytes:
from reportlab.pdfgen import canvas

import io

buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=(W, H))
for _ in range(pages):
c.showPage()
c.save()
return buf.getvalue()


@pdf_only
def test_embed_qr_returns_valid_pdf():
from pypdf import PdfReader
import io

from validpay import embed_qr

original = _blank_pdf(1)
out = embed_qr(original, "abc123", "deadbeef",
QrPlacement(anchor="bottom-right", x=36, y=36, width=90))
assert isinstance(out, bytes) and len(out) > 0
assert len(PdfReader(io.BytesIO(out)).pages) == 1


@pdf_only
def test_embed_qr_targets_a_page():
from pypdf import PdfReader
import io

from validpay import embed_qr

out = embed_qr(_blank_pdf(3), "id", "k",
QrPlacement(page=2, anchor="top-left", x=50, y=50, width=100))
assert len(PdfReader(io.BytesIO(out)).pages) == 3


@pdf_only
def test_embed_qr_rejects_out_of_range_page():
from validpay import embed_qr

with pytest.raises(ValidPayError, match="out of range"):
embed_qr(_blank_pdf(1), "id", "k", QrPlacement(page=5, x=10, y=10, width=50))


@pdf_only
def test_embed_qr_rejects_off_page():
from validpay import embed_qr

with pytest.raises(ValidPayError, match="off the page"):
embed_qr(_blank_pdf(1), "id", "k",
QrPlacement(anchor="top-left", x=600, y=10, width=100))


@pdf_only
def test_embed_qr_rejects_empty_bytes():
from validpay import embed_qr

with pytest.raises(ValidPayError):
embed_qr(b"", "id", "k", QrPlacement(x=1, y=1, width=50))
16 changes: 15 additions & 1 deletion validpay/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@
)
from .errors import ValidPayError
from .offline import OfflineCache, OfflineVerifyResult
from .pdf import (
MIN_RECOMMENDED_QR_PT,
QrPlacement,
ResolvedQrRect,
build_verify_url,
embed_qr,
resolve_qr_rect,
)
from .types import CreateIntentResult, VerifyIntentResult

__all__ = [
Expand All @@ -53,6 +61,12 @@
"BindingComparisonResult",
"OfflineCache",
"OfflineVerifyResult",
"QrPlacement",
"ResolvedQrRect",
"MIN_RECOMMENDED_QR_PT",
"build_verify_url",
"resolve_qr_rect",
"embed_qr",
]

__version__ = "1.3.0"
__version__ = "1.4.0"
Loading
Loading