diff --git a/README.md b/README.md index e08a4bb..ebeb9aa 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Read-only by design — `zfy` cannot modify your Zeffy data, because the officia - [Quick start (5 minutes)](#quick-start-5-minutes) - [CLI commands](#cli-commands) - [End-of-year report](#end-of-year-report) + - [What the PDF receipts look like](#what-the-pdf-receipts-look-like) - [Logo spec for `--logo`](#logo-spec-for---logo) - [Optional: use from Claude or other AI agents (MCP)](#optional-use-from-claude-or-other-ai-agents-mcp) - [SDK usage](#sdk-usage) @@ -165,7 +166,7 @@ zfy report eoy --year 2025 --format md --out eoy-2025.md --top 50 # One PDF receipt per donor (with optional logo) zfy report eoy --year 2025 --format pdf --out ./receipts/ \ --org "Friends of the Library" \ - --logo ./logo.png --logo-size 64 \ + --logo ./logo.png --logo-size 48 \ --timezone America/Los_Angeles ``` @@ -180,11 +181,21 @@ zfy report eoy --year 2025 --format pdf --out ./receipts/ \ | `--top ` | Markdown: limit donor table. | | `--org ` | PDF: organization name in the header. | | `--logo ` | PDF: square PNG/JPEG mark — see spec below. | -| `--logo-size ` | PDF: edge length of the logo slot in [PDF points](https://en.wikipedia.org/wiki/Point_(typography)) (default 64; 72 pt ≈ 1 inch). | +| `--logo-size ` | PDF: edge length of the logo slot in [PDF points](https://en.wikipedia.org/wiki/Point_(typography)) (default 48; 72 pt ≈ 1 inch). | | `--receipt-text ` | PDF: override the default tax-receipt boilerplate. | Run `zfy report eoy --help` for the complete list. +#### What the PDF receipts look like + +

+ + Sample EOY donation receipt rendered by zfy + +
+ Download the sample PDF · regenerate with pnpm build && node examples/gen-sample-receipt.mjs +

+ ### Logo spec for `--logo` The PDF renderer reserves a small square slot for an org mark. Logos must meet these constraints — otherwise zfy prints a warning to stderr and falls back to text-only (no crash, batch keeps running): diff --git a/examples/gen-sample-receipt.mjs b/examples/gen-sample-receipt.mjs new file mode 100644 index 0000000..28574d9 --- /dev/null +++ b/examples/gen-sample-receipt.mjs @@ -0,0 +1,50 @@ +// Generates a sample EOY receipt PDF using the real renderer. +// Requires the package to be built first (imports from ../dist/sdk). +// Run: pnpm build && node examples/gen-sample-receipt.mjs [outPath] +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { writeFile } from "node:fs/promises"; +import { renderDonorPdf } from "../dist/sdk/index.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const out = process.argv[2] ?? path.join(__dirname, "sample-receipt.pdf"); + +const donor = { + contact_id: "ct_demo", + name: "Jordan Rivera", + email: "jordan.rivera@example.org", + address: { + line1: "1428 Maple Street", + line2: "Apt 3B", + city: "Portland", + state: "OR", + postal_code: "97214", + country: "United States", + }, + total_amount: 1450, + donation_count: 6, + payments: [ + { id: "p1", date: "2025-01-15T00:00:00.000Z", amount: 250, campaign: "Winter Appeal" }, + { id: "p2", date: "2025-03-02T00:00:00.000Z", amount: 100, campaign: "General Fund" }, + { id: "p3", date: "2025-05-20T00:00:00.000Z", amount: 500, campaign: "Library Renovation" }, + { id: "p4", date: "2025-08-11T00:00:00.000Z", amount: 100, campaign: "General Fund" }, + { id: "p5", date: "2025-10-04T00:00:00.000Z", amount: 250, campaign: "Fall Membership Drive" }, + { id: "p6", date: "2025-12-22T00:00:00.000Z", amount: 250, campaign: "Year-End Match" }, + ], +}; + +const report = { + year: 2025, + timezone: "America/Los_Angeles", + currency: "USD", + generated_at: "2026-01-15T17:00:00.000Z", + totals: { total_amount: 1450, donor_count: 1, donation_count: 6 }, + donors: [donor], +}; + +const buf = await renderDonorPdf(donor, report, { + orgName: "Friends of the Library", + logoPath: path.join(__dirname, "sample-logo.png"), +}); +await writeFile(out, buf); +console.log(`Wrote ${out} (${buf.length} bytes)`); diff --git a/examples/sample-receipt.pdf b/examples/sample-receipt.pdf new file mode 100644 index 0000000..94a2b88 Binary files /dev/null and b/examples/sample-receipt.pdf differ diff --git a/examples/sample-receipt.png b/examples/sample-receipt.png new file mode 100644 index 0000000..56737b3 Binary files /dev/null and b/examples/sample-receipt.png differ diff --git a/src/cli/report.ts b/src/cli/report.ts index 1f434e3..fe257b8 100644 --- a/src/cli/report.ts +++ b/src/cli/report.ts @@ -27,7 +27,7 @@ export function reportCommand(): Command { .option("--top ", "Markdown: limit donor table to top N", (v) => Number(v)) .option("--org ", "PDF: organization name for the header") .option("--logo ", "PDF: path to a square PNG/JPEG logo (~512×512, max 2 MB)") - .option("--logo-size ", "PDF: edge length of the square logo slot in points (default 64)", (v) => Number(v)) + .option("--logo-size ", "PDF: edge length of the square logo slot in points (default 48)", (v) => Number(v)) .option("--receipt-text ", "PDF: override receipt boilerplate") .action(async (opts: Record) => { const year = opts["year"] as number; diff --git a/src/report/formats/pdf.ts b/src/report/formats/pdf.ts index 8039c56..95a2538 100644 --- a/src/report/formats/pdf.ts +++ b/src/report/formats/pdf.ts @@ -10,7 +10,7 @@ export interface PdfOptions { receiptText?: string; /** Path to a square PNG or JPEG logo. See validateLogo() for the spec. */ logoPath?: string; - /** Edge length in pt for the square logo slot (default 64). */ + /** Edge length in pt for the square logo slot (default 48). */ logoSize?: number; /** Stream to write validation warnings to. Defaults to process.stderr. */ warnStream?: { write(s: string): void }; @@ -19,7 +19,7 @@ export interface PdfOptions { const LOGO_MAX_BYTES = 2 * 1024 * 1024; const LOGO_MIN_PIXELS = 64; const LOGO_ASPECT_TOLERANCE = 0.1; // ±10% off 1:1 is still accepted as "square" -const DEFAULT_LOGO_SIZE = 64; +const DEFAULT_LOGO_SIZE = 48; export type LogoValidation = | { ok: true; width: number; height: number; format: "png" | "jpeg" } @@ -177,6 +177,7 @@ export function renderDonorPdf( const headerY = doc.y; const logoSize = opts.logoSize ?? DEFAULT_LOGO_SIZE; + const logoGutter = opts.logoPath ? logoSize + 14 : 0; if (opts.logoPath) { try { doc.image(opts.logoPath, left, headerY, { fit: [logoSize, logoSize] }); @@ -185,21 +186,27 @@ export function renderDonorPdf( } } + const textX = left + logoGutter; + const textWidth = usable - logoGutter; doc .font("Helvetica-Bold") .fontSize(20) .fillColor("black") - .text(orgName, left + (opts.logoPath ? logoSize + 14 : 0), headerY + 4, { - width: usable, + .text(orgName, textX, headerY + 4, { + width: textWidth, }); doc .font("Helvetica") .fontSize(9) .fillColor(MUTED) - .text(`OFFICIAL DONATION RECEIPT · ${report.year}`, { + .text(`OFFICIAL DONATION RECEIPT · ${report.year}`, textX, doc.y, { + width: textWidth, characterSpacing: 1.2, }); + // Make sure the next section starts below the logo, not on top of it. + doc.x = left; + doc.y = Math.max(doc.y, headerY + logoSize); doc.moveDown(1.5); doc.fillColor("black");