DocFast MVP: HTML/Markdown to PDF API with invoice templates
This commit is contained in:
parent
789b3bfeeb
commit
77ec1c5524
24 changed files with 5010 additions and 22 deletions
|
|
@ -1,21 +1,24 @@
|
||||||
# Decisions Log
|
# Decisions Log
|
||||||
|
|
||||||
## 2026-02-14 — Phase 0: Business Model Analysis
|
## 2026-02-14 — Product: PDF API (approved by user)
|
||||||
|
Chose HTML/Markdown to PDF API over webhook relay and JSON schema validation. Fastest to ship, proven demand, clear monetization.
|
||||||
|
|
||||||
### Constraints Assessed
|
## 2026-02-14 — Name: DocFast
|
||||||
- €200 budget, no human identity for accounts/payments
|
Product name "DocFast" — short, memorable, describes value prop (fast document generation). Domain TBD.
|
||||||
- Must be fully automatable by AI agent
|
|
||||||
- Digital products only
|
|
||||||
- Human partner handles: domain purchases, account creation, payment setup
|
|
||||||
- Available tech: Go, TypeScript, Python; Forgejo for git; can deploy on existing infra
|
|
||||||
|
|
||||||
### Models Evaluated & Rejected
|
## 2026-02-14 — Tech Stack: TypeScript + Express + Puppeteer
|
||||||
- **Content/SEO sites**: Too slow to monetize, saturated, requires ongoing content
|
- TypeScript for type safety and fast iteration
|
||||||
- **Freelance marketplace**: Requires human identity, client calls
|
- Express for simplicity (no framework overhead)
|
||||||
- **Course/info product**: Need audience first, slow ramp
|
- Puppeteer for high-fidelity PDF rendering (Chrome-based = pixel-perfect)
|
||||||
- **Chrome extension**: App store accounts need identity, review process unpredictable
|
- Marked for markdown parsing
|
||||||
- **Newsletter**: Audience building too slow for €200 budget
|
- Chose Puppeteer over wkhtmltopdf/pdfkit for better CSS support and template rendering quality
|
||||||
- **Marketplace/platform**: Chicken-and-egg problem, needs critical mass
|
|
||||||
|
|
||||||
### Models Shortlisted (3 proposals)
|
## 2026-02-14 — Templates: Invoice + Receipt first
|
||||||
See proposals below — sent to user for approval.
|
Built-in invoice and receipt templates as differentiator. Most PDF API competitors are raw converters — templates add immediate value for common use cases.
|
||||||
|
|
||||||
|
## 2026-02-14 — Pricing model (planned)
|
||||||
|
- Free tier: 100 PDFs/month
|
||||||
|
- Pro: $9/mo for 5,000 PDFs
|
||||||
|
- Business: $29/mo for 25,000 PDFs
|
||||||
|
- Enterprise: custom
|
||||||
|
Not yet implemented — need Stripe integration.
|
||||||
|
|
|
||||||
|
|
@ -22,3 +22,16 @@
|
||||||
- **Status:** Proposals v2 written, awaiting human decision
|
- **Status:** Proposals v2 written, awaiting human decision
|
||||||
- **Next:** Build MVP once product is chosen
|
- **Next:** Build MVP once product is chosen
|
||||||
- **Blocker:** Need human to pick a product direction
|
- **Blocker:** Need human to pick a product direction
|
||||||
|
|
||||||
|
## Session 3 — 2026-02-14 12:24 UTC (Morning Session 1)
|
||||||
|
- Phase 1: Build MVP — **core API complete and tested**
|
||||||
|
- Built "DocFast API" in TypeScript + Express + Puppeteer
|
||||||
|
- Endpoints working: HTML→PDF, Markdown→PDF, Invoice template, Receipt template
|
||||||
|
- Features: API key auth, rate limiting (100/min), helmet security headers
|
||||||
|
- All endpoints tested locally — HTML (11KB), Markdown (17KB), Invoice (25KB) PDFs generated successfully
|
||||||
|
- Added Dockerfile, README with full API docs
|
||||||
|
- Installed Chrome dependencies on VM for Puppeteer
|
||||||
|
- **Tech stack:** TypeScript, Express, Puppeteer, Marked
|
||||||
|
- **Status:** Core MVP functional, needs deployment
|
||||||
|
- **Next:** Ask human to create Forgejo repo, decide on hosting, add tests, build landing page
|
||||||
|
- **Blockers:** Need git repo + hosting
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,21 @@
|
||||||
{
|
{
|
||||||
"phase": 1,
|
"phase": 1,
|
||||||
"phaseLabel": "Build MVP",
|
"phaseLabel": "Build MVP",
|
||||||
"status": "approved",
|
"status": "in-progress",
|
||||||
"product": "HTML/Markdown to PDF API",
|
"product": "DocFast - HTML/Markdown to PDF API",
|
||||||
"currentPriority": "Build the MVP. Markdown/HTML in, PDF out. Include built-in invoice templates as differentiator. Ship fast, test everything. Ask human to create a git repo when ready to push code.",
|
"currentPriority": "Deploy MVP. Need git repo on Forgejo and hosting. Add landing page. Consider adding more templates (letter, report). Write tests.",
|
||||||
"humanFeedback": "User approved PDF API. Said 'lets go with the agent recommendation'. Start building.",
|
"mvpStatus": {
|
||||||
"blockers": [],
|
"core": "done",
|
||||||
|
"endpoints": ["POST /v1/convert/html", "POST /v1/convert/markdown", "POST /v1/templates/:id/render", "GET /v1/templates"],
|
||||||
|
"templates": ["invoice", "receipt"],
|
||||||
|
"auth": "done",
|
||||||
|
"rateLimiting": "done",
|
||||||
|
"docker": "done",
|
||||||
|
"tests": "todo",
|
||||||
|
"landingPage": "todo",
|
||||||
|
"deployment": "todo"
|
||||||
|
},
|
||||||
|
"blockers": ["Need Forgejo repo created", "Need hosting decision (VPS or user's infra?)"],
|
||||||
"startDate": "2026-02-14",
|
"startDate": "2026-02-14",
|
||||||
"sessionCount": 3
|
"sessionCount": 4
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
projects/business/src/pdf-api/Dockerfile
Normal file
19
projects/business/src/pdf-api/Dockerfile
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
# Chrome deps
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libatk1.0-0t64 libatk-bridge2.0-0t64 libcups2t64 libdrm2 \
|
||||||
|
libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
|
||||||
|
libgbm1 libpango-1.0-0 libpangocairo-1.0-0 libcairo2 \
|
||||||
|
libasound2t64 libnspr4 libnss3 fonts-liberation \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY dist/ dist/
|
||||||
|
|
||||||
|
ENV PORT=3100
|
||||||
|
EXPOSE 3100
|
||||||
|
USER node
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
62
projects/business/src/pdf-api/README.md
Normal file
62
projects/business/src/pdf-api/README.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# DocFast API
|
||||||
|
|
||||||
|
Fast, simple HTML/Markdown to PDF API with built-in invoice templates.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
API_KEYS=your-key-here npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Convert HTML to PDF
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3100/v1/convert/html \
|
||||||
|
-H "Authorization: Bearer YOUR_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"html": "<h1>Hello</h1><p>World</p>"}' \
|
||||||
|
-o output.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
### Convert Markdown to PDF
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3100/v1/convert/markdown \
|
||||||
|
-H "Authorization: Bearer YOUR_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"markdown": "# Hello\n\nWorld"}' \
|
||||||
|
-o output.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
### Invoice Template
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3100/v1/templates/invoice/render \
|
||||||
|
-H "Authorization: Bearer YOUR_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"invoiceNumber": "INV-001",
|
||||||
|
"date": "2026-02-14",
|
||||||
|
"from": {"name": "Your Company", "email": "you@example.com"},
|
||||||
|
"to": {"name": "Client", "email": "client@example.com"},
|
||||||
|
"items": [{"description": "Service", "quantity": 1, "unitPrice": 100, "taxRate": 20}]
|
||||||
|
}' \
|
||||||
|
-o invoice.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
- `format`: Paper size (A4, Letter, Legal, etc.)
|
||||||
|
- `landscape`: true/false
|
||||||
|
- `margin`: `{top, right, bottom, left}` in CSS units
|
||||||
|
- `css`: Custom CSS (for markdown/html fragments)
|
||||||
|
- `filename`: Suggested filename in Content-Disposition header
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
Pass API key via `Authorization: Bearer <key>`. Set `API_KEYS` env var (comma-separated for multiple keys).
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
```bash
|
||||||
|
docker build -t docfast .
|
||||||
|
docker run -p 3100:3100 -e API_KEYS=your-key docfast
|
||||||
|
```
|
||||||
62
projects/business/src/pdf-api/dist/index.js
vendored
Normal file
62
projects/business/src/pdf-api/dist/index.js
vendored
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"use strict";
|
||||||
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.app = void 0;
|
||||||
|
const express_1 = __importDefault(require("express"));
|
||||||
|
const helmet_1 = __importDefault(require("helmet"));
|
||||||
|
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
|
||||||
|
const convert_js_1 = require("./routes/convert.js");
|
||||||
|
const templates_js_1 = require("./routes/templates.js");
|
||||||
|
const health_js_1 = require("./routes/health.js");
|
||||||
|
const auth_js_1 = require("./middleware/auth.js");
|
||||||
|
const browser_js_1 = require("./services/browser.js");
|
||||||
|
const app = (0, express_1.default)();
|
||||||
|
exports.app = app;
|
||||||
|
const PORT = parseInt(process.env.PORT || "3100", 10);
|
||||||
|
app.use((0, helmet_1.default)());
|
||||||
|
app.use(express_1.default.json({ limit: "2mb" }));
|
||||||
|
app.use(express_1.default.text({ limit: "2mb", type: "text/*" }));
|
||||||
|
// Rate limiting: 100 req/min for free tier
|
||||||
|
const limiter = (0, express_rate_limit_1.default)({
|
||||||
|
windowMs: 60_000,
|
||||||
|
max: 100,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
app.use(limiter);
|
||||||
|
// Public
|
||||||
|
app.use("/health", health_js_1.healthRouter);
|
||||||
|
// Authenticated
|
||||||
|
app.use("/v1/convert", auth_js_1.authMiddleware, convert_js_1.convertRouter);
|
||||||
|
app.use("/v1/templates", auth_js_1.authMiddleware, templates_js_1.templatesRouter);
|
||||||
|
// Root
|
||||||
|
app.get("/", (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
name: "DocFast API",
|
||||||
|
version: "0.1.0",
|
||||||
|
docs: "/health",
|
||||||
|
endpoints: [
|
||||||
|
"POST /v1/convert/html",
|
||||||
|
"POST /v1/convert/markdown",
|
||||||
|
"POST /v1/templates/:id/render",
|
||||||
|
"GET /v1/templates",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
async function start() {
|
||||||
|
await (0, browser_js_1.initBrowser)();
|
||||||
|
app.listen(PORT, () => console.log(`DocFast API running on :${PORT}`));
|
||||||
|
const shutdown = async () => {
|
||||||
|
console.log("Shutting down...");
|
||||||
|
await (0, browser_js_1.closeBrowser)();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
}
|
||||||
|
start().catch((err) => {
|
||||||
|
console.error("Failed to start:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
17
projects/business/src/pdf-api/dist/middleware/auth.js
vendored
Normal file
17
projects/business/src/pdf-api/dist/middleware/auth.js
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.authMiddleware = authMiddleware;
|
||||||
|
const API_KEYS = new Set((process.env.API_KEYS || "test-key-123").split(",").map((k) => k.trim()));
|
||||||
|
function authMiddleware(req, res, next) {
|
||||||
|
const header = req.headers.authorization;
|
||||||
|
if (!header?.startsWith("Bearer ")) {
|
||||||
|
res.status(401).json({ error: "Missing API key. Use: Authorization: Bearer <key>" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = header.slice(7);
|
||||||
|
if (!API_KEYS.has(key)) {
|
||||||
|
res.status(403).json({ error: "Invalid API key" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
60
projects/business/src/pdf-api/dist/routes/convert.js
vendored
Normal file
60
projects/business/src/pdf-api/dist/routes/convert.js
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.convertRouter = void 0;
|
||||||
|
const express_1 = require("express");
|
||||||
|
const browser_js_1 = require("../services/browser.js");
|
||||||
|
const markdown_js_1 = require("../services/markdown.js");
|
||||||
|
exports.convertRouter = (0, express_1.Router)();
|
||||||
|
// POST /v1/convert/html
|
||||||
|
exports.convertRouter.post("/html", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = typeof req.body === "string" ? { html: req.body } : req.body;
|
||||||
|
if (!body.html) {
|
||||||
|
res.status(400).json({ error: "Missing 'html' field" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Wrap bare HTML fragments
|
||||||
|
const fullHtml = body.html.includes("<html")
|
||||||
|
? body.html
|
||||||
|
: (0, markdown_js_1.wrapHtml)(body.html, body.css);
|
||||||
|
const pdf = await (0, browser_js_1.renderPdf)(fullHtml, {
|
||||||
|
format: body.format,
|
||||||
|
landscape: body.landscape,
|
||||||
|
margin: body.margin,
|
||||||
|
printBackground: body.printBackground,
|
||||||
|
});
|
||||||
|
const filename = body.filename || "document.pdf";
|
||||||
|
res.setHeader("Content-Type", "application/pdf");
|
||||||
|
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||||
|
res.send(pdf);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Convert HTML error:", err);
|
||||||
|
res.status(500).json({ error: "PDF generation failed", detail: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// POST /v1/convert/markdown
|
||||||
|
exports.convertRouter.post("/markdown", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = typeof req.body === "string" ? { markdown: req.body } : req.body;
|
||||||
|
if (!body.markdown) {
|
||||||
|
res.status(400).json({ error: "Missing 'markdown' field" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const html = (0, markdown_js_1.markdownToHtml)(body.markdown, body.css);
|
||||||
|
const pdf = await (0, browser_js_1.renderPdf)(html, {
|
||||||
|
format: body.format,
|
||||||
|
landscape: body.landscape,
|
||||||
|
margin: body.margin,
|
||||||
|
printBackground: body.printBackground,
|
||||||
|
});
|
||||||
|
const filename = body.filename || "document.pdf";
|
||||||
|
res.setHeader("Content-Type", "application/pdf");
|
||||||
|
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||||
|
res.send(pdf);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Convert MD error:", err);
|
||||||
|
res.status(500).json({ error: "PDF generation failed", detail: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
8
projects/business/src/pdf-api/dist/routes/health.js
vendored
Normal file
8
projects/business/src/pdf-api/dist/routes/health.js
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.healthRouter = void 0;
|
||||||
|
const express_1 = require("express");
|
||||||
|
exports.healthRouter = (0, express_1.Router)();
|
||||||
|
exports.healthRouter.get("/", (_req, res) => {
|
||||||
|
res.json({ status: "ok", version: "0.1.0" });
|
||||||
|
});
|
||||||
42
projects/business/src/pdf-api/dist/routes/templates.js
vendored
Normal file
42
projects/business/src/pdf-api/dist/routes/templates.js
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.templatesRouter = void 0;
|
||||||
|
const express_1 = require("express");
|
||||||
|
const browser_js_1 = require("../services/browser.js");
|
||||||
|
const templates_js_1 = require("../services/templates.js");
|
||||||
|
exports.templatesRouter = (0, express_1.Router)();
|
||||||
|
// GET /v1/templates — list available templates
|
||||||
|
exports.templatesRouter.get("/", (_req, res) => {
|
||||||
|
const list = Object.entries(templates_js_1.templates).map(([id, t]) => ({
|
||||||
|
id,
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
fields: t.fields,
|
||||||
|
}));
|
||||||
|
res.json({ templates: list });
|
||||||
|
});
|
||||||
|
// POST /v1/templates/:id/render — render template to PDF
|
||||||
|
exports.templatesRouter.post("/:id/render", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = req.params.id;
|
||||||
|
const template = templates_js_1.templates[id];
|
||||||
|
if (!template) {
|
||||||
|
res.status(404).json({ error: `Template '${id}' not found` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = req.body;
|
||||||
|
const html = (0, templates_js_1.renderTemplate)(id, data);
|
||||||
|
const pdf = await (0, browser_js_1.renderPdf)(html, {
|
||||||
|
format: data._format || "A4",
|
||||||
|
margin: data._margin,
|
||||||
|
});
|
||||||
|
const filename = data._filename || `${id}.pdf`;
|
||||||
|
res.setHeader("Content-Type", "application/pdf");
|
||||||
|
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||||
|
res.send(pdf);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Template render error:", err);
|
||||||
|
res.status(500).json({ error: "Template rendering failed", detail: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
47
projects/business/src/pdf-api/dist/services/browser.js
vendored
Normal file
47
projects/business/src/pdf-api/dist/services/browser.js
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"use strict";
|
||||||
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.initBrowser = initBrowser;
|
||||||
|
exports.closeBrowser = closeBrowser;
|
||||||
|
exports.renderPdf = renderPdf;
|
||||||
|
const puppeteer_1 = __importDefault(require("puppeteer"));
|
||||||
|
let browser = null;
|
||||||
|
async function initBrowser() {
|
||||||
|
browser = await puppeteer_1.default.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"],
|
||||||
|
});
|
||||||
|
console.log("Browser pool ready");
|
||||||
|
}
|
||||||
|
async function closeBrowser() {
|
||||||
|
if (browser)
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
async function renderPdf(html, options = {}) {
|
||||||
|
if (!browser)
|
||||||
|
throw new Error("Browser not initialized");
|
||||||
|
const page = await browser.newPage();
|
||||||
|
try {
|
||||||
|
await page.setContent(html, { waitUntil: "networkidle0", timeout: 15_000 });
|
||||||
|
const pdf = await page.pdf({
|
||||||
|
format: options.format || "A4",
|
||||||
|
landscape: options.landscape || false,
|
||||||
|
printBackground: options.printBackground !== false,
|
||||||
|
margin: options.margin || {
|
||||||
|
top: "20mm",
|
||||||
|
right: "15mm",
|
||||||
|
bottom: "20mm",
|
||||||
|
left: "15mm",
|
||||||
|
},
|
||||||
|
headerTemplate: options.headerTemplate,
|
||||||
|
footerTemplate: options.footerTemplate,
|
||||||
|
displayHeaderFooter: options.displayHeaderFooter || false,
|
||||||
|
});
|
||||||
|
return Buffer.from(pdf);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
34
projects/business/src/pdf-api/dist/services/markdown.js
vendored
Normal file
34
projects/business/src/pdf-api/dist/services/markdown.js
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.markdownToHtml = markdownToHtml;
|
||||||
|
exports.wrapHtml = wrapHtml;
|
||||||
|
const marked_1 = require("marked");
|
||||||
|
const defaultCss = `
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #1a1a1a;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
h1 { font-size: 2em; margin-bottom: 0.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
||||||
|
h2 { font-size: 1.5em; margin-bottom: 0.5em; }
|
||||||
|
h3 { font-size: 1.25em; }
|
||||||
|
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
|
||||||
|
pre { background: #f4f4f4; padding: 16px; border-radius: 6px; overflow-x: auto; }
|
||||||
|
pre code { background: none; padding: 0; }
|
||||||
|
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
|
||||||
|
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
|
||||||
|
th { background: #f8f8f8; font-weight: 600; }
|
||||||
|
blockquote { border-left: 4px solid #ddd; margin: 1em 0; padding: 0.5em 1em; color: #666; }
|
||||||
|
img { max-width: 100%; }
|
||||||
|
`;
|
||||||
|
function markdownToHtml(md, css) {
|
||||||
|
const html = marked_1.marked.parse(md, { async: false });
|
||||||
|
return wrapHtml(html, css || defaultCss);
|
||||||
|
}
|
||||||
|
function wrapHtml(body, css) {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"><style>${css || defaultCss}</style></head>
|
||||||
|
<body>${body}</body></html>`;
|
||||||
|
}
|
||||||
167
projects/business/src/pdf-api/dist/services/templates.js
vendored
Normal file
167
projects/business/src/pdf-api/dist/services/templates.js
vendored
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.templates = void 0;
|
||||||
|
exports.renderTemplate = renderTemplate;
|
||||||
|
exports.templates = {
|
||||||
|
invoice: {
|
||||||
|
name: "Invoice",
|
||||||
|
description: "Professional invoice with line items, taxes, and payment details",
|
||||||
|
fields: [
|
||||||
|
{ name: "invoiceNumber", type: "string", required: true, description: "Invoice number" },
|
||||||
|
{ name: "date", type: "string", required: true, description: "Invoice date (YYYY-MM-DD)" },
|
||||||
|
{ name: "dueDate", type: "string", required: false, description: "Due date" },
|
||||||
|
{ name: "from", type: "object", required: true, description: "Sender: {name, address?, email?, phone?, vatId?}" },
|
||||||
|
{ name: "to", type: "object", required: true, description: "Recipient: {name, address?, email?, vatId?}" },
|
||||||
|
{ name: "items", type: "array", required: true, description: "Line items: [{description, quantity, unitPrice, taxRate?}]" },
|
||||||
|
{ name: "currency", type: "string", required: false, description: "Currency symbol (default: €)" },
|
||||||
|
{ name: "notes", type: "string", required: false, description: "Additional notes" },
|
||||||
|
{ name: "paymentDetails", type: "string", required: false, description: "Bank/payment info" },
|
||||||
|
],
|
||||||
|
render: renderInvoice,
|
||||||
|
},
|
||||||
|
receipt: {
|
||||||
|
name: "Receipt",
|
||||||
|
description: "Simple receipt for payments received",
|
||||||
|
fields: [
|
||||||
|
{ name: "receiptNumber", type: "string", required: true, description: "Receipt number" },
|
||||||
|
{ name: "date", type: "string", required: true, description: "Date" },
|
||||||
|
{ name: "from", type: "object", required: true, description: "Business: {name, address?}" },
|
||||||
|
{ name: "to", type: "object", required: false, description: "Customer: {name, email?}" },
|
||||||
|
{ name: "items", type: "array", required: true, description: "Items: [{description, amount}]" },
|
||||||
|
{ name: "currency", type: "string", required: false, description: "Currency symbol" },
|
||||||
|
{ name: "paymentMethod", type: "string", required: false, description: "Payment method" },
|
||||||
|
],
|
||||||
|
render: renderReceipt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
function esc(s) {
|
||||||
|
return String(s || "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
function renderInvoice(d) {
|
||||||
|
const cur = d.currency || "€";
|
||||||
|
const items = d.items || [];
|
||||||
|
let subtotal = 0;
|
||||||
|
let totalTax = 0;
|
||||||
|
const rows = items
|
||||||
|
.map((item) => {
|
||||||
|
const qty = Number(item.quantity) || 1;
|
||||||
|
const price = Number(item.unitPrice) || 0;
|
||||||
|
const taxRate = Number(item.taxRate) || 0;
|
||||||
|
const lineTotal = qty * price;
|
||||||
|
const lineTax = lineTotal * (taxRate / 100);
|
||||||
|
subtotal += lineTotal;
|
||||||
|
totalTax += lineTax;
|
||||||
|
return `<tr>
|
||||||
|
<td>${esc(item.description)}</td>
|
||||||
|
<td style="text-align:right">${qty}</td>
|
||||||
|
<td style="text-align:right">${cur}${price.toFixed(2)}</td>
|
||||||
|
<td style="text-align:right">${taxRate}%</td>
|
||||||
|
<td style="text-align:right">${cur}${lineTotal.toFixed(2)}</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
const total = subtotal + totalTax;
|
||||||
|
const from = d.from || {};
|
||||||
|
const to = d.to || {};
|
||||||
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 13px; color: #222; padding: 40px; }
|
||||||
|
.header { display: flex; justify-content: space-between; margin-bottom: 40px; }
|
||||||
|
.header h1 { font-size: 28px; color: #1a1a1a; }
|
||||||
|
.meta { text-align: right; }
|
||||||
|
.meta div { margin-bottom: 4px; }
|
||||||
|
.parties { display: flex; justify-content: space-between; margin-bottom: 30px; }
|
||||||
|
.party { width: 45%; }
|
||||||
|
.party h3 { font-size: 11px; text-transform: uppercase; color: #888; margin-bottom: 8px; }
|
||||||
|
.party p { margin-bottom: 2px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
|
||||||
|
th { background: #f8f8f8; text-align: left; padding: 10px; font-size: 11px; text-transform: uppercase; color: #666; border-bottom: 2px solid #ddd; }
|
||||||
|
td { padding: 10px; border-bottom: 1px solid #eee; }
|
||||||
|
.totals { text-align: right; margin-bottom: 30px; }
|
||||||
|
.totals div { margin-bottom: 4px; }
|
||||||
|
.totals .total { font-size: 18px; font-weight: 700; color: #1a1a1a; }
|
||||||
|
.footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #666; }
|
||||||
|
</style></head><body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>INVOICE</h1>
|
||||||
|
<div class="meta">
|
||||||
|
<div><strong>#${esc(d.invoiceNumber)}</strong></div>
|
||||||
|
<div>Date: ${esc(d.date)}</div>
|
||||||
|
${d.dueDate ? `<div>Due: ${esc(d.dueDate)}</div>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="parties">
|
||||||
|
<div class="party">
|
||||||
|
<h3>From</h3>
|
||||||
|
<p><strong>${esc(from.name)}</strong></p>
|
||||||
|
${from.address ? `<p>${esc(from.address).replace(/\n/g, "<br>")}</p>` : ""}
|
||||||
|
${from.email ? `<p>${esc(from.email)}</p>` : ""}
|
||||||
|
${from.vatId ? `<p>VAT: ${esc(from.vatId)}</p>` : ""}
|
||||||
|
</div>
|
||||||
|
<div class="party">
|
||||||
|
<h3>To</h3>
|
||||||
|
<p><strong>${esc(to.name)}</strong></p>
|
||||||
|
${to.address ? `<p>${esc(to.address).replace(/\n/g, "<br>")}</p>` : ""}
|
||||||
|
${to.email ? `<p>${esc(to.email)}</p>` : ""}
|
||||||
|
${to.vatId ? `<p>VAT: ${esc(to.vatId)}</p>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Description</th><th style="text-align:right">Qty</th><th style="text-align:right">Price</th><th style="text-align:right">Tax</th><th style="text-align:right">Total</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="totals">
|
||||||
|
<div>Subtotal: ${cur}${subtotal.toFixed(2)}</div>
|
||||||
|
<div>Tax: ${cur}${totalTax.toFixed(2)}</div>
|
||||||
|
<div class="total">Total: ${cur}${total.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
${d.paymentDetails ? `<div class="footer"><strong>Payment Details</strong><br>${esc(d.paymentDetails).replace(/\n/g, "<br>")}</div>` : ""}
|
||||||
|
${d.notes ? `<div class="footer"><strong>Notes</strong><br>${esc(d.notes)}</div>` : ""}
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
function renderReceipt(d) {
|
||||||
|
const cur = d.currency || "€";
|
||||||
|
const items = d.items || [];
|
||||||
|
let total = 0;
|
||||||
|
const rows = items
|
||||||
|
.map((item) => {
|
||||||
|
const amount = Number(item.amount) || 0;
|
||||||
|
total += amount;
|
||||||
|
return `<tr><td>${esc(item.description)}</td><td style="text-align:right">${cur}${amount.toFixed(2)}</td></tr>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
const from = d.from || {};
|
||||||
|
const to = d.to || {};
|
||||||
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
||||||
|
body { font-family: 'Courier New', monospace; font-size: 13px; max-width: 320px; margin: 0 auto; padding: 30px 20px; }
|
||||||
|
h1 { text-align: center; font-size: 18px; margin-bottom: 4px; }
|
||||||
|
.center { text-align: center; margin-bottom: 16px; }
|
||||||
|
hr { border: none; border-top: 1px dashed #999; margin: 12px 0; }
|
||||||
|
table { width: 100%; }
|
||||||
|
td { padding: 3px 0; }
|
||||||
|
.total { font-weight: bold; font-size: 16px; }
|
||||||
|
</style></head><body>
|
||||||
|
<h1>${esc(from.name)}</h1>
|
||||||
|
${from.address ? `<div class="center">${esc(from.address)}</div>` : ""}
|
||||||
|
<hr>
|
||||||
|
<div>Receipt #${esc(d.receiptNumber)}</div>
|
||||||
|
<div>Date: ${esc(d.date)}</div>
|
||||||
|
${to?.name ? `<div>Customer: ${esc(to.name)}</div>` : ""}
|
||||||
|
<hr>
|
||||||
|
<table>${rows}</table>
|
||||||
|
<hr>
|
||||||
|
<table><tr><td class="total">TOTAL</td><td class="total" style="text-align:right">${cur}${total.toFixed(2)}</td></tr></table>
|
||||||
|
${d.paymentMethod ? `<hr><div>Paid via: ${esc(d.paymentMethod)}</div>` : ""}
|
||||||
|
<hr><div class="center">Thank you!</div>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
function renderTemplate(id, data) {
|
||||||
|
const template = exports.templates[id];
|
||||||
|
if (!template)
|
||||||
|
throw new Error(`Template '${id}' not found`);
|
||||||
|
return template.render(data);
|
||||||
|
}
|
||||||
3921
projects/business/src/pdf-api/package-lock.json
generated
Normal file
3921
projects/business/src/pdf-api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
projects/business/src/pdf-api/package.json
Normal file
27
projects/business/src/pdf-api/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "docfast-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Markdown/HTML to PDF API with built-in invoice templates",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "tsx src/index.ts",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"marked": "^15.0.0",
|
||||||
|
"puppeteer": "^24.0.0",
|
||||||
|
"nanoid": "^5.0.0",
|
||||||
|
"helmet": "^8.0.0",
|
||||||
|
"express-rate-limit": "^7.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
66
projects/business/src/pdf-api/src/index.ts
Normal file
66
projects/business/src/pdf-api/src/index.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import express from "express";
|
||||||
|
import helmet from "helmet";
|
||||||
|
import rateLimit from "express-rate-limit";
|
||||||
|
import { convertRouter } from "./routes/convert.js";
|
||||||
|
import { templatesRouter } from "./routes/templates.js";
|
||||||
|
import { healthRouter } from "./routes/health.js";
|
||||||
|
import { authMiddleware } from "./middleware/auth.js";
|
||||||
|
import { initBrowser, closeBrowser } from "./services/browser.js";
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = parseInt(process.env.PORT || "3100", 10);
|
||||||
|
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(express.json({ limit: "2mb" }));
|
||||||
|
app.use(express.text({ limit: "2mb", type: "text/*" }));
|
||||||
|
|
||||||
|
// Rate limiting: 100 req/min for free tier
|
||||||
|
const limiter = rateLimit({
|
||||||
|
windowMs: 60_000,
|
||||||
|
max: 100,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
app.use(limiter);
|
||||||
|
|
||||||
|
// Public
|
||||||
|
app.use("/health", healthRouter);
|
||||||
|
|
||||||
|
// Authenticated
|
||||||
|
app.use("/v1/convert", authMiddleware, convertRouter);
|
||||||
|
app.use("/v1/templates", authMiddleware, templatesRouter);
|
||||||
|
|
||||||
|
// Root
|
||||||
|
app.get("/", (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
name: "DocFast API",
|
||||||
|
version: "0.1.0",
|
||||||
|
docs: "/health",
|
||||||
|
endpoints: [
|
||||||
|
"POST /v1/convert/html",
|
||||||
|
"POST /v1/convert/markdown",
|
||||||
|
"POST /v1/templates/:id/render",
|
||||||
|
"GET /v1/templates",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
await initBrowser();
|
||||||
|
app.listen(PORT, () => console.log(`DocFast API running on :${PORT}`));
|
||||||
|
|
||||||
|
const shutdown = async () => {
|
||||||
|
console.log("Shutting down...");
|
||||||
|
await closeBrowser();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
start().catch((err) => {
|
||||||
|
console.error("Failed to start:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
export { app };
|
||||||
23
projects/business/src/pdf-api/src/middleware/auth.ts
Normal file
23
projects/business/src/pdf-api/src/middleware/auth.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
|
||||||
|
const API_KEYS = new Set(
|
||||||
|
(process.env.API_KEYS || "test-key-123").split(",").map((k) => k.trim())
|
||||||
|
);
|
||||||
|
|
||||||
|
export function authMiddleware(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): void {
|
||||||
|
const header = req.headers.authorization;
|
||||||
|
if (!header?.startsWith("Bearer ")) {
|
||||||
|
res.status(401).json({ error: "Missing API key. Use: Authorization: Bearer <key>" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = header.slice(7);
|
||||||
|
if (!API_KEYS.has(key)) {
|
||||||
|
res.status(403).json({ error: "Invalid API key" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
78
projects/business/src/pdf-api/src/routes/convert.ts
Normal file
78
projects/business/src/pdf-api/src/routes/convert.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { renderPdf } from "../services/browser.js";
|
||||||
|
import { markdownToHtml, wrapHtml } from "../services/markdown.js";
|
||||||
|
|
||||||
|
export const convertRouter = Router();
|
||||||
|
|
||||||
|
interface ConvertBody {
|
||||||
|
html?: string;
|
||||||
|
markdown?: string;
|
||||||
|
css?: string;
|
||||||
|
format?: string;
|
||||||
|
landscape?: boolean;
|
||||||
|
margin?: { top?: string; right?: string; bottom?: string; left?: string };
|
||||||
|
printBackground?: boolean;
|
||||||
|
filename?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /v1/convert/html
|
||||||
|
convertRouter.post("/html", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const body: ConvertBody =
|
||||||
|
typeof req.body === "string" ? { html: req.body } : req.body;
|
||||||
|
|
||||||
|
if (!body.html) {
|
||||||
|
res.status(400).json({ error: "Missing 'html' field" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap bare HTML fragments
|
||||||
|
const fullHtml = body.html.includes("<html")
|
||||||
|
? body.html
|
||||||
|
: wrapHtml(body.html, body.css);
|
||||||
|
|
||||||
|
const pdf = await renderPdf(fullHtml, {
|
||||||
|
format: body.format,
|
||||||
|
landscape: body.landscape,
|
||||||
|
margin: body.margin,
|
||||||
|
printBackground: body.printBackground,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = body.filename || "document.pdf";
|
||||||
|
res.setHeader("Content-Type", "application/pdf");
|
||||||
|
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||||
|
res.send(pdf);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Convert HTML error:", err);
|
||||||
|
res.status(500).json({ error: "PDF generation failed", detail: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /v1/convert/markdown
|
||||||
|
convertRouter.post("/markdown", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const body: ConvertBody =
|
||||||
|
typeof req.body === "string" ? { markdown: req.body } : req.body;
|
||||||
|
|
||||||
|
if (!body.markdown) {
|
||||||
|
res.status(400).json({ error: "Missing 'markdown' field" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = markdownToHtml(body.markdown, body.css);
|
||||||
|
const pdf = await renderPdf(html, {
|
||||||
|
format: body.format,
|
||||||
|
landscape: body.landscape,
|
||||||
|
margin: body.margin,
|
||||||
|
printBackground: body.printBackground,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = body.filename || "document.pdf";
|
||||||
|
res.setHeader("Content-Type", "application/pdf");
|
||||||
|
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||||
|
res.send(pdf);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Convert MD error:", err);
|
||||||
|
res.status(500).json({ error: "PDF generation failed", detail: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
7
projects/business/src/pdf-api/src/routes/health.ts
Normal file
7
projects/business/src/pdf-api/src/routes/health.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
|
||||||
|
export const healthRouter = Router();
|
||||||
|
|
||||||
|
healthRouter.get("/", (_req, res) => {
|
||||||
|
res.json({ status: "ok", version: "0.1.0" });
|
||||||
|
});
|
||||||
43
projects/business/src/pdf-api/src/routes/templates.ts
Normal file
43
projects/business/src/pdf-api/src/routes/templates.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { renderPdf } from "../services/browser.js";
|
||||||
|
import { templates, renderTemplate } from "../services/templates.js";
|
||||||
|
|
||||||
|
export const templatesRouter = Router();
|
||||||
|
|
||||||
|
// GET /v1/templates — list available templates
|
||||||
|
templatesRouter.get("/", (_req: Request, res: Response) => {
|
||||||
|
const list = Object.entries(templates).map(([id, t]) => ({
|
||||||
|
id,
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
fields: t.fields,
|
||||||
|
}));
|
||||||
|
res.json({ templates: list });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /v1/templates/:id/render — render template to PDF
|
||||||
|
templatesRouter.post("/:id/render", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const template = templates[id];
|
||||||
|
if (!template) {
|
||||||
|
res.status(404).json({ error: `Template '${id}' not found` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = req.body;
|
||||||
|
const html = renderTemplate(id, data);
|
||||||
|
const pdf = await renderPdf(html, {
|
||||||
|
format: data._format || "A4",
|
||||||
|
margin: data._margin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = data._filename || `${id}.pdf`;
|
||||||
|
res.setHeader("Content-Type", "application/pdf");
|
||||||
|
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||||
|
res.send(pdf);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Template render error:", err);
|
||||||
|
res.status(500).json({ error: "Template rendering failed", detail: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
54
projects/business/src/pdf-api/src/services/browser.ts
Normal file
54
projects/business/src/pdf-api/src/services/browser.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import puppeteer, { Browser, Page } from "puppeteer";
|
||||||
|
|
||||||
|
let browser: Browser | null = null;
|
||||||
|
|
||||||
|
export async function initBrowser(): Promise<void> {
|
||||||
|
browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"],
|
||||||
|
});
|
||||||
|
console.log("Browser pool ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeBrowser(): Promise<void> {
|
||||||
|
if (browser) await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderPdf(
|
||||||
|
html: string,
|
||||||
|
options: {
|
||||||
|
format?: string;
|
||||||
|
landscape?: boolean;
|
||||||
|
margin?: { top?: string; right?: string; bottom?: string; left?: string };
|
||||||
|
printBackground?: boolean;
|
||||||
|
headerTemplate?: string;
|
||||||
|
footerTemplate?: string;
|
||||||
|
displayHeaderFooter?: boolean;
|
||||||
|
} = {}
|
||||||
|
): Promise<Buffer> {
|
||||||
|
if (!browser) throw new Error("Browser not initialized");
|
||||||
|
|
||||||
|
const page: Page = await browser.newPage();
|
||||||
|
try {
|
||||||
|
await page.setContent(html, { waitUntil: "networkidle0", timeout: 15_000 });
|
||||||
|
|
||||||
|
const pdf = await page.pdf({
|
||||||
|
format: (options.format as any) || "A4",
|
||||||
|
landscape: options.landscape || false,
|
||||||
|
printBackground: options.printBackground !== false,
|
||||||
|
margin: options.margin || {
|
||||||
|
top: "20mm",
|
||||||
|
right: "15mm",
|
||||||
|
bottom: "20mm",
|
||||||
|
left: "15mm",
|
||||||
|
},
|
||||||
|
headerTemplate: options.headerTemplate,
|
||||||
|
footerTemplate: options.footerTemplate,
|
||||||
|
displayHeaderFooter: options.displayHeaderFooter || false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Buffer.from(pdf);
|
||||||
|
} finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
33
projects/business/src/pdf-api/src/services/markdown.ts
Normal file
33
projects/business/src/pdf-api/src/services/markdown.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { marked } from "marked";
|
||||||
|
|
||||||
|
const defaultCss = `
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #1a1a1a;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
h1 { font-size: 2em; margin-bottom: 0.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
||||||
|
h2 { font-size: 1.5em; margin-bottom: 0.5em; }
|
||||||
|
h3 { font-size: 1.25em; }
|
||||||
|
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
|
||||||
|
pre { background: #f4f4f4; padding: 16px; border-radius: 6px; overflow-x: auto; }
|
||||||
|
pre code { background: none; padding: 0; }
|
||||||
|
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
|
||||||
|
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
|
||||||
|
th { background: #f8f8f8; font-weight: 600; }
|
||||||
|
blockquote { border-left: 4px solid #ddd; margin: 1em 0; padding: 0.5em 1em; color: #666; }
|
||||||
|
img { max-width: 100%; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function markdownToHtml(md: string, css?: string): string {
|
||||||
|
const html = marked.parse(md, { async: false }) as string;
|
||||||
|
return wrapHtml(html, css || defaultCss);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrapHtml(body: string, css?: string): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"><style>${css || defaultCss}</style></head>
|
||||||
|
<body>${body}</body></html>`;
|
||||||
|
}
|
||||||
179
projects/business/src/pdf-api/src/services/templates.ts
Normal file
179
projects/business/src/pdf-api/src/services/templates.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
export interface TemplateDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
fields: { name: string; type: string; required: boolean; description: string }[];
|
||||||
|
render: (data: any) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const templates: Record<string, TemplateDefinition> = {
|
||||||
|
invoice: {
|
||||||
|
name: "Invoice",
|
||||||
|
description: "Professional invoice with line items, taxes, and payment details",
|
||||||
|
fields: [
|
||||||
|
{ name: "invoiceNumber", type: "string", required: true, description: "Invoice number" },
|
||||||
|
{ name: "date", type: "string", required: true, description: "Invoice date (YYYY-MM-DD)" },
|
||||||
|
{ name: "dueDate", type: "string", required: false, description: "Due date" },
|
||||||
|
{ name: "from", type: "object", required: true, description: "Sender: {name, address?, email?, phone?, vatId?}" },
|
||||||
|
{ name: "to", type: "object", required: true, description: "Recipient: {name, address?, email?, vatId?}" },
|
||||||
|
{ name: "items", type: "array", required: true, description: "Line items: [{description, quantity, unitPrice, taxRate?}]" },
|
||||||
|
{ name: "currency", type: "string", required: false, description: "Currency symbol (default: €)" },
|
||||||
|
{ name: "notes", type: "string", required: false, description: "Additional notes" },
|
||||||
|
{ name: "paymentDetails", type: "string", required: false, description: "Bank/payment info" },
|
||||||
|
],
|
||||||
|
render: renderInvoice,
|
||||||
|
},
|
||||||
|
receipt: {
|
||||||
|
name: "Receipt",
|
||||||
|
description: "Simple receipt for payments received",
|
||||||
|
fields: [
|
||||||
|
{ name: "receiptNumber", type: "string", required: true, description: "Receipt number" },
|
||||||
|
{ name: "date", type: "string", required: true, description: "Date" },
|
||||||
|
{ name: "from", type: "object", required: true, description: "Business: {name, address?}" },
|
||||||
|
{ name: "to", type: "object", required: false, description: "Customer: {name, email?}" },
|
||||||
|
{ name: "items", type: "array", required: true, description: "Items: [{description, amount}]" },
|
||||||
|
{ name: "currency", type: "string", required: false, description: "Currency symbol" },
|
||||||
|
{ name: "paymentMethod", type: "string", required: false, description: "Payment method" },
|
||||||
|
],
|
||||||
|
render: renderReceipt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function esc(s: string): string {
|
||||||
|
return String(s || "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInvoice(d: any): string {
|
||||||
|
const cur = d.currency || "€";
|
||||||
|
const items = d.items || [];
|
||||||
|
let subtotal = 0;
|
||||||
|
let totalTax = 0;
|
||||||
|
|
||||||
|
const rows = items
|
||||||
|
.map((item: any) => {
|
||||||
|
const qty = Number(item.quantity) || 1;
|
||||||
|
const price = Number(item.unitPrice) || 0;
|
||||||
|
const taxRate = Number(item.taxRate) || 0;
|
||||||
|
const lineTotal = qty * price;
|
||||||
|
const lineTax = lineTotal * (taxRate / 100);
|
||||||
|
subtotal += lineTotal;
|
||||||
|
totalTax += lineTax;
|
||||||
|
return `<tr>
|
||||||
|
<td>${esc(item.description)}</td>
|
||||||
|
<td style="text-align:right">${qty}</td>
|
||||||
|
<td style="text-align:right">${cur}${price.toFixed(2)}</td>
|
||||||
|
<td style="text-align:right">${taxRate}%</td>
|
||||||
|
<td style="text-align:right">${cur}${lineTotal.toFixed(2)}</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const total = subtotal + totalTax;
|
||||||
|
const from = d.from || {};
|
||||||
|
const to = d.to || {};
|
||||||
|
|
||||||
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 13px; color: #222; padding: 40px; }
|
||||||
|
.header { display: flex; justify-content: space-between; margin-bottom: 40px; }
|
||||||
|
.header h1 { font-size: 28px; color: #1a1a1a; }
|
||||||
|
.meta { text-align: right; }
|
||||||
|
.meta div { margin-bottom: 4px; }
|
||||||
|
.parties { display: flex; justify-content: space-between; margin-bottom: 30px; }
|
||||||
|
.party { width: 45%; }
|
||||||
|
.party h3 { font-size: 11px; text-transform: uppercase; color: #888; margin-bottom: 8px; }
|
||||||
|
.party p { margin-bottom: 2px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
|
||||||
|
th { background: #f8f8f8; text-align: left; padding: 10px; font-size: 11px; text-transform: uppercase; color: #666; border-bottom: 2px solid #ddd; }
|
||||||
|
td { padding: 10px; border-bottom: 1px solid #eee; }
|
||||||
|
.totals { text-align: right; margin-bottom: 30px; }
|
||||||
|
.totals div { margin-bottom: 4px; }
|
||||||
|
.totals .total { font-size: 18px; font-weight: 700; color: #1a1a1a; }
|
||||||
|
.footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #666; }
|
||||||
|
</style></head><body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>INVOICE</h1>
|
||||||
|
<div class="meta">
|
||||||
|
<div><strong>#${esc(d.invoiceNumber)}</strong></div>
|
||||||
|
<div>Date: ${esc(d.date)}</div>
|
||||||
|
${d.dueDate ? `<div>Due: ${esc(d.dueDate)}</div>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="parties">
|
||||||
|
<div class="party">
|
||||||
|
<h3>From</h3>
|
||||||
|
<p><strong>${esc(from.name)}</strong></p>
|
||||||
|
${from.address ? `<p>${esc(from.address).replace(/\n/g, "<br>")}</p>` : ""}
|
||||||
|
${from.email ? `<p>${esc(from.email)}</p>` : ""}
|
||||||
|
${from.vatId ? `<p>VAT: ${esc(from.vatId)}</p>` : ""}
|
||||||
|
</div>
|
||||||
|
<div class="party">
|
||||||
|
<h3>To</h3>
|
||||||
|
<p><strong>${esc(to.name)}</strong></p>
|
||||||
|
${to.address ? `<p>${esc(to.address).replace(/\n/g, "<br>")}</p>` : ""}
|
||||||
|
${to.email ? `<p>${esc(to.email)}</p>` : ""}
|
||||||
|
${to.vatId ? `<p>VAT: ${esc(to.vatId)}</p>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Description</th><th style="text-align:right">Qty</th><th style="text-align:right">Price</th><th style="text-align:right">Tax</th><th style="text-align:right">Total</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="totals">
|
||||||
|
<div>Subtotal: ${cur}${subtotal.toFixed(2)}</div>
|
||||||
|
<div>Tax: ${cur}${totalTax.toFixed(2)}</div>
|
||||||
|
<div class="total">Total: ${cur}${total.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
${d.paymentDetails ? `<div class="footer"><strong>Payment Details</strong><br>${esc(d.paymentDetails).replace(/\n/g, "<br>")}</div>` : ""}
|
||||||
|
${d.notes ? `<div class="footer"><strong>Notes</strong><br>${esc(d.notes)}</div>` : ""}
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReceipt(d: any): string {
|
||||||
|
const cur = d.currency || "€";
|
||||||
|
const items = d.items || [];
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
const rows = items
|
||||||
|
.map((item: any) => {
|
||||||
|
const amount = Number(item.amount) || 0;
|
||||||
|
total += amount;
|
||||||
|
return `<tr><td>${esc(item.description)}</td><td style="text-align:right">${cur}${amount.toFixed(2)}</td></tr>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const from = d.from || {};
|
||||||
|
const to = d.to || {};
|
||||||
|
|
||||||
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
||||||
|
body { font-family: 'Courier New', monospace; font-size: 13px; max-width: 320px; margin: 0 auto; padding: 30px 20px; }
|
||||||
|
h1 { text-align: center; font-size: 18px; margin-bottom: 4px; }
|
||||||
|
.center { text-align: center; margin-bottom: 16px; }
|
||||||
|
hr { border: none; border-top: 1px dashed #999; margin: 12px 0; }
|
||||||
|
table { width: 100%; }
|
||||||
|
td { padding: 3px 0; }
|
||||||
|
.total { font-weight: bold; font-size: 16px; }
|
||||||
|
</style></head><body>
|
||||||
|
<h1>${esc(from.name)}</h1>
|
||||||
|
${from.address ? `<div class="center">${esc(from.address)}</div>` : ""}
|
||||||
|
<hr>
|
||||||
|
<div>Receipt #${esc(d.receiptNumber)}</div>
|
||||||
|
<div>Date: ${esc(d.date)}</div>
|
||||||
|
${to?.name ? `<div>Customer: ${esc(to.name)}</div>` : ""}
|
||||||
|
<hr>
|
||||||
|
<table>${rows}</table>
|
||||||
|
<hr>
|
||||||
|
<table><tr><td class="total">TOTAL</td><td class="total" style="text-align:right">${cur}${total.toFixed(2)}</td></tr></table>
|
||||||
|
${d.paymentMethod ? `<hr><div>Paid via: ${esc(d.paymentMethod)}</div>` : ""}
|
||||||
|
<hr><div class="center">Thank you!</div>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTemplate(id: string, data: any): string {
|
||||||
|
const template = templates[id];
|
||||||
|
if (!template) throw new Error(`Template '${id}' not found`);
|
||||||
|
return template.render(data);
|
||||||
|
}
|
||||||
13
projects/business/src/pdf-api/tsconfig.json
Normal file
13
projects/business/src/pdf-api/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue