
Scheduled PDF reports fail in two predictable ways: the HTML renders before JavaScript data arrives, or the file exists but is empty, truncated, or an HTML error page saved with a .pdf suffix. This guide builds a small, reliable pipeline on a hosted Mac that catches both before the report reaches a recipient.
For almost every HTML-to-PDF reporting workload, the right starting point is Figment's $29/month virtual Intel macOS environment (2 vCPU, 8 GiB RAM, 80 GiB disk). Rendering a dashboard to a PDF is CPU- and memory-bound during layout; it does not need Apple silicon, GPU acceleration, or audio. You access the environment through the browser console and run commands from its desktop terminal.
Consider moving up when the job outgrows the VM:
All plans are billed monthly in advance. A two-week project on the $29 VM is still one month of billing — there is no published daily rate. See Mac Hosting for current plan details.
| Tool | Install | Best for |
|---|---|---|
wkhtmltopdf | brew install wkhtmltopdf | URL or HTML file → PDF with JavaScript support |
macOS Shortcuts (shortcuts run) | Built-in | No-code multi-step flows; see Apple's Shortcuts CLI guide |
Headless Chrome (--print-to-pdf) | brew install --cask google-chrome | Modern CSS, strict JS timing control |
cupsfilter | Built-in | Converting already-printable formats |
wkhtmltopdf is the most predictable choice for scheduled jobs: headless, URL or file input, configurable JS delay, deterministic output. The upstream project is archived, so pin a known-good Homebrew version and check the wkhtmltopdf option reference — the default --javascript-delay is 200 ms, which is almost always too short for data-driven pages.
Save this to ~/bin/generate-report.sh and chmod +x it. It is the artifact for this page: every PDF is checked four ways before it is treated as a valid report.
#!/usr/bin/env bash
set -euo pipefail
REPORT_URL="${1:?Usage: generate-report.sh <url> <output.pdf>}"
OUTPUT_PDF="${2:?}"
LOG="${HOME}/Library/Logs/pdf-reports.log"
MIN_BYTES=10000 # Tune to the smallest valid report you have ever produced.
ts() { date -u +%FT%TZ; }
# 1. Render
if ! wkhtmltopdf \
--page-size A4 \
--javascript-delay 3000 \
--title "Scheduled Report $(date +%F)" \
--footer-center "Page [page] of [topage]" \
--footer-font-size 9 \
"$REPORT_URL" "$OUTPUT_PDF" >> "$LOG" 2>&1; then
echo "$(ts) FAIL render" >> "$LOG"; exit 1
fi
# 2. File present
[[ -f "$OUTPUT_PDF" ]] || { echo "$(ts) FAIL missing" >> "$LOG"; exit 1; }
# 3. Minimum size
BYTES=$(wc -c < "$OUTPUT_PDF" | tr -d ' ')
(( BYTES >= MIN_BYTES )) || { echo "$(ts) FAIL small ${BYTES}" >> "$LOG"; exit 1; }
# 4. PDF magic header (catches HTML error pages saved as .pdf)
[[ "$(head -c 5 "$OUTPUT_PDF")" == "%PDF-" ]] \
|| { echo "$(ts) FAIL header" >> "$LOG"; exit 1; }
echo "$(ts) OK ${OUTPUT_PDF} ${BYTES}" >> "$LOG"
| Check | What it catches |
|---|---|
| Exit code | Network failure, renderer crash |
| File present | Silent failure before any write |
| Minimum bytes | Empty page, whitespace-only output, truncation |
%PDF- header | Login page, 403/404, or proxy error written as .pdf |
This is an unexecuted example. Run it once by hand against your real URL before scheduling it.
Open the report URL in a browser, open DevTools → Network, reload, and note when the last XHR or fetch completes. Set --javascript-delay to that time plus a 500 ms buffer. Confirm by running the script manually and opening the PDF — if a chart is missing, increase the delay. Dashboards backed by APIs usually settle between 2,000 and 4,000 ms.
Place this at ~/Library/LaunchAgents/com.example.pdf-report.plist and load it with launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.pdf-report.plist.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.example.pdf-report</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/agent/bin/generate-report.sh</string>
<string>https://your-dashboard.example.com/weekly</string>
<string>/Users/agent/reports/weekly-$(date +%F).pdf</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Weekday</key><integer>1</integer>
<key>Hour</key><integer>7</integer>
<key>Minute</key><integer>0</integer>
</dict>
<key>StandardOutPath</key><string>/Users/agent/Library/Logs/pdf-reports.log</string>
<key>StandardErrorPath</key><string>/Users/agent/Library/Logs/pdf-reports-error.log</string>
</dict>
</plist>
launchd expands StartCalendarInterval as calendar wildcards — missing keys mean "any". For the exact semantics of Weekday, Day, and catch-up runs when the machine is asleep, read man launchd.plist on your Mac; the authoritative description lives there rather than in the archived HTML. Shell variables like $(date +%F) are not expanded inside the plist; compute the filename inside the script instead.
A concrete monthly check for this workload on the $29 VM:
top during a render shows sustained >80% CPU and the job regularly misses its window, price the i3 or the Managed Build Mac against the time you spend babysitting the current one.Homebrew and wkhtmltopdf are free. Any charting library, data API, or email provider you call has its own licence and quota — confirm those separately.
tail -n 50 ~/Library/Logs/pdf-reports.log — expect OK lines with stable byte counts.MIN_BYTES only after you understand why.launchctl list | grep pdf-report shows a non-zero exit, inspect pdf-reports-error.log before re-loading the agent.Open Mac Hosting, choose the $29 virtual Intel macOS plan (or request a quote for i3, M-series, or Managed Build), sign in to the browser console, install wkhtmltopdf with Homebrew, drop in the script and plist above, and run one manual render end-to-end before enabling the schedule.
Related guides: