diff --git a/CHANGELOG.md b/CHANGELOG.md index 75d886c..7dd7a02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 57cf6b7..5370274 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 4594b9c..6f9c69b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } @@ -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" diff --git a/tests/test_pdf.py b/tests/test_pdf.py new file mode 100644 index 0000000..c28278e --- /dev/null +++ b/tests/test_pdf.py @@ -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)) diff --git a/validpay/__init__.py b/validpay/__init__.py index 7b637d5..d4eb266 100644 --- a/validpay/__init__.py +++ b/validpay/__init__.py @@ -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__ = [ @@ -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" diff --git a/validpay/pdf.py b/validpay/pdf.py new file mode 100644 index 0000000..5ae21dd --- /dev/null +++ b/validpay/pdf.py @@ -0,0 +1,276 @@ +"""QR placement helpers (file mode add-on). + +``create_file_intent`` / ``create_intent`` seal a document and return a +``CreateIntentResult`` with ``retrieval_id`` + ``key``. To verify it, a +scannable QR encoding the verify URL must appear ON the document. WHERE it +goes is the integrator's call — but historically they were on their own to +render it and to guess coordinates, which is fiddly and error-prone (PDFs use +a bottom-left origin; every screen uses top-left). + +This module fixes that with one canonical placement contract — identical to the +Node SDK and the website "Try it" tool — so a position picked once maps to the +exact same spot here. + +``qrcode``, ``reportlab``, and ``pypdf`` are OPTIONAL — the core client needs +none of them. Install them only if you call :func:`embed_qr`:: + + pip install "validpay[pdf]" + +The pure helpers :func:`build_verify_url` and :func:`resolve_qr_rect` have no +dependencies — use them directly if you render PDFs with a different library. +""" + +from __future__ import annotations + +import io +import warnings +from dataclasses import dataclass +from typing import Tuple +from urllib.parse import quote + +from .errors import ValidPayError + +# Which page corner the (x, y) inset is measured from. +QrAnchor = str # "top-left" | "top-right" | "bottom-left" | "bottom-right" +QrUnit = str # "pt" | "mm" | "in" + +_UNIT_TO_PT = {"pt": 1.0, "mm": 72.0 / 25.4, "in": 72.0} +_ANCHORS = ("top-left", "top-right", "bottom-left", "bottom-right") + +#: Smallest QR side considered reliably scannable from a printed page at +#: arm's length (~72pt = 1in = 2.54cm). :func:`embed_qr` warns below this. +MIN_RECOMMENDED_QR_PT = 72.0 + + +@dataclass(frozen=True) +class QrPlacement: + """Where to place the QR, the way people think about a page. + + ``anchor`` names a page CORNER; ``x`` / ``y`` are the insets from that + corner's edges; the QR's matching corner is pinned there. So + ``QrPlacement(anchor="bottom-right", x=36, y=36, width=90)`` sits 36pt in + from the bottom and right edges and stays bottom-right on any page size. + The default ``top-left`` anchor reads like screen coordinates. + """ + + x: float + y: float + width: float + page: int = 1 + anchor: QrAnchor = "top-left" + units: QrUnit = "pt" + + +@dataclass(frozen=True) +class ResolvedQrRect: + """A QR rectangle in PDF's bottom-left-origin point space.""" + + x: float # left edge from the page left, in points + y: float # bottom edge from the page bottom, in points + size: float # QR side length, in points + + +def _to_base64url(b64: str) -> str: + """base64 -> base64url. Phone scanners + share-sheets mangle ``+ / =`` in + URL fragments, so QR keys must be base64url. Idempotent; ``/verify`` + accepts both.""" + return b64.replace("+", "-").replace("/", "_").rstrip("=") + + +def build_verify_url( + retrieval_id: str, + key: str, + base_url: str = "https://validpay.com", +) -> str: + """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. + """ + if not retrieval_id: + raise ValidPayError("invalid_argument", "retrieval_id is required") + if not key: + raise ValidPayError("invalid_argument", "key is required") + base = base_url.rstrip("/") + return f"{base}/verify/{quote(retrieval_id, safe='')}#key={_to_base64url(key)}" + + +def resolve_qr_rect( + placement: QrPlacement, + page_width_pt: float, + page_height_pt: float, +) -> ResolvedQrRect: + """Convert a :class:`QrPlacement` into PDF's bottom-left-origin point + rectangle for a page of the given size. + + This is the EXACT conversion the website "Try it" tool uses, so copied + coordinates land in the same place. Pure and dependency-free. + """ + if placement.anchor not in _ANCHORS: + raise ValidPayError( + "invalid_argument", + f"anchor must be one of {_ANCHORS}, got {placement.anchor!r}", + ) + if placement.units not in _UNIT_TO_PT: + raise ValidPayError( + "invalid_argument", + f"units must be one of {tuple(_UNIT_TO_PT)}, got {placement.units!r}", + ) + unit = _UNIT_TO_PT[placement.units] + size = placement.width * unit + inset_x = placement.x * unit + inset_y = placement.y * unit + + left_anchored = placement.anchor in ("top-left", "bottom-left") + top_anchored = placement.anchor in ("top-left", "top-right") + + x = inset_x if left_anchored else page_width_pt - inset_x - size + # PDF y is the QR's BOTTOM edge from the page bottom. A top inset measures + # from the page top down to the QR's top edge. + y = page_height_pt - inset_y - size if top_anchored else inset_y + return ResolvedQrRect(x=x, y=y, size=size) + + +def embed_qr( + pdf_bytes: bytes, + retrieval_id: str, + key: str, + placement: QrPlacement, + *, + base_url: str = "https://validpay.com", + error_correction: str = "M", + margin: int = 2, + dark_color: str = "#0A0F1E", + light_color: str = "#FFFFFF", +) -> bytes: + """Stamp a scannable verify QR onto an existing PDF and return new bytes. + + The input is not mutated. Requires the optional extras + (``pip install "validpay[pdf]"``); raises ``missing_dependency`` if absent. + + Example:: + + res = client.create_file_intent(document_type="invoice", file=data) + sealed = embed_qr( + data, res.retrieval_id, res.key, + QrPlacement(anchor="bottom-right", x=36, y=36, width=90), + ) + """ + if not pdf_bytes: + raise ValidPayError("invalid_argument", "pdf_bytes must be non-empty") + if placement.width <= 0: + raise ValidPayError("invalid_argument", "placement.width must be > 0") + + qrcode = _load("qrcode") + pypdf = _load("pypdf") + canvas_mod = _load("reportlab.pdfgen.canvas", pip="reportlab") + imagereader = _load("reportlab.lib.utils", pip="reportlab") + + ec_map = { + "L": qrcode.constants.ERROR_CORRECT_L, + "M": qrcode.constants.ERROR_CORRECT_M, + "Q": qrcode.constants.ERROR_CORRECT_Q, + "H": qrcode.constants.ERROR_CORRECT_H, + } + if error_correction not in ec_map: + raise ValidPayError( + "invalid_argument", + f"error_correction must be one of {tuple(ec_map)}, got {error_correction!r}", + ) + + url = build_verify_url(retrieval_id, key, base_url) + qr = qrcode.QRCode(error_correction=ec_map[error_correction], border=margin) + qr.add_data(url) + qr.make(fit=True) + img = qr.make_image(fill_color=dark_color, back_color=light_color).convert("RGB") + png_buf = io.BytesIO() + img.save(png_buf, format="PNG") + png_buf.seek(0) + + reader = pypdf.PdfReader(io.BytesIO(pdf_bytes)) + page_count = len(reader.pages) + idx = placement.page - 1 + if idx < 0 or idx >= page_count: + raise ValidPayError( + "invalid_argument", + f"placement.page {placement.page} is out of range " + f"(document has {page_count} page(s))", + ) + target = reader.pages[idx] + page_w = float(target.mediabox.width) + page_h = float(target.mediabox.height) + rect = resolve_qr_rect(placement, page_w, page_h) + + if rect.size < MIN_RECOMMENDED_QR_PT: + warnings.warn( + f"QR is {rect.size:.0f}pt wide — below the ~{MIN_RECOMMENDED_QR_PT:.0f}pt " + "(1in) recommended minimum; it may be hard to scan once printed.", + stacklevel=2, + ) + if ( + rect.x < 0 + or rect.y < 0 + or rect.x + rect.size > page_w + or rect.y + rect.size > page_h + ): + raise ValidPayError( + "invalid_argument", + "placement puts the QR (partly) off the page — check x/y/width against the page size", + ) + + overlay_buf = io.BytesIO() + c = canvas_mod.Canvas(overlay_buf, pagesize=(page_w, page_h)) + c.drawImage( + imagereader.ImageReader(png_buf), + rect.x, + rect.y, + width=rect.size, + height=rect.size, + mask="auto", + ) + c.showPage() + c.save() + overlay_buf.seek(0) + + overlay = pypdf.PdfReader(overlay_buf) + writer = pypdf.PdfWriter() + for i, page in enumerate(reader.pages): + if i == idx: + page.merge_page(overlay.pages[0]) + writer.add_page(page) + out = io.BytesIO() + writer.write(out) + return out.getvalue() + + +def _load(module: str, *, pip: str | None = None): + """Import an optional dependency or raise a helpful ValidPayError.""" + import importlib + + try: + mod = importlib.import_module(module) + # qrcode needs its constants submodule eagerly for the EC map. + if module == "qrcode": + importlib.import_module("qrcode.constants") + return mod + except ImportError as exc: # pragma: no cover - exercised via integration + pkg = pip or module.split(".")[0] + raise ValidPayError( + "missing_dependency", + f"embed_qr requires the optional dependency '{pkg}'. " + 'Install the PDF extras: pip install "validpay[pdf]"', + ) from exc + + +__all__ = [ + "QrAnchor", + "QrUnit", + "QrPlacement", + "ResolvedQrRect", + "MIN_RECOMMENDED_QR_PT", + "build_verify_url", + "resolve_qr_rect", + "embed_qr", +]