cli-pdf-generator 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/run.js +4 -0
- package/package.json +28 -0
- package/payload.example.json +30 -0
- package/src/commands/generate.js +43 -0
- package/src/render.js +72 -0
- package/templates/generic.html +52 -0
- package/templates/invoice.html +94 -0
package/bin/run.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cli-pdf-generator",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Generate PDFs from JSON data using CLI templates.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"pdfgen": "./bin/run.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"templates",
|
|
13
|
+
"payload.example.json"
|
|
14
|
+
],
|
|
15
|
+
"oclif": {
|
|
16
|
+
"commands": "./src/commands"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"generate": "node ./bin/run.js generate",
|
|
20
|
+
"check": "node --check ./bin/run.js && node --check ./src/commands/generate.js && node --check ./src/render.js",
|
|
21
|
+
"postinstall": "playwright install chromium"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@oclif/core": "^4.0.31",
|
|
25
|
+
"handlebars": "^4.7.8",
|
|
26
|
+
"playwright": "^1.56.1"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"template": "invoice",
|
|
3
|
+
"filename": "sample_invoice.pdf",
|
|
4
|
+
"data": {
|
|
5
|
+
"company": {
|
|
6
|
+
"name": "Sample Company"
|
|
7
|
+
},
|
|
8
|
+
"party": {
|
|
9
|
+
"name": "Sample Customer"
|
|
10
|
+
},
|
|
11
|
+
"invoice_no": "INV-001",
|
|
12
|
+
"date": "01-01-2026",
|
|
13
|
+
"items": [
|
|
14
|
+
{
|
|
15
|
+
"description": "Sample Product",
|
|
16
|
+
"hsn": "00000000",
|
|
17
|
+
"qty": "1 Unit",
|
|
18
|
+
"rate": "1000.00/Unit",
|
|
19
|
+
"taxable": "1000.00",
|
|
20
|
+
"tax": "18%",
|
|
21
|
+
"total": "1180.00"
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"totals": {
|
|
25
|
+
"taxable": "1000.00",
|
|
26
|
+
"tax": "180.00",
|
|
27
|
+
"grand_total": "1180.00"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import {Args, Command, Flags} from "@oclif/core";
|
|
5
|
+
|
|
6
|
+
import {generatePdf, safeFilename} from "../render.js";
|
|
7
|
+
|
|
8
|
+
export default class Generate extends Command {
|
|
9
|
+
static description = "Generate a PDF from a JSON payload.";
|
|
10
|
+
|
|
11
|
+
static examples = [
|
|
12
|
+
"<%= config.bin %> <%= command.id %> --input payload.json --output invoice.pdf",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
static flags = {
|
|
16
|
+
input: Flags.string({
|
|
17
|
+
char: "i",
|
|
18
|
+
description: "Path to JSON payload file.",
|
|
19
|
+
required: true,
|
|
20
|
+
}),
|
|
21
|
+
output: Flags.string({
|
|
22
|
+
char: "o",
|
|
23
|
+
description: "Output PDF path.",
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
static args = {
|
|
28
|
+
outputArg: Args.string({
|
|
29
|
+
description: "Optional output PDF path.",
|
|
30
|
+
required: false,
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
async run() {
|
|
35
|
+
const {args, flags} = await this.parse(Generate);
|
|
36
|
+
const payload = JSON.parse(await fs.readFile(flags.input, "utf8"));
|
|
37
|
+
const output = path.resolve(flags.output || args.outputArg || safeFilename(payload.filename));
|
|
38
|
+
|
|
39
|
+
await fs.mkdir(path.dirname(output), {recursive: true});
|
|
40
|
+
await generatePdf(payload, output);
|
|
41
|
+
this.log(output);
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {fileURLToPath} from "node:url";
|
|
4
|
+
|
|
5
|
+
import Handlebars from "handlebars";
|
|
6
|
+
import {chromium} from "playwright";
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = path.dirname(__filename);
|
|
10
|
+
const baseDir = path.resolve(__dirname, "..");
|
|
11
|
+
|
|
12
|
+
const templates = {
|
|
13
|
+
generic: "generic.html",
|
|
14
|
+
invoice: "invoice.html",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
Handlebars.registerHelper("or", (left, right) => left || right);
|
|
18
|
+
Handlebars.registerHelper("entries", (value) =>
|
|
19
|
+
Object.entries(value || {}).map(([key, val]) => ({
|
|
20
|
+
key: key.replaceAll("_", " ").replace(/\b\w/g, (char) => char.toUpperCase()),
|
|
21
|
+
val,
|
|
22
|
+
isObject: val && typeof val === "object" && !Array.isArray(val),
|
|
23
|
+
isArray: Array.isArray(val),
|
|
24
|
+
})),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export function safeFilename(value, fallback = "generated.pdf") {
|
|
28
|
+
const filename = String(value || fallback).replace(/[^a-zA-Z0-9._/-]/g, "_");
|
|
29
|
+
return filename.toLowerCase().endsWith(".pdf") ? filename : `${filename}.pdf`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function renderHtml(payload) {
|
|
33
|
+
const templateKey = payload.template || "generic";
|
|
34
|
+
const templateFile = templates[templateKey];
|
|
35
|
+
if (!templateFile) {
|
|
36
|
+
throw new Error(`Unsupported template '${templateKey}'. Use one of: ${Object.keys(templates).join(", ")}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const templatePath = path.join(baseDir, "templates", templateFile);
|
|
40
|
+
const source = await fs.readFile(templatePath, "utf8");
|
|
41
|
+
const template = Handlebars.compile(source, {noEscape: false});
|
|
42
|
+
|
|
43
|
+
return template({
|
|
44
|
+
title: payload.title || payload.data?.title || "Generated Document",
|
|
45
|
+
rawText: payload.raw_text || payload.rawText || "",
|
|
46
|
+
data: payload.data || {},
|
|
47
|
+
generatedAt: new Date().toLocaleString("en-IN"),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function generatePdf(payload, outputPath) {
|
|
52
|
+
const html = await renderHtml(payload);
|
|
53
|
+
const browser = await chromium.launch({headless: true});
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const page = await browser.newPage();
|
|
57
|
+
await page.setContent(html, {waitUntil: "networkidle"});
|
|
58
|
+
await page.pdf({
|
|
59
|
+
path: outputPath,
|
|
60
|
+
format: "A4",
|
|
61
|
+
printBackground: true,
|
|
62
|
+
margin: {
|
|
63
|
+
top: "14mm",
|
|
64
|
+
right: "14mm",
|
|
65
|
+
bottom: "14mm",
|
|
66
|
+
left: "14mm",
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
} finally {
|
|
70
|
+
await browser.close();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<style>
|
|
6
|
+
body { color: #1f2937; font-family: Arial, sans-serif; font-size: 12px; line-height: 1.5; }
|
|
7
|
+
h1 { border-bottom: 2px solid #111827; color: #111827; font-size: 22px; margin: 0 0 16px; padding-bottom: 8px; }
|
|
8
|
+
h2 { color: #111827; font-size: 15px; margin: 18px 0 8px; }
|
|
9
|
+
table { border-collapse: collapse; margin: 10px 0 18px; width: 100%; }
|
|
10
|
+
th, td { border: 1px solid #d1d5db; padding: 7px 8px; text-align: left; vertical-align: top; }
|
|
11
|
+
th { background: #f3f4f6; color: #111827; font-weight: 700; }
|
|
12
|
+
pre { background: #f9fafb; border: 1px solid #e5e7eb; padding: 12px; white-space: pre-wrap; }
|
|
13
|
+
.meta { color: #6b7280; font-size: 10px; margin-bottom: 18px; text-align: right; }
|
|
14
|
+
</style>
|
|
15
|
+
</head>
|
|
16
|
+
<body>
|
|
17
|
+
<div class="meta">Generated: {{generatedAt}}</div>
|
|
18
|
+
<h1>{{title}}</h1>
|
|
19
|
+
|
|
20
|
+
{{#each (entries data)}}
|
|
21
|
+
{{#if isObject}}
|
|
22
|
+
<h2>{{key}}</h2>
|
|
23
|
+
<table>
|
|
24
|
+
<tbody>
|
|
25
|
+
{{#each (entries val)}}
|
|
26
|
+
<tr>
|
|
27
|
+
<th>{{key}}</th>
|
|
28
|
+
<td>{{val}}</td>
|
|
29
|
+
</tr>
|
|
30
|
+
{{/each}}
|
|
31
|
+
</tbody>
|
|
32
|
+
</table>
|
|
33
|
+
{{else if isArray}}
|
|
34
|
+
<h2>{{key}}</h2>
|
|
35
|
+
<table>
|
|
36
|
+
<tbody>
|
|
37
|
+
{{#each val}}
|
|
38
|
+
<tr><td>{{this}}</td></tr>
|
|
39
|
+
{{/each}}
|
|
40
|
+
</tbody>
|
|
41
|
+
</table>
|
|
42
|
+
{{else}}
|
|
43
|
+
<p><strong>{{key}}:</strong> {{val}}</p>
|
|
44
|
+
{{/if}}
|
|
45
|
+
{{/each}}
|
|
46
|
+
|
|
47
|
+
{{#if rawText}}
|
|
48
|
+
<h2>Details</h2>
|
|
49
|
+
<pre>{{rawText}}</pre>
|
|
50
|
+
{{/if}}
|
|
51
|
+
</body>
|
|
52
|
+
</html>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<style>
|
|
6
|
+
body { color: #111827; font-family: Arial, sans-serif; font-size: 11px; }
|
|
7
|
+
h1 { font-size: 24px; letter-spacing: .08em; margin: 0; text-align: right; }
|
|
8
|
+
h2 { border-bottom: 1px solid #d1d5db; font-size: 13px; margin: 18px 0 8px; padding-bottom: 4px; }
|
|
9
|
+
table { border-collapse: collapse; width: 100%; }
|
|
10
|
+
th, td { border: 1px solid #d1d5db; padding: 7px; vertical-align: top; }
|
|
11
|
+
th { background: #f3f4f6; font-weight: 700; }
|
|
12
|
+
.top { display: table; margin-bottom: 18px; width: 100%; }
|
|
13
|
+
.left, .right { display: table-cell; vertical-align: top; width: 50%; }
|
|
14
|
+
.right { text-align: right; }
|
|
15
|
+
.muted { color: #6b7280; }
|
|
16
|
+
.no-border td { border: 0; padding: 2px 0; }
|
|
17
|
+
.amount { text-align: right; white-space: nowrap; }
|
|
18
|
+
.totals { margin-left: auto; margin-top: 18px; width: 45%; }
|
|
19
|
+
.grand-total td { background: #111827; color: #fff; font-size: 13px; font-weight: 700; }
|
|
20
|
+
.notes { background: #f9fafb; border: 1px solid #e5e7eb; margin-top: 18px; padding: 10px; white-space: pre-wrap; }
|
|
21
|
+
</style>
|
|
22
|
+
</head>
|
|
23
|
+
<body>
|
|
24
|
+
<div class="top">
|
|
25
|
+
<div class="left">
|
|
26
|
+
<strong>{{or data.company.name data.company_name}}</strong><br>
|
|
27
|
+
{{#if data.company.address}}{{data.company.address}}<br>{{/if}}
|
|
28
|
+
{{#if data.company.gstin}}GSTIN: {{data.company.gstin}}<br>{{/if}}
|
|
29
|
+
{{#if data.company.email}}Email: {{data.company.email}}<br>{{/if}}
|
|
30
|
+
{{#if data.company.phone}}Phone: {{data.company.phone}}{{/if}}
|
|
31
|
+
</div>
|
|
32
|
+
<div class="right">
|
|
33
|
+
<h1>INVOICE</h1>
|
|
34
|
+
<table class="no-border">
|
|
35
|
+
<tr><td class="muted">Invoice No:</td><td>{{or data.invoice_no "-"}}</td></tr>
|
|
36
|
+
<tr><td class="muted">Date:</td><td>{{or data.date "-"}}</td></tr>
|
|
37
|
+
{{#if data.due_date}}<tr><td class="muted">Due Date:</td><td>{{data.due_date}}</td></tr>{{/if}}
|
|
38
|
+
</table>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<h2>Bill To</h2>
|
|
43
|
+
<strong>{{or data.party.name data.party_name}}</strong><br>
|
|
44
|
+
{{#if data.party.address}}{{data.party.address}}<br>{{/if}}
|
|
45
|
+
{{#if data.party.gstin}}GSTIN: {{data.party.gstin}}{{/if}}
|
|
46
|
+
|
|
47
|
+
<h2>Items</h2>
|
|
48
|
+
<table>
|
|
49
|
+
<thead>
|
|
50
|
+
<tr>
|
|
51
|
+
<th style="width: 38%">Description</th>
|
|
52
|
+
<th>HSN</th>
|
|
53
|
+
<th class="amount">Qty</th>
|
|
54
|
+
<th class="amount">Rate</th>
|
|
55
|
+
<th class="amount">Taxable</th>
|
|
56
|
+
<th class="amount">Tax</th>
|
|
57
|
+
<th class="amount">Total</th>
|
|
58
|
+
</tr>
|
|
59
|
+
</thead>
|
|
60
|
+
<tbody>
|
|
61
|
+
{{#each data.items}}
|
|
62
|
+
<tr>
|
|
63
|
+
<td>{{or description name}}</td>
|
|
64
|
+
<td>{{hsn}}</td>
|
|
65
|
+
<td class="amount">{{or qty quantity}}</td>
|
|
66
|
+
<td class="amount">{{rate}}</td>
|
|
67
|
+
<td class="amount">{{or taxable amount}}</td>
|
|
68
|
+
<td class="amount">{{or tax gst}}</td>
|
|
69
|
+
<td class="amount">{{total}}</td>
|
|
70
|
+
</tr>
|
|
71
|
+
{{/each}}
|
|
72
|
+
</tbody>
|
|
73
|
+
</table>
|
|
74
|
+
|
|
75
|
+
<table class="totals">
|
|
76
|
+
<tbody>
|
|
77
|
+
{{#if data.totals.taxable}}<tr><td>Taxable Value</td><td class="amount">{{data.totals.taxable}}</td></tr>{{/if}}
|
|
78
|
+
{{#if data.totals.cgst}}<tr><td>CGST</td><td class="amount">{{data.totals.cgst}}</td></tr>{{/if}}
|
|
79
|
+
{{#if data.totals.sgst}}<tr><td>SGST</td><td class="amount">{{data.totals.sgst}}</td></tr>{{/if}}
|
|
80
|
+
{{#if data.totals.igst}}<tr><td>IGST</td><td class="amount">{{data.totals.igst}}</td></tr>{{/if}}
|
|
81
|
+
{{#if data.totals.tax}}<tr><td>Total Tax</td><td class="amount">{{data.totals.tax}}</td></tr>{{/if}}
|
|
82
|
+
<tr class="grand-total"><td>Grand Total</td><td class="amount">{{or data.totals.grand_total data.total}}</td></tr>
|
|
83
|
+
</tbody>
|
|
84
|
+
</table>
|
|
85
|
+
|
|
86
|
+
{{#if data.amount_in_words}}
|
|
87
|
+
<p><strong>Amount in words:</strong> {{data.amount_in_words}}</p>
|
|
88
|
+
{{/if}}
|
|
89
|
+
|
|
90
|
+
{{#if data.notes}}
|
|
91
|
+
<div class="notes">{{data.notes}}</div>
|
|
92
|
+
{{/if}}
|
|
93
|
+
</body>
|
|
94
|
+
</html>
|