A link you never click is a bug your customer finds
I generated an 871-page hyperlinked PDF planner with Python — 8,556 internal link annotations across 730 daily pages, 105 weekly spreads, 24 monthly calendars, and a persistent tab bar. The render pass is only half the job. The other half is a verification script that re-opens the finished PDF and checks every single link, because a link you never click is a bug your customer finds.
This is the QA pass from the companion piece on generating the planner. Same build, second half of the story.
Check 1: the document opens and counts match
Before anything else, the script opens the built PDF and asserts the basics:
from pypdf import PdfReader
reader = PdfReader("planner-2026-2027.pdf")
assert len(reader.pages) == 871, f"expected 871 pages, got {len(reader.pages)}"
It also runs text extraction on a sample of page types — home, month, day, week, goal, notes — to confirm the document opens cleanly and the text layer is intact. A corrupted render fails here before wasting time on 8,556 links. Cheap check, run first.
Check 2: every link annotation resolves
This is the core loop. Every page's annotations are walked, every link-type annotation is resolved to its destination page, and anything pointing nowhere — or anywhere outside the document — is recorded:
broken = []
total = 0
for i, page in enumerate(reader.pages):
for annot in page.get("/Annots", []):
obj = annot.get_object()
if obj.get("/Subtype") != "/Link":
continue
total += 1
dest = obj.get("/Dest")
page_idx = resolve_destination(reader, dest)
if page_idx is None or not (0 <= page_idx < len(reader.pages)):
broken.append((i, dest))
print(f"{total} links checked, {len(broken)} broken")
On the shipped build: 8,556 checked, 0 broken. The number is the whole point — a human spot-checking "a few links" on an 871-page document is theater. The script is the actual test.
Check 3: semantic spot-checks (not just "resolves")
A link can resolve to a valid page and still be wrong. March 14's calendar cell landing on March 15's page is a valid link and a broken product. So the script does semantic checks: extract text from the destination page and confirm it contains the expected date:
dest_page = reader.pages[resolved_idx]
text = dest_page.extract_text()
assert "March 14, 2026" in text, f"cell landed on wrong page: {text[:60]}"
Same treatment for the tab bar (each tab lands on its section's real first page) and for a sample of daily pages. Resolution checks catch crashes; semantic checks catch wrongness.
Check 4: the week chain
The 105 weekly spreads form a prev/next chain across two year boundaries — the highest-risk area for off-by-one errors. The script walks the full chain: week N's "next" link must land on week N+1's page, including the December 2026 → January 2027 handoff. First-week and last-week edge cases get explicit assertions. Calendar code loves to break at boundaries; the QA knows where to look.
Check 5: static-file guarantee
The finished file is asserted to be a static PDF: no JavaScript actions, no external URIs, no network calls. A planner that phones home is a support nightmare and a trust problem. The check is a few lines and it runs on every build.
What the script caught
Honest answer: on the shipped build it caught nothing — the QA result was PASS, 8,556/8,556, zero broken. The script earned its keep earlier in development, where the checks it now runs automatically are exactly the failure modes that bit during layout changes: named destinations are used instead of raw page numbers precisely because inserting a page during development used to silently repoint hundreds of links. The QA pass is the reason that class of bug can't ship again.
What the script can't check
The remaining gap is stated plainly in the product's own README: on-device testing in GoodNotes and Notability hasn't happened yet. pypdf says every link resolves; what it can't tell you is how a specific annotation app renders or handles 8,556 annotations on a real tablet. Desktop viewers forgive sins that annotation apps don't. That test is next, and the product copy doesn't claim it passed.
The takeaway
For template-style digital products, the QA script is part of the product. The render code makes the thing; the verify code is what lets you sell it with a straight face. Write the checks first, run them on every build, and disclose what they don't cover.
DesignClarity builds original digital products with code-first workflows. More of the factory's builds: https://designclarity.github.io
Top comments (0)