nexdf 0.1.0 → 0.1.2
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/README.md +68 -4
- package/dist/cli/index.js +83 -0
- package/dist/index.d.ts +24 -13
- package/dist/index.js +416 -11
- package/package.json +4 -2
- package/templates/native/native.html +74 -0
package/README.md
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
# nexdf
|
|
2
2
|
|
|
3
|
-
Plug-and-play PDF generation for Next.js
|
|
3
|
+
Plug-and-play PDF generation for Next.js with two engines:
|
|
4
|
+
|
|
5
|
+
- `chromium`: strongest CSS compatibility (Tailwind, advanced layouts), slower startup.
|
|
6
|
+
- `native`: built-in fast HTML/CSS renderer (classic classes + HTMX-friendly templates), lower CSS coverage.
|
|
4
7
|
|
|
5
8
|
## Features
|
|
6
9
|
|
|
7
10
|
- Ready `/api/pdf` endpoint (via one scaffold command)
|
|
8
11
|
- Unlimited placeholders (`{{anyField}}`)
|
|
9
|
-
-
|
|
12
|
+
- Two rendering engines (`chromium` and `native`)
|
|
13
|
+
- Tailwind-friendly templates for Chromium path
|
|
14
|
+
- Fast built-in native path for classic CSS classes
|
|
10
15
|
- A4-ready default template
|
|
11
16
|
- Fast runtime path: browser prewarm + page pool reuse + template compile cache
|
|
17
|
+
- Optional DB template resolver with file-template fallback
|
|
12
18
|
|
|
13
19
|
## Install
|
|
14
20
|
|
|
@@ -28,6 +34,7 @@ This creates:
|
|
|
28
34
|
|
|
29
35
|
- `app/api/pdf/route.ts`
|
|
30
36
|
- `templates/pdf/basic.html`
|
|
37
|
+
- `templates/pdf/native.html`
|
|
31
38
|
|
|
32
39
|
2) Ensure Node runtime in route (generated by default):
|
|
33
40
|
|
|
@@ -39,6 +46,7 @@ export const runtime = "nodejs";
|
|
|
39
46
|
export const POST = createPdfRouteHandler({
|
|
40
47
|
templatesDir: process.cwd() + "/templates/pdf",
|
|
41
48
|
defaultTemplate: "basic.html",
|
|
49
|
+
defaultEngine: "chromium", // or "native"
|
|
42
50
|
poolSize: 2
|
|
43
51
|
});
|
|
44
52
|
```
|
|
@@ -50,7 +58,9 @@ curl -X POST http://localhost:3000/api/pdf \
|
|
|
50
58
|
-H "content-type: application/json" \
|
|
51
59
|
-d '{
|
|
52
60
|
"filename": "invoice.pdf",
|
|
53
|
-
"
|
|
61
|
+
"engine": "native",
|
|
62
|
+
"templateKey": "basic",
|
|
63
|
+
"templatePath": "native.html",
|
|
54
64
|
"data": {
|
|
55
65
|
"title": "Invoice #001",
|
|
56
66
|
"subtitle": "Thanks for your business",
|
|
@@ -86,6 +96,8 @@ Body:
|
|
|
86
96
|
```json
|
|
87
97
|
{
|
|
88
98
|
"filename": "document.pdf",
|
|
99
|
+
"engine": "chromium",
|
|
100
|
+
"templateKey": "basic",
|
|
89
101
|
"templatePath": "basic.html",
|
|
90
102
|
"data": {
|
|
91
103
|
"title": "Any value"
|
|
@@ -93,10 +105,28 @@ Body:
|
|
|
93
105
|
}
|
|
94
106
|
```
|
|
95
107
|
|
|
108
|
+
Engine behavior:
|
|
109
|
+
|
|
110
|
+
- `chromium`: best for Tailwind and modern CSS features.
|
|
111
|
+
- `native`: best for speed with classic class-based CSS (`font-size`, `font-weight`, `color`, `margin-top`, `margin-bottom`, `line-height`, `text-align`).
|
|
112
|
+
|
|
113
|
+
Recommended pairing:
|
|
114
|
+
|
|
115
|
+
- `basic.html` + `engine: "chromium"`
|
|
116
|
+
- `native.html` + `engine: "native"`
|
|
117
|
+
|
|
118
|
+
`templateKey` is available when you use a custom template resolver.
|
|
119
|
+
|
|
120
|
+
Template resolution behavior:
|
|
121
|
+
|
|
122
|
+
1. If `resolveTemplate` returns HTML, that HTML is rendered.
|
|
123
|
+
2. If `resolveTemplate` returns `null`, file-template rendering is used.
|
|
124
|
+
3. File-template path is `templatePath` or `defaultTemplate`.
|
|
125
|
+
|
|
96
126
|
## Runtime requirements
|
|
97
127
|
|
|
98
128
|
- Next route runtime must be `nodejs`
|
|
99
|
-
- Chrome/Chromium must be available
|
|
129
|
+
- Chrome/Chromium must be available only when using `chromium` engine
|
|
100
130
|
- Set executable path if needed:
|
|
101
131
|
|
|
102
132
|
```bash
|
|
@@ -108,6 +138,7 @@ export PUPPETEER_EXECUTABLE_PATH="/Applications/Google Chrome.app/Contents/MacOS
|
|
|
108
138
|
- Prewarms Chromium when route handler is created
|
|
109
139
|
- Reuses hot pages via an internal pool (`poolSize`)
|
|
110
140
|
- Caches compiled templates with file mtime invalidation
|
|
141
|
+
- Native engine skips browser boot and is typically much faster for text/document layouts
|
|
111
142
|
|
|
112
143
|
Note: true zero cold start is not possible if your hosting platform starts a brand-new container/process.
|
|
113
144
|
|
|
@@ -121,6 +152,39 @@ await prewarmPdfEngine({ poolSize: 2 });
|
|
|
121
152
|
export const POST = createPdfRouteHandler({
|
|
122
153
|
templatesDir: process.cwd() + "/templates/pdf",
|
|
123
154
|
defaultTemplate: "basic.html",
|
|
155
|
+
defaultEngine: "chromium",
|
|
124
156
|
poolSize: 2
|
|
125
157
|
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Database-backed templates
|
|
161
|
+
|
|
162
|
+
Use `resolveTemplate` to fetch HTML from DB and render server-side:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
export const POST = createPdfRouteHandler({
|
|
166
|
+
templatesDir: process.cwd() + "/templates/pdf",
|
|
167
|
+
defaultTemplate: "basic.html",
|
|
168
|
+
poolSize: 2,
|
|
169
|
+
resolveTemplate: async ({ body }) => {
|
|
170
|
+
const templateKey = body.templateKey ?? "basic";
|
|
171
|
+
const html = await loadTemplateHtmlFromDb(templateKey);
|
|
172
|
+
return html;
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
If `resolveTemplate` returns `null`, file-based template loading is used as fallback.
|
|
178
|
+
|
|
179
|
+
Example request body with DB-first + file fallback support:
|
|
180
|
+
|
|
181
|
+
```json
|
|
182
|
+
{
|
|
183
|
+
"filename": "document.pdf",
|
|
184
|
+
"templateKey": "basic",
|
|
185
|
+
"templatePath": "basic.html",
|
|
186
|
+
"data": {
|
|
187
|
+
"title": "Any value"
|
|
188
|
+
}
|
|
189
|
+
}
|
|
126
190
|
```
|
package/dist/cli/index.js
CHANGED
|
@@ -8,6 +8,7 @@ var cwd = process.cwd();
|
|
|
8
8
|
var routeFilePath = path.join(cwd, "app", "api", "pdf", "route.ts");
|
|
9
9
|
var templateDir = path.join(cwd, "templates", "pdf");
|
|
10
10
|
var templatePath = path.join(templateDir, "basic.html");
|
|
11
|
+
var nativeTemplatePath = path.join(templateDir, "native.html");
|
|
11
12
|
var routeTemplate = `import { createPdfRouteHandler } from "nexdf";
|
|
12
13
|
|
|
13
14
|
export const runtime = "nodejs";
|
|
@@ -15,6 +16,7 @@ export const runtime = "nodejs";
|
|
|
15
16
|
export const POST = createPdfRouteHandler({
|
|
16
17
|
templatesDir: process.cwd() + "/templates/pdf",
|
|
17
18
|
defaultTemplate: "basic.html",
|
|
19
|
+
defaultEngine: "chromium",
|
|
18
20
|
poolSize: 2
|
|
19
21
|
});
|
|
20
22
|
`;
|
|
@@ -70,6 +72,81 @@ var defaultHtmlTemplate = `<!doctype html>
|
|
|
70
72
|
</body>
|
|
71
73
|
</html>
|
|
72
74
|
`;
|
|
75
|
+
var nativeHtmlTemplate = `<!doctype html>
|
|
76
|
+
<html lang="en">
|
|
77
|
+
<head>
|
|
78
|
+
<meta charset="UTF-8" />
|
|
79
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
80
|
+
<title>{{title}}</title>
|
|
81
|
+
<style>
|
|
82
|
+
body {
|
|
83
|
+
font-size: 11pt;
|
|
84
|
+
color: #111827;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.document-title {
|
|
88
|
+
font-size: 24pt;
|
|
89
|
+
font-weight: 700;
|
|
90
|
+
margin-bottom: 4pt;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.document-subtitle {
|
|
94
|
+
font-size: 11pt;
|
|
95
|
+
color: #4b5563;
|
|
96
|
+
margin-bottom: 14pt;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.section-title {
|
|
100
|
+
font-size: 13pt;
|
|
101
|
+
font-weight: 700;
|
|
102
|
+
margin-top: 10pt;
|
|
103
|
+
margin-bottom: 4pt;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.muted {
|
|
107
|
+
color: #6b7280;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.small {
|
|
111
|
+
font-size: 10pt;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.row {
|
|
115
|
+
margin-bottom: 2pt;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.summary {
|
|
119
|
+
margin-top: 8pt;
|
|
120
|
+
margin-bottom: 10pt;
|
|
121
|
+
line-height: 1.55;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.footer {
|
|
125
|
+
margin-top: 18pt;
|
|
126
|
+
font-size: 9pt;
|
|
127
|
+
color: #9ca3af;
|
|
128
|
+
}
|
|
129
|
+
</style>
|
|
130
|
+
</head>
|
|
131
|
+
<body>
|
|
132
|
+
<h1 class="document-title">{{title}}</h1>
|
|
133
|
+
<p class="document-subtitle">{{subtitle}}</p>
|
|
134
|
+
|
|
135
|
+
<h2 class="section-title">From</h2>
|
|
136
|
+
<p class="row">{{fromName}}</p>
|
|
137
|
+
<p class="row muted">{{fromEmail}}</p>
|
|
138
|
+
|
|
139
|
+
<h2 class="section-title">To</h2>
|
|
140
|
+
<p class="row">{{toName}}</p>
|
|
141
|
+
<p class="row muted">{{toEmail}}</p>
|
|
142
|
+
|
|
143
|
+
<h2 class="section-title">Summary</h2>
|
|
144
|
+
<p class="summary">{{summary}}</p>
|
|
145
|
+
|
|
146
|
+
<p class="footer">Generated on {{generatedAt}}</p>
|
|
147
|
+
</body>
|
|
148
|
+
</html>
|
|
149
|
+
`;
|
|
73
150
|
async function exists(filePath) {
|
|
74
151
|
try {
|
|
75
152
|
await access(filePath, fsConstants.F_OK);
|
|
@@ -93,6 +170,12 @@ async function main() {
|
|
|
93
170
|
} else {
|
|
94
171
|
console.log("Skipped templates/pdf/basic.html (already exists)");
|
|
95
172
|
}
|
|
173
|
+
if (!await exists(nativeTemplatePath)) {
|
|
174
|
+
await writeFile(nativeTemplatePath, nativeHtmlTemplate, "utf8");
|
|
175
|
+
console.log("Created templates/pdf/native.html");
|
|
176
|
+
} else {
|
|
177
|
+
console.log("Skipped templates/pdf/native.html (already exists)");
|
|
178
|
+
}
|
|
96
179
|
console.log("PDF endpoint is ready at /api/pdf");
|
|
97
180
|
}
|
|
98
181
|
main().catch((error) => {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { PDFOptions } from 'puppeteer-core';
|
|
2
2
|
|
|
3
|
+
type PdfEngine = "chromium" | "native";
|
|
4
|
+
type GeneratePdfInput = {
|
|
5
|
+
html: string;
|
|
6
|
+
engine?: PdfEngine;
|
|
7
|
+
pdf?: PDFOptions;
|
|
8
|
+
executablePath?: string;
|
|
9
|
+
};
|
|
10
|
+
type PrewarmPdfEngineOptions = {
|
|
11
|
+
executablePath?: string;
|
|
12
|
+
poolSize?: number;
|
|
13
|
+
};
|
|
14
|
+
declare function prewarmPdfEngine(options?: PrewarmPdfEngineOptions): Promise<void>;
|
|
15
|
+
declare function generatePdfBuffer(input: GeneratePdfInput): Promise<Buffer>;
|
|
16
|
+
|
|
3
17
|
type TemplateData = Record<string, unknown>;
|
|
4
18
|
declare function renderTemplateString(template: string, data: TemplateData): string;
|
|
5
19
|
declare function renderTemplateFile(templatePath: string, data: TemplateData): Promise<string>;
|
|
@@ -8,26 +22,23 @@ declare function listPlaceholders(template: string): string[];
|
|
|
8
22
|
type PdfRequestBody = {
|
|
9
23
|
data?: TemplateData;
|
|
10
24
|
templatePath?: string;
|
|
25
|
+
templateKey?: string;
|
|
11
26
|
filename?: string;
|
|
27
|
+
engine?: PdfEngine;
|
|
28
|
+
};
|
|
29
|
+
type ResolveTemplateContext = {
|
|
30
|
+
body: PdfRequestBody;
|
|
31
|
+
templatesDir: string;
|
|
32
|
+
defaultTemplate: string;
|
|
12
33
|
};
|
|
13
34
|
type CreatePdfRouteHandlerOptions = {
|
|
14
35
|
templatesDir?: string;
|
|
15
36
|
defaultTemplate?: string;
|
|
37
|
+
defaultEngine?: PdfEngine;
|
|
16
38
|
prewarm?: boolean;
|
|
17
39
|
poolSize?: number;
|
|
40
|
+
resolveTemplate?: (context: ResolveTemplateContext) => Promise<string | null>;
|
|
18
41
|
};
|
|
19
42
|
declare function createPdfRouteHandler(options?: CreatePdfRouteHandlerOptions): (request: Request) => Promise<Response>;
|
|
20
43
|
|
|
21
|
-
type GeneratePdfInput
|
|
22
|
-
html: string;
|
|
23
|
-
pdf?: PDFOptions;
|
|
24
|
-
executablePath?: string;
|
|
25
|
-
};
|
|
26
|
-
type PrewarmPdfEngineOptions = {
|
|
27
|
-
executablePath?: string;
|
|
28
|
-
poolSize?: number;
|
|
29
|
-
};
|
|
30
|
-
declare function prewarmPdfEngine(options?: PrewarmPdfEngineOptions): Promise<void>;
|
|
31
|
-
declare function generatePdfBuffer(input: GeneratePdfInput): Promise<Buffer>;
|
|
32
|
-
|
|
33
|
-
export { type CreatePdfRouteHandlerOptions, type GeneratePdfInput, type PdfRequestBody, type PrewarmPdfEngineOptions, type TemplateData, createPdfRouteHandler, generatePdfBuffer, listPlaceholders, prewarmPdfEngine, renderTemplateFile, renderTemplateString };
|
|
44
|
+
export { type CreatePdfRouteHandlerOptions, type GeneratePdfInput, type PdfEngine, type PdfRequestBody, type PrewarmPdfEngineOptions, type TemplateData, createPdfRouteHandler, generatePdfBuffer, listPlaceholders, prewarmPdfEngine, renderTemplateFile, renderTemplateString };
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,393 @@ import path from "path";
|
|
|
5
5
|
|
|
6
6
|
// src/pdf.ts
|
|
7
7
|
import puppeteer from "puppeteer-core";
|
|
8
|
+
|
|
9
|
+
// src/native.ts
|
|
10
|
+
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
|
|
11
|
+
import { parse } from "node-html-parser";
|
|
12
|
+
var A4_WIDTH = 595.28;
|
|
13
|
+
var A4_HEIGHT = 841.89;
|
|
14
|
+
var BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
15
|
+
"html",
|
|
16
|
+
"body",
|
|
17
|
+
"main",
|
|
18
|
+
"section",
|
|
19
|
+
"article",
|
|
20
|
+
"header",
|
|
21
|
+
"footer",
|
|
22
|
+
"aside",
|
|
23
|
+
"nav",
|
|
24
|
+
"div",
|
|
25
|
+
"p",
|
|
26
|
+
"h1",
|
|
27
|
+
"h2",
|
|
28
|
+
"h3",
|
|
29
|
+
"h4",
|
|
30
|
+
"h5",
|
|
31
|
+
"h6",
|
|
32
|
+
"ul",
|
|
33
|
+
"ol",
|
|
34
|
+
"li"
|
|
35
|
+
]);
|
|
36
|
+
var DEFAULT_STYLE = {
|
|
37
|
+
fontSize: 11,
|
|
38
|
+
fontWeight: "normal",
|
|
39
|
+
color: rgb(0, 0, 0),
|
|
40
|
+
lineHeight: 1.4,
|
|
41
|
+
marginTop: 0,
|
|
42
|
+
marginBottom: 0,
|
|
43
|
+
textAlign: "left"
|
|
44
|
+
};
|
|
45
|
+
var HEADING_STYLES = {
|
|
46
|
+
h1: { fontSize: 28, fontWeight: "bold", marginBottom: 8 },
|
|
47
|
+
h2: { fontSize: 22, fontWeight: "bold", marginBottom: 6 },
|
|
48
|
+
h3: { fontSize: 18, fontWeight: "bold", marginBottom: 5 },
|
|
49
|
+
h4: { fontSize: 15, fontWeight: "bold", marginBottom: 4 },
|
|
50
|
+
h5: { fontSize: 13, fontWeight: "bold", marginBottom: 4 },
|
|
51
|
+
h6: { fontSize: 11, fontWeight: "bold", marginBottom: 3 }
|
|
52
|
+
};
|
|
53
|
+
var BLOCK_DEFAULTS = {
|
|
54
|
+
p: { marginBottom: 6 },
|
|
55
|
+
div: { marginBottom: 4 },
|
|
56
|
+
section: { marginBottom: 6 },
|
|
57
|
+
article: { marginBottom: 6 },
|
|
58
|
+
header: { marginBottom: 8 },
|
|
59
|
+
footer: { marginTop: 8 },
|
|
60
|
+
li: { marginBottom: 2 },
|
|
61
|
+
ul: { marginBottom: 4 },
|
|
62
|
+
ol: { marginBottom: 4 }
|
|
63
|
+
};
|
|
64
|
+
function toPoints(value) {
|
|
65
|
+
const normalized = value.trim().toLowerCase();
|
|
66
|
+
if (!normalized) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (normalized.endsWith("px")) {
|
|
70
|
+
const px = Number.parseFloat(normalized.slice(0, -2));
|
|
71
|
+
return Number.isFinite(px) ? px * 0.75 : null;
|
|
72
|
+
}
|
|
73
|
+
if (normalized.endsWith("pt")) {
|
|
74
|
+
const pt = Number.parseFloat(normalized.slice(0, -2));
|
|
75
|
+
return Number.isFinite(pt) ? pt : null;
|
|
76
|
+
}
|
|
77
|
+
const raw = Number.parseFloat(normalized);
|
|
78
|
+
return Number.isFinite(raw) ? raw : null;
|
|
79
|
+
}
|
|
80
|
+
function parseHexColor(input) {
|
|
81
|
+
const value = input.trim().toLowerCase();
|
|
82
|
+
if (!value.startsWith("#")) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const hex = value.slice(1);
|
|
86
|
+
if (hex.length === 3) {
|
|
87
|
+
const r = Number.parseInt(hex[0] + hex[0], 16);
|
|
88
|
+
const g = Number.parseInt(hex[1] + hex[1], 16);
|
|
89
|
+
const b = Number.parseInt(hex[2] + hex[2], 16);
|
|
90
|
+
return rgb(r / 255, g / 255, b / 255);
|
|
91
|
+
}
|
|
92
|
+
if (hex.length === 6) {
|
|
93
|
+
const r = Number.parseInt(hex.slice(0, 2), 16);
|
|
94
|
+
const g = Number.parseInt(hex.slice(2, 4), 16);
|
|
95
|
+
const b = Number.parseInt(hex.slice(4, 6), 16);
|
|
96
|
+
return rgb(r / 255, g / 255, b / 255);
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
function parseRgbColor(input) {
|
|
101
|
+
const match = input.trim().match(/^rgb\((\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\)$/i);
|
|
102
|
+
if (!match) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const r = Number.parseInt(match[1], 10);
|
|
106
|
+
const g = Number.parseInt(match[2], 10);
|
|
107
|
+
const b = Number.parseInt(match[3], 10);
|
|
108
|
+
if (r > 255 || g > 255 || b > 255) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
return rgb(r / 255, g / 255, b / 255);
|
|
112
|
+
}
|
|
113
|
+
function parseColor(value) {
|
|
114
|
+
return parseHexColor(value) ?? parseRgbColor(value);
|
|
115
|
+
}
|
|
116
|
+
function parseStyleDeclarations(cssText) {
|
|
117
|
+
const out = {};
|
|
118
|
+
const parts = cssText.split(";");
|
|
119
|
+
for (const part of parts) {
|
|
120
|
+
const index = part.indexOf(":");
|
|
121
|
+
if (index === -1) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const property = part.slice(0, index).trim().toLowerCase();
|
|
125
|
+
const value = part.slice(index + 1).trim();
|
|
126
|
+
if (!property || !value) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (property === "font-size") {
|
|
130
|
+
const parsed = toPoints(value);
|
|
131
|
+
if (parsed !== null) {
|
|
132
|
+
out.fontSize = parsed;
|
|
133
|
+
}
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (property === "font-weight") {
|
|
137
|
+
if (value === "bold" || Number.parseInt(value, 10) >= 600) {
|
|
138
|
+
out.fontWeight = "bold";
|
|
139
|
+
} else {
|
|
140
|
+
out.fontWeight = "normal";
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (property === "color") {
|
|
145
|
+
const parsed = parseColor(value);
|
|
146
|
+
if (parsed) {
|
|
147
|
+
out.color = parsed;
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (property === "line-height") {
|
|
152
|
+
const numeric = Number.parseFloat(value);
|
|
153
|
+
if (Number.isFinite(numeric) && numeric > 0) {
|
|
154
|
+
out.lineHeight = numeric;
|
|
155
|
+
} else {
|
|
156
|
+
const parsed = toPoints(value);
|
|
157
|
+
if (parsed !== null) {
|
|
158
|
+
out.lineHeight = parsed;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (property === "margin-top") {
|
|
164
|
+
const parsed = toPoints(value);
|
|
165
|
+
if (parsed !== null) {
|
|
166
|
+
out.marginTop = parsed;
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (property === "margin-bottom") {
|
|
171
|
+
const parsed = toPoints(value);
|
|
172
|
+
if (parsed !== null) {
|
|
173
|
+
out.marginBottom = parsed;
|
|
174
|
+
}
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (property === "text-align") {
|
|
178
|
+
if (value === "left" || value === "center" || value === "right") {
|
|
179
|
+
out.textAlign = value;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
function extractClassStyles(html) {
|
|
186
|
+
const map = /* @__PURE__ */ new Map();
|
|
187
|
+
const styleRegex = /<style[^>]*>([\s\S]*?)<\/style>/gi;
|
|
188
|
+
let styleMatch;
|
|
189
|
+
while (styleMatch = styleRegex.exec(html)) {
|
|
190
|
+
const css = styleMatch[1] ?? "";
|
|
191
|
+
const ruleRegex = /([^{}]+)\{([^{}]+)\}/g;
|
|
192
|
+
let ruleMatch;
|
|
193
|
+
while (ruleMatch = ruleRegex.exec(css)) {
|
|
194
|
+
const selector = ruleMatch[1]?.trim() ?? "";
|
|
195
|
+
const declarations = ruleMatch[2] ?? "";
|
|
196
|
+
const parsed = parseStyleDeclarations(declarations);
|
|
197
|
+
const selectors = selector.split(",").map((value) => value.trim());
|
|
198
|
+
for (const currentSelector of selectors) {
|
|
199
|
+
if (!currentSelector.startsWith(".")) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const className = currentSelector.slice(1).split(/\s|:|>/)[0]?.trim();
|
|
203
|
+
if (!className) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
map.set(className, {
|
|
207
|
+
...map.get(className) ?? {},
|
|
208
|
+
...parsed
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return map;
|
|
214
|
+
}
|
|
215
|
+
function mergeStyles(...styles) {
|
|
216
|
+
return styles.reduce(
|
|
217
|
+
(acc, item) => ({
|
|
218
|
+
...acc,
|
|
219
|
+
...item,
|
|
220
|
+
color: item.color ?? acc.color
|
|
221
|
+
}),
|
|
222
|
+
{ ...DEFAULT_STYLE }
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
function getTagStyle(tag) {
|
|
226
|
+
const heading = HEADING_STYLES[tag];
|
|
227
|
+
if (heading) {
|
|
228
|
+
return heading;
|
|
229
|
+
}
|
|
230
|
+
return BLOCK_DEFAULTS[tag] ?? {};
|
|
231
|
+
}
|
|
232
|
+
function getElementStyle(element, classStyles, inherited) {
|
|
233
|
+
const tag = element.tagName.toLowerCase();
|
|
234
|
+
const classAttr = element.getAttribute("class") ?? "";
|
|
235
|
+
const inlineStyle = element.getAttribute("style") ?? "";
|
|
236
|
+
const classes = classAttr.split(/\s+/).map((value) => value.trim()).filter(Boolean);
|
|
237
|
+
const classMerged = classes.reduce((acc, className) => {
|
|
238
|
+
return {
|
|
239
|
+
...acc,
|
|
240
|
+
...classStyles.get(className) ?? {}
|
|
241
|
+
};
|
|
242
|
+
}, {});
|
|
243
|
+
const inlineParsed = inlineStyle ? parseStyleDeclarations(inlineStyle) : {};
|
|
244
|
+
return mergeStyles(inherited, getTagStyle(tag), classMerged, inlineParsed);
|
|
245
|
+
}
|
|
246
|
+
function ensureRoom(state, heightNeeded) {
|
|
247
|
+
if (state.cursorY - heightNeeded >= state.marginBottom) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
state.page = state.doc.addPage([state.pageWidth, state.pageHeight]);
|
|
251
|
+
state.cursorY = state.pageHeight - state.marginTop;
|
|
252
|
+
}
|
|
253
|
+
function collapseWhitespace(value) {
|
|
254
|
+
return value.replace(/\s+/g, " ").trim();
|
|
255
|
+
}
|
|
256
|
+
function wrapText(text, font, fontSize, maxWidth) {
|
|
257
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
258
|
+
if (words.length === 0) {
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
const lines = [];
|
|
262
|
+
let current = words[0];
|
|
263
|
+
for (let index = 1; index < words.length; index += 1) {
|
|
264
|
+
const candidate = `${current} ${words[index]}`;
|
|
265
|
+
if (font.widthOfTextAtSize(candidate, fontSize) <= maxWidth) {
|
|
266
|
+
current = candidate;
|
|
267
|
+
} else {
|
|
268
|
+
lines.push(current);
|
|
269
|
+
current = words[index];
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
lines.push(current);
|
|
273
|
+
return lines;
|
|
274
|
+
}
|
|
275
|
+
function drawTextBlock(state, text, style) {
|
|
276
|
+
const cleaned = collapseWhitespace(text);
|
|
277
|
+
if (!cleaned) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const font = style.fontWeight === "bold" ? state.fontBold : state.fontRegular;
|
|
281
|
+
const fontSize = style.fontSize;
|
|
282
|
+
const resolvedLineHeight = style.lineHeight > 3 ? style.lineHeight : style.lineHeight * fontSize;
|
|
283
|
+
const maxWidth = state.pageWidth - state.marginLeft - state.marginRight;
|
|
284
|
+
const lines = wrapText(cleaned, font, fontSize, maxWidth);
|
|
285
|
+
for (const line of lines) {
|
|
286
|
+
ensureRoom(state, resolvedLineHeight);
|
|
287
|
+
const lineWidth = font.widthOfTextAtSize(line, fontSize);
|
|
288
|
+
let x = state.marginLeft;
|
|
289
|
+
if (style.textAlign === "center") {
|
|
290
|
+
x = state.marginLeft + (maxWidth - lineWidth) / 2;
|
|
291
|
+
} else if (style.textAlign === "right") {
|
|
292
|
+
x = state.pageWidth - state.marginRight - lineWidth;
|
|
293
|
+
}
|
|
294
|
+
state.page.drawText(line, {
|
|
295
|
+
x,
|
|
296
|
+
y: state.cursorY - resolvedLineHeight,
|
|
297
|
+
size: fontSize,
|
|
298
|
+
font,
|
|
299
|
+
color: style.color
|
|
300
|
+
});
|
|
301
|
+
state.cursorY -= resolvedLineHeight;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function addVerticalGap(state, points) {
|
|
305
|
+
if (points <= 0) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
ensureRoom(state, points);
|
|
309
|
+
state.cursorY -= points;
|
|
310
|
+
}
|
|
311
|
+
function renderNode(node, state, inheritedStyle, classStyles, listDepth = 0) {
|
|
312
|
+
if (node.nodeType === 3) {
|
|
313
|
+
const textNode = node;
|
|
314
|
+
drawTextBlock(state, textNode.rawText, inheritedStyle);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (node.nodeType !== 1) {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const element = node;
|
|
321
|
+
const tag = element.tagName.toLowerCase();
|
|
322
|
+
if (tag === "style" || tag === "script" || tag === "head") {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const style = getElementStyle(element, classStyles, inheritedStyle);
|
|
326
|
+
if (tag === "br") {
|
|
327
|
+
const resolvedLineHeight = style.lineHeight > 3 ? style.lineHeight : style.lineHeight * style.fontSize;
|
|
328
|
+
addVerticalGap(state, resolvedLineHeight);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (tag === "hr") {
|
|
332
|
+
addVerticalGap(state, 4);
|
|
333
|
+
ensureRoom(state, 8);
|
|
334
|
+
state.page.drawLine({
|
|
335
|
+
start: { x: state.marginLeft, y: state.cursorY },
|
|
336
|
+
end: { x: state.pageWidth - state.marginRight, y: state.cursorY },
|
|
337
|
+
thickness: 0.8,
|
|
338
|
+
color: rgb(0.82, 0.82, 0.82)
|
|
339
|
+
});
|
|
340
|
+
addVerticalGap(state, 8);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const isBlock = BLOCK_TAGS.has(tag);
|
|
344
|
+
if (isBlock) {
|
|
345
|
+
addVerticalGap(state, style.marginTop);
|
|
346
|
+
}
|
|
347
|
+
if (tag === "li") {
|
|
348
|
+
const bulletIndent = 10 + listDepth * 8;
|
|
349
|
+
drawTextBlock(state, `${" ".repeat(Math.max(0, bulletIndent / 4))}\u2022 ${element.textContent}`, style);
|
|
350
|
+
} else if (tag === "ul" || tag === "ol") {
|
|
351
|
+
for (const child of element.childNodes) {
|
|
352
|
+
renderNode(child, state, style, classStyles, listDepth + 1);
|
|
353
|
+
}
|
|
354
|
+
} else if (element.childNodes.length > 0) {
|
|
355
|
+
for (const child of element.childNodes) {
|
|
356
|
+
renderNode(child, state, style, classStyles, listDepth);
|
|
357
|
+
}
|
|
358
|
+
} else {
|
|
359
|
+
drawTextBlock(state, element.textContent, style);
|
|
360
|
+
}
|
|
361
|
+
if (isBlock) {
|
|
362
|
+
addVerticalGap(state, style.marginBottom);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
async function generateNativePdfBuffer(html) {
|
|
366
|
+
const classStyles = extractClassStyles(html);
|
|
367
|
+
const root = parse(html);
|
|
368
|
+
const pdfDoc = await PDFDocument.create();
|
|
369
|
+
const fontRegular = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
|
370
|
+
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
|
371
|
+
const page = pdfDoc.addPage([A4_WIDTH, A4_HEIGHT]);
|
|
372
|
+
const state = {
|
|
373
|
+
doc: pdfDoc,
|
|
374
|
+
page,
|
|
375
|
+
fontRegular,
|
|
376
|
+
fontBold,
|
|
377
|
+
cursorY: A4_HEIGHT - 36,
|
|
378
|
+
pageWidth: A4_WIDTH,
|
|
379
|
+
pageHeight: A4_HEIGHT,
|
|
380
|
+
marginLeft: 36,
|
|
381
|
+
marginRight: 36,
|
|
382
|
+
marginTop: 36,
|
|
383
|
+
marginBottom: 36
|
|
384
|
+
};
|
|
385
|
+
const body = root.querySelector("body");
|
|
386
|
+
const startNode = body ?? root;
|
|
387
|
+
for (const child of startNode.childNodes) {
|
|
388
|
+
renderNode(child, state, DEFAULT_STYLE, classStyles);
|
|
389
|
+
}
|
|
390
|
+
const bytes = await pdfDoc.save();
|
|
391
|
+
return Buffer.from(bytes);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// src/pdf.ts
|
|
8
395
|
var DEFAULT_EXECUTABLE_PATHS = [
|
|
9
396
|
process.env.PUPPETEER_EXECUTABLE_PATH,
|
|
10
397
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
@@ -100,6 +487,9 @@ process.once("SIGTERM", () => {
|
|
|
100
487
|
void closeSharedBrowser().finally(() => process.exit(0));
|
|
101
488
|
});
|
|
102
489
|
async function generatePdfBuffer(input) {
|
|
490
|
+
if (input.engine === "native") {
|
|
491
|
+
return generateNativePdfBuffer(input.html);
|
|
492
|
+
}
|
|
103
493
|
const executablePath = getExecutablePath(input.executablePath);
|
|
104
494
|
const browser = await getBrowser(executablePath);
|
|
105
495
|
const page = await acquirePage(browser);
|
|
@@ -181,25 +571,40 @@ var DEFAULT_TEMPLATE_NAME = "basic.html";
|
|
|
181
571
|
function createPdfRouteHandler(options = {}) {
|
|
182
572
|
const templatesDir = options.templatesDir ?? path.join(process.cwd(), "templates", "pdf");
|
|
183
573
|
const defaultTemplate = options.defaultTemplate ?? DEFAULT_TEMPLATE_NAME;
|
|
574
|
+
const defaultEngine = options.defaultEngine ?? "chromium";
|
|
184
575
|
const prewarm = options.prewarm ?? true;
|
|
185
|
-
if (prewarm) {
|
|
576
|
+
if (prewarm && defaultEngine === "chromium") {
|
|
186
577
|
void prewarmPdfEngine({ poolSize: options.poolSize });
|
|
187
578
|
}
|
|
188
579
|
return async function POST(request) {
|
|
189
580
|
try {
|
|
190
581
|
const body = await request.json();
|
|
191
582
|
const filename = body.filename || "document.pdf";
|
|
192
|
-
const
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
583
|
+
const engine = body.engine ?? defaultEngine;
|
|
584
|
+
const resolvedTemplate = options.resolveTemplate ? await options.resolveTemplate({
|
|
585
|
+
body,
|
|
586
|
+
templatesDir,
|
|
587
|
+
defaultTemplate
|
|
588
|
+
}) : null;
|
|
589
|
+
let html;
|
|
590
|
+
if (resolvedTemplate) {
|
|
591
|
+
html = renderTemplateString(resolvedTemplate, body.data ?? {});
|
|
592
|
+
} else {
|
|
593
|
+
const templateName = body.templatePath || defaultTemplate;
|
|
594
|
+
const safeTemplatePath = path.resolve(templatesDir, templateName);
|
|
595
|
+
if (!safeTemplatePath.startsWith(path.resolve(templatesDir))) {
|
|
596
|
+
return Response.json(
|
|
597
|
+
{ error: "Invalid templatePath. Template must be inside templates directory." },
|
|
598
|
+
{ status: 400 }
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
await access(safeTemplatePath, fsConstants.R_OK);
|
|
602
|
+
html = await renderTemplateFile(safeTemplatePath, body.data ?? {});
|
|
199
603
|
}
|
|
200
|
-
await
|
|
201
|
-
|
|
202
|
-
|
|
604
|
+
const pdfBuffer = await generatePdfBuffer({
|
|
605
|
+
html,
|
|
606
|
+
engine
|
|
607
|
+
});
|
|
203
608
|
return new Response(new Uint8Array(pdfBuffer), {
|
|
204
609
|
status: 200,
|
|
205
610
|
headers: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexdf",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Plug-and-play Next.js PDF endpoint with customizable HTML/HTMX templates",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -41,6 +41,8 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"handlebars": "^4.7.8",
|
|
44
|
+
"node-html-parser": "^7.0.1",
|
|
45
|
+
"pdf-lib": "^1.17.1",
|
|
44
46
|
"puppeteer-core": "^24.2.0"
|
|
45
47
|
},
|
|
46
48
|
"devDependencies": {
|
|
@@ -48,4 +50,4 @@
|
|
|
48
50
|
"tsup": "^8.3.5",
|
|
49
51
|
"typescript": "^5.7.3"
|
|
50
52
|
}
|
|
51
|
-
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>{{title}}</title>
|
|
7
|
+
<style>
|
|
8
|
+
body {
|
|
9
|
+
font-size: 11pt;
|
|
10
|
+
color: #111827;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.document-title {
|
|
14
|
+
font-size: 24pt;
|
|
15
|
+
font-weight: 700;
|
|
16
|
+
margin-bottom: 4pt;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.document-subtitle {
|
|
20
|
+
font-size: 11pt;
|
|
21
|
+
color: #4b5563;
|
|
22
|
+
margin-bottom: 14pt;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.section-title {
|
|
26
|
+
font-size: 13pt;
|
|
27
|
+
font-weight: 700;
|
|
28
|
+
margin-top: 10pt;
|
|
29
|
+
margin-bottom: 4pt;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.muted {
|
|
33
|
+
color: #6b7280;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.small {
|
|
37
|
+
font-size: 10pt;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.row {
|
|
41
|
+
margin-bottom: 2pt;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.summary {
|
|
45
|
+
margin-top: 8pt;
|
|
46
|
+
margin-bottom: 10pt;
|
|
47
|
+
line-height: 1.55;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.footer {
|
|
51
|
+
margin-top: 18pt;
|
|
52
|
+
font-size: 9pt;
|
|
53
|
+
color: #9ca3af;
|
|
54
|
+
}
|
|
55
|
+
</style>
|
|
56
|
+
</head>
|
|
57
|
+
<body>
|
|
58
|
+
<h1 class="document-title">{{title}}</h1>
|
|
59
|
+
<p class="document-subtitle">{{subtitle}}</p>
|
|
60
|
+
|
|
61
|
+
<h2 class="section-title">From</h2>
|
|
62
|
+
<p class="row">{{fromName}}</p>
|
|
63
|
+
<p class="row muted">{{fromEmail}}</p>
|
|
64
|
+
|
|
65
|
+
<h2 class="section-title">To</h2>
|
|
66
|
+
<p class="row">{{toName}}</p>
|
|
67
|
+
<p class="row muted">{{toEmail}}</p>
|
|
68
|
+
|
|
69
|
+
<h2 class="section-title">Summary</h2>
|
|
70
|
+
<p class="summary">{{summary}}</p>
|
|
71
|
+
|
|
72
|
+
<p class="footer">Generated on {{generatedAt}}</p>
|
|
73
|
+
</body>
|
|
74
|
+
</html>
|