In-house PDF generation service for Mazuma Service Co., Ltd., replacing CraftMyPDF. Generates Thai-language PDF documents (ใบยืมสินค้า, ใบสั่งงานบริการ) via REST API.
Stack: NestJS 11 · TypeScript · Puppeteer · Handlebars · Bai Jamjuree font (base64 embedded)
git clone <repo-url>
cd pdf-generator
npm install
npm run start:devServer starts at http://localhost:3000. No .env needed for dev — all auth is disabled when env vars are unset.
Verify:
curl http://localhost:3000/health
# → { "status": "ok", "uptime": ..., "timestamp": "..." }| Template | Description | Pages |
|---|---|---|
borrowing-slip |
ใบยืมสินค้า/อะไหล่ | 1+ (auto-paginate) |
service-order |
ใบสั่งงานบริการ | 2 |
Template files: templates/*.html — edit directly, restart server to reload.
Render a named template or raw HTML to PDF.
Headers:
Content-Type: application/jsonX-API-Key: <key>(production only — omit in dev whenPDF_API_KEYis unset)
Request body:
{ "template": "borrowing-slip", "data": { "documentNo": "2511BR000245", "..." } }{ "html": "<!DOCTYPE html>...", "data": { "title": "Test" } }Query parameters:
| Parameter | Response |
|---|---|
| (none) | 201 — Save PDF to output/, return { success, fileName, fileUrl, fileSize } |
?output=stream |
200 — PDF binary (application/pdf) |
?output=html |
200 — Rendered HTML (for debugging layout in browser) |
Stream example:
curl -X POST "http://localhost:3000/pdf/render?output=stream" \
-H "Content-Type: application/json" \
-d "{\"template\":\"borrowing-slip\",\"data\":$(cat test/fixtures/borrowing-slip.fixture.json)}" \
--output borrowing-slip.pdfSave to file example:
curl -X POST http://localhost:3000/pdf/render \
-H "Content-Type: application/json" \
-d "{\"template\":\"service-order\",\"data\":$(cat test/fixtures/service-order.fixture.json)}"
# → { "success": true, "fileName": "service-order-abc123-1234567890.pdf", "fileUrl": "http://...", "fileSize": 175000 }Browser preview — renders a named template with its fixture file (test/fixtures/<template>.fixture.json) and returns HTML. Useful for fast layout iteration without generating a PDF.
open http://localhost:3000/pdf/preview/borrowing-slipDownload a previously saved PDF. Files are auto-purged after 24 hours.
curl -o result.pdf "http://localhost:3000/pdf/files/service-order-abc123-1234567890.pdf"Health check — no auth required.
curl http://localhost:3000/health- Create
templates/my-doc.html— seedocs/TEMPLATE_GUIDE.mdfor full syntax reference - Restart the server (templates compile at startup)
- Test:
curl -X POST "http://localhost:3000/pdf/render?output=stream" \ -H "Content-Type: application/json" \ -d '{"template":"my-doc","data":{"title":"Test"}}' \ --output test.pdf
- Import
docs/pdf-generator.postman_collection.jsoninto Postman for interactive testing
Template structure (minimal):
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="/assets/pdf-base.css">
<style>
/* ── Config ── */
:root {
--pg-h: 8mm; /* left/right padding */
--pg-bottom: 20mm; /* ⚠ SYNC with @page below and pdf-options margin.bottom */
--brand: #1a9e96;
}
/* Outer table: <thead> repeats the document header on every page natively */
.page-layout { width: 100%; border-collapse: collapse; table-layout: fixed; }
.page-header { padding: 6mm var(--pg-h) 0 var(--pg-h); }
.page-content { padding: 4mm var(--pg-h) 8mm var(--pg-h); vertical-align: top; }
@media print {
@page { margin-bottom: 20mm; } /* ⚠ SYNC with --pg-bottom */
.items-table tbody tr { page-break-inside: avoid; }
}
</style>
</head>
<body>
<table class="page-layout">
<thead>
<tr><td class="page-header"><!-- logo, company name, doc number --></td></tr>
</thead>
<tbody>
<tr><td class="page-content">
<p>{{customerName}}</p>
<p>{{dateFormat openDate}}</p>
<table class="items-table">
<thead><tr><!-- column headers --></tr></thead>
<tbody>{{#each items}}<tr><!-- row --></tr>{{/each}}</tbody>
</table>
</td></tr>
</tbody>
</table>
<script type="application/pdf-options">
{ "margin": { "top": "0", "right": "0", "bottom": "20mm", "left": "0" } }
</script>
</body>
</html>See docs/TEMPLATE_GUIDE.md for the complete guide: Handlebars helpers, Puppeteer PDF options, multi-page layout, QR codes, and more.
| Helper | Usage | Output |
|---|---|---|
dateFormat |
{{dateFormat isoDate}} |
18/11/2568 (Buddhist year) |
dateTimeFormat |
{{dateTimeFormat isoDate}} |
18/11/2568 14:30 |
timeFormat |
{{timeFormat isoDate}} |
14:30 |
numberFormat |
{{numberFormat amount 2}} |
1,000.00 (Thai locale) |
valueOrDash |
{{valueOrDash field}} |
- if null/empty/undefined |
checkMark |
{{checkMark bool}} |
✓ or empty |
inc |
{{inc @index}} |
1-based loop counter |
eq, gt, or |
{{#if (eq a b)}} |
comparison/logic |
Special: Pass qrCodeContent in data → server auto-generates qrCodeDataUri for use in template:
Templates can control all Puppeteer PDF settings by embedding a JSON block in <body>:
<script type="application/pdf-options">
{
"footerTemplate": "<div style='font-family:Bai Jamjuree,sans-serif;font-size:9px;color:#555;width:100%;display:flex;justify-content:space-between;padding:0 8mm;box-sizing:border-box;'><span>{{documentNo}}</span><span><span class='pageNumber'></span> / <span class='totalPages'></span></span></div>",
"margin": { "top": "0", "right": "0", "bottom": "12mm", "left": "0" }
}
</script>This block is extracted and passed directly to Puppeteer, then stripped from the HTML before rendering. Handlebars variables inside the JSON are resolved before extraction. Bai Jamjuree font is auto-injected into header/footer templates server-side.
Default (when no block present): page number N / M at bottom-right, margin.bottom: 8mm.
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Server port |
PDF_API_KEY |
(empty) | API key for all endpoints — leave empty for dev (no auth) |
CORS_ORIGINS |
(empty) | Allowed CORS origins (comma-separated) — empty = allow all |
THROTTLE_TTL |
60 |
Rate limit window (seconds) |
THROTTLE_LIMIT |
30 |
Max requests per window |
PDF_MAX_CONCURRENT |
5 |
Max Chrome pages open simultaneously (see sizing guide in .env.example) |
PDF_MAX_QUEUE |
20 |
Max requests queued while all slots are busy (returns 503 when full) |
PDF_QUEUE_TIMEOUT_MS |
60000 |
How long a queued request waits before timing out (ms) |
cp .env.example .env# Generate a secure API key
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# .env
PDF_API_KEY=<generated-key>
CORS_ORIGINS=https://your-app.com
# Build and start
npm run build
npm startAll requests to POST /pdf/render and GET /pdf/files/:fileName now require X-API-Key: <key> header.
# Copy and configure env
cp .env.example .env
# Build and start
docker compose up -d --build
# Stop
docker compose downEnvironment variables — see .env.example for full reference. Key ones:
| Variable | Description |
|---|---|
HOST_PORT |
Port exposed on the host machine (default: 3000) |
PORT |
Port the app listens on inside the container (default: 3000) |
PDF_API_KEY |
API key for all endpoints — leave empty for dev (no auth) |
Generated PDFs are stored in a named Docker volume (pdf-output) and auto-purged after 24h.
Pushing to main automatically deploys to VPS via GitHub Actions (.github/workflows/deploy.yml).
Required GitHub Secrets (Settings → Secrets and variables → Actions):
| Secret | Description |
|---|---|
VPS_HOST |
VPS IP or domain |
VPS_USER |
SSH username |
VPS_PASSWORD |
SSH password |
VPS_PORT |
SSH port (usually 22) |
APP_DIR |
App directory on VPS (e.g. /root/pdf-generator) |
First-time VPS setup:
git clone https://github.com/zierocode/pdf-generator.git /root/pdf-generator
cd /root/pdf-generator
cp .env.example .env
# edit .env — set PDF_API_KEY, HOST_PORT, etc.
docker compose up -d --buildAfter that, every git push origin main triggers an automatic redeploy.
templates/ ← HTML templates (edit here)
borrowing-slip.html ← ใบยืมสินค้า/อะไหล่
service-order.html ← ใบสั่งงานบริการ (2 pages)
assets/
pdf-base.css ← shared fonts + CSS (generated — do not edit)
images/mazuma-logo.png
fonts/Bai Jamjuree/ ← source TTF files
scripts/
build-pdf-base-css.js ← regenerate assets/pdf-base.css (run when fonts change)
src/modules/pdf/
pdf.controller.ts ← POST /pdf/render, GET /pdf/files/:fileName
pdf.service.ts ← render() — QR generation + font injection
template-renderer.service.ts ← Handlebars compile + asset inlining + pdf-options extraction
browser-pool.service.ts ← Puppeteer (single instance, 30s timeout, auto-reconnect)
file-storage.service.ts ← output/ dir, UUID + timestamp filenames, 24h auto-purge
pdf.module.ts
test/fixtures/ ← sample JSON payloads for curl testing
borrowing-slip.fixture.json
service-order.fixture.json
docs/
TEMPLATE_GUIDE.md ← full guide: create → test → production
pdf-generator.postman_collection.json
output/ ← generated PDFs (auto-purged after 24h, gitignored)
npm run start:dev # Dev server (watch mode)
npm run start # Production server
npm run build # Compile TypeScript (nest build)
npm run lint # ESLint + Prettier (auto-fix)
npm test # Jest unit tests
node scripts/build-pdf-base-css.js # Regenerate pdf-base.css (after font changes)