
If PDFs arrive from design agencies, clients, or automated exports, you need a reproducible way to verify each file meets your spec before it hits print, a document system, or a downstream pipeline. A hosted Mac keeps PDFKit and Homebrew's pdfinfo and pdffonts running continuously, without you managing hardware or leaving files on a shared CI runner. Start with Figment's $29/month virtual Intel macOS environment (2 vCPU, 8 GiB RAM, 80 GiB disk) for file-based checks; move to the M-series tier from $95 when you need Apple silicon execution or heavier parallelism, and confirm the exact chip and RAM in your quote.
PDF page validation is CPU-light: reading page counts, dimensions, and font tables with pdfinfo and pdffonts typically takes well under a second per file. Figment's virtual Intel macOS environment at $29/month is the right fit for most document validation workloads. macOS ships with mdls for Spotlight metadata, and Homebrew provides pdfinfo and pdffonts from the poppler package with a single install.
Consider a different Figment plan when:
The i3 $49 and physical M-series tiers currently collect requests until supply is confirmed, so treat availability as part of the quote, not a stock promise. Access on the $29 VM is through the browser console, where you can open a desktop terminal and run brew, git, and the scripts below; outbound agents can connect to any CI service you configure.
Map quality requirements to concrete checks before writing code. This worksheet defines each check, the tool that runs it, and what failure means. Run it manually on 20 representative files from your source folder before automating anything — unexpected dimension variance and unembedded subset fonts almost always surface on production samples, not synthetic test PDFs.
| Check | Tool | Pass criterion | On fail |
|---|---|---|---|
| File is readable, not corrupted | pdfinfo exit code | Exit 0 | Reject; alert operator |
| Page count matches manifest | pdfinfo Pages field | Exact match to expected | Flag PAGE_COUNT_MISMATCH |
| No suspiciously small pages | pdfinfo -f 1 -l N Page size | Width ≥ 72 pt and height ≥ 72 pt on every page | Flag TINY_PAGE |
| Page dimensions within spec | pdfinfo Page size | Within ±2 pt of target | Flag to review when a target size is known |
| All fonts embedded | pdffonts emb column | No no entries | Flag UNEMBEDDED_FONTS |
| File is not encrypted | pdfinfo Encrypted field | no | Log and skip |
| At least one page | pdfinfo Pages field | Pages ≥ 1 | Reject EMPTY |
Manual acceptance procedure — ten minutes per batch, before you trust any automation:
pdfinfo <file> on each and record the Pages, Encrypted, and Page size lines. For PDFs with mixed page sizes, repeat with pdfinfo -f 1 -l <N> <file> to inspect every page — the default Page size line reports only the first page.pdffonts <file> on each and count rows where the emb column is no.pdf_check.sh below, and re-run the ten-file set through the script to confirm its output matches your hand answers.The check script needs:
brew install poppler.mdls -name kMDItemNumberOfPages can return page counts, but only once Spotlight has indexed the file. For automation where files are processed seconds after arrival, read the PDF directly with pdfinfo — it parses the file structure and does not depend on the index state.
Install poppler once: brew install poppler. Save the following as pdf_check.sh. This is an educational example, not an executed benchmark — run it against the worksheet samples above before trusting its output, and adjust the thresholds to your spec.
#!/usr/bin/env bash
# pdf_check.sh — page count, dimensions, and font check for a directory of PDFs
# Usage: ./pdf_check.sh <input-dir> <results-dir> [expected-pages]
set -euo pipefail
INPUT_DIR="$1"
RESULTS_DIR="$2"
EXPECTED_PAGES="${3:-}"
mkdir -p "$RESULTS_DIR"
REPORT="${RESULTS_DIR}/report_$(date +%Y%m%d_%H%M%S)_$$.tsv"
printf 'file\tpages\twidth_pt\theight_pt\tunembedded_fonts\tencrypted\tstatus\n' \
> "$REPORT"
check_pdf() {
local pdf="$1"
local name status="OK"
name=$(basename "$pdf")
local info
if ! info=$(pdfinfo "$pdf" 2>&1); then
printf '%s\t-\t-\t-\t-\t-\tPDFINFO_ERROR\n' "$name" >> "$REPORT"
return 0
fi
local pages encrypted width_pt height_pt
pages=$(printf '%s\n' "$info" | awk '/^Pages:/{print $2; exit}')
encrypted=$(printf '%s\n' "$info" | awk '/^Encrypted:/{print $2; exit}')
# "Page size: 595.28 x 841.89 pts (A4)" — first-page dimensions only.
width_pt=$(printf '%s\n' "$info" | awk '/^Page size:/{print $3; exit}')
height_pt=$(printf '%s\n' "$info" | awk '/^Page size:/{print $5; exit}')
[[ "${pages:-0}" -eq 0 ]] && status="EMPTY"
[[ "${encrypted:-no}" != "no" ]] && status="${status};ENCRYPTED"
[[ -n "$EXPECTED_PAGES" && "${pages:-0}" != "$EXPECTED_PAGES" ]] \
&& status="${status};PAGE_COUNT_MISMATCH"
local w_int h_int
w_int=$(awk -v v="${width_pt:-0}" 'BEGIN{printf "%d", v}')
h_int=$(awk -v v="${height_pt:-0}" 'BEGIN{printf "%d", v}')
[[ "$w_int" -lt 72 || "$h_int" -lt 72 ]] && status="${status};TINY_PAGE"
# pdffonts type can be 1-3 words ("TrueType", "Type 1", "CID Type 0C"),
# so counting fields from the right is unreliable. Find the 'emb'
# column's byte offset in the header, then read the same offset on
# each data row.
local unembedded
unembedded=$(pdffonts "$pdf" 2>/dev/null | awk '
NR==1 { c = index($0, "emb"); next }
NR==2 { next }
c && substr($0, c, 3) == "no " { n++ }
END { print n+0 }
') || unembedded=0
unembedded="${unembedded:-0}"
[[ "$unembedded" -gt 0 ]] && status="${status};UNEMBEDDED_FONTS"
status="${status#;}"
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$name" "${pages:-0}" "${width_pt:--}" "${height_pt:--}" \
"$unembedded" "${encrypted:-unknown}" "$status" \
>> "$REPORT"
}
find "$INPUT_DIR" -type f -name '*.pdf' -print0 \
| while IFS= read -r -d '' pdf; do
check_pdf "$pdf" || printf '%s\t-\t-\t-\t-\t-\tSCRIPT_ERROR\n' \
"$(basename "$pdf")" >> "$REPORT"
done
echo "Report written to $REPORT"
Make it executable and run against the small sample from the worksheet:
chmod +x ~/scripts/pdf_check.sh
~/scripts/pdf_check.sh ~/pdf-samples ~/pdf-results 2
column -t -s $'\t' ~/pdf-results/report_*.tsv | head -20
The TSV opens directly in Numbers, imports into Airtable, or feeds a Slack alert step. Each row is one file; the status column carries semicolon-delimited failure codes so you can grep for specific problems:
grep 'UNEMBEDDED_FONTS' ~/pdf-results/report_*.tsv
If your PDFs may contain pages with mixed dimensions, replace the single Page size read with a loop that invokes pdfinfo -f N -l N for each page index from 1 through Pages, and apply the tiny-page and spec checks to every page before writing the row.
Use the QueueDirectories key to process files as they arrive in a spool folder. Apple's documentation states that launchd keeps the job running while the listed directories are non-empty and stops it when they drain.
<?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.yourdomain.pdf-check</string>
<key>ProgramArguments</key>
<array>
<string>/Users/agent/scripts/pdf_check.sh</string>
<string>/Users/agent/pdf-spool</string>
<string>/Users/agent/pdf-results</string>
</array>
<key>QueueDirectories</key>
<array>
<string>/Users/agent/pdf-spool</string>
</array>
<key>StandardOutPath</key>
<string>/Users/agent/Library/Logs/pdf_check/out.log</string>
<key>StandardErrorPath</key>
<string>/Users/agent/Library/Logs/pdf_check/err.log</string>
</dict>
</plist>
Save as ~/Library/LaunchAgents/com.yourdomain.pdf-check.plist and load with launchctl load ~/Library/LaunchAgents/com.yourdomain.pdf-check.plist. launchd starts the job whenever the spool directory is non-empty and stops when it drains — no polling loop and no wasted CPU between batches.
PDFINFO_ERROR — the file is corrupted or truncated. Move it to a quarantine/ folder and alert. Do not retry blindly; a truncated upload may still be in flight, so pair this with a size-stable check (same byte count across two reads 10 seconds apart) before processing.
Mid-batch crash — keep the job idempotent by appending each successful filename to a processed.txt manifest. On restart, skip filenames already in the manifest with a grep -qF check.
Encrypted files — log and skip rather than crash the batch. If encrypted PDFs in the spool indicate a producer-side error, route them to a separate alert rather than the common rejects.
Disk fill — the 80 GiB VM disk also holds macOS, Homebrew, and your tooling, so treat only part of that as usable spool and results space. Sync completed reports off-host and move originals to a completed folder or your own storage immediately after each batch.
| Volume per day | Avg file size | Approx run time | Candidate plan |
|---|---|---|---|
| 200 PDFs | 2–5 MB | under 5 min | $29 virtual Intel macOS |
| 1,000 PDFs | 2–5 MB | 15–25 min | $29 virtual Intel macOS |
| 1,000 PDFs | 50 MB | 45–90 min | i3 $49 (request availability) |
| 5,000 PDFs | 20 MB | 2–4 hours | i3 $49 or M-series from $95 (request) |
These are planning estimates, not measured benchmarks. Run the script on 50 representative files from your production set and record the wall time before choosing a plan: pdffonts on a heavily fonted 100-page print file takes noticeably longer than a two-page invoice.
brew install poppler.pdf_check.sh into ~/scripts/ through the browser console session, or clone it from your Git repository in the VM's desktop terminal.mkdir ~/pdf-spool ~/pdf-results.~/pdf-spool and confirm a report_*.tsv lands in ~/pdf-results.Configure your plan and start →
Related guides: Run a Mac API agent runtime · Reboot recovery for Mac agents · Self-hosted GitHub Actions runner on Mac