The problem with digital planners
A hyperlinked PDF planner lives or dies on its links. Every monthly calendar date must jump to the right daily page. Every tab bar must land on the right section. Do it by hand in InDesign and you're placing thousands of link rectangles manually — and one fat-fingered destination ruins the product.
I generate mine with code instead. The latest build: 871 pages, 730 daily pages, 8,556 internal link annotations, verified zero broken. Here's the approach.
Architecture: render + verify as separate passes
Two passes, two libraries:
- Render pass — draw every page and place every link annotation.
- QA pass — re-open the finished PDF and mechanically verify every link resolves to a real page, plus semantic spot-checks (does the March 14 cell land on the March 14 page?).
Separating them matters. The render pass can have a systematic off-by-one that looks fine; only an independent verification pass catches it.
The core trick: date → page index math
Everything hangs off one function: given a date, which 0-based page index is its daily page? Once that's right, every calendar cell, week header, and tab bar is just arithmetic.
from datetime import date, timedelta
START = date(2026, 1, 1)
def daily_page_index(d: date, first_daily_page: int) -> int:
"""0-based PDF page index of this date's daily page."""
return first_daily_page + (d - START).days
Layout plan (page indices decided up front, before drawing):
P_COVER = 0
P_HOME = 1
P_YEAR_2026 = 2
P_YEAR_2027 = 3
FIRST_MONTH_PAGE = 4 # 24 monthly spreads follow
FIRST_WEEK_PAGE = 4 + 24 # 105 weekly spreads follow
FIRST_DAILY_PAGE = 4 + 24 + 105 # 730 daily pages follow
Placing links: named destinations, not raw page numbers
In the real build I used named destinations (bookmarkPage + linkAbsolute) rather than raw page numbers, so inserting or removing a page during development never silently repoints hundreds of links. Named destinations are the single biggest robustness win: links survive layout edits.
# When drawing March 14, 2026's daily page:
c.bookmarkPage("daily-2026-03-14")
# When drawing the March 2026 calendar grid, over the "14" cell:
c.linkAbsolute("", "daily-2026-03-14", Rect=(x0, y0, x1, y1), thickness=0)
The tab bar: one function, every page
A persistent tab bar (Home / 2026 / 2027 / Months / Goals / Notes) appears on all 871 pages — one drawing function, six named-destination links, same coordinates everywhere. Six tabs × 871 pages = 5,226 of the 8,556 links come from this one function. Write it once, verify it once.
QA pass: trust nothing
The verification script re-opens the built PDF with pypdf and checks: (1) page count exactly 871; (2) every link annotation resolves — 8,556 checked, 0 broken; (3) semantic spot-checks — extract text from the destination page and confirm it contains the expected date; (4) week-chain integrity across all 105 weeks including year boundaries; (5) static-file check — no JavaScript, no external URIs, fully offline.
Practical notes
- File size: 2.5 MB for 871 pages — vector drawing beats rasterized pages by an order of magnitude, and text stays selectable.
- Page size: 768 × 1024 pt, portrait tablet-first, matches GoodNotes/Notability import expectations.
- Fonts: stick to the 14 base PDF fonts or embed your own. A missing font on the buyer's device is a support ticket you can't debug remotely.
- Test on-device. Desktop PDF viewers forgive sins that annotation apps don't. (Still on my own checklist — automated QA passed, device testing is next.)
Why generate instead of design?
Speed of iteration. Extending the planner from one year to two was a constant change and a re-run — not a month of manual layout. The QA script re-verified all 8,556 links in seconds. For template-style digital products, code is the design tool.
DesignClarity builds original digital products with code-first workflows. More of the factory's builds: https://designclarity.github.io
Top comments (0)