nexdf 0.1.1 → 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 +25 -4
- package/dist/cli/index.js +83 -0
- package/dist/index.d.ts +17 -13
- package/dist/index.js +397 -2
- package/package.json +3 -1
- package/templates/native/native.html +74 -0
package/README.md
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
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
|
|
12
17
|
- Optional DB template resolver with file-template fallback
|
|
@@ -29,6 +34,7 @@ This creates:
|
|
|
29
34
|
|
|
30
35
|
- `app/api/pdf/route.ts`
|
|
31
36
|
- `templates/pdf/basic.html`
|
|
37
|
+
- `templates/pdf/native.html`
|
|
32
38
|
|
|
33
39
|
2) Ensure Node runtime in route (generated by default):
|
|
34
40
|
|
|
@@ -40,6 +46,7 @@ export const runtime = "nodejs";
|
|
|
40
46
|
export const POST = createPdfRouteHandler({
|
|
41
47
|
templatesDir: process.cwd() + "/templates/pdf",
|
|
42
48
|
defaultTemplate: "basic.html",
|
|
49
|
+
defaultEngine: "chromium", // or "native"
|
|
43
50
|
poolSize: 2
|
|
44
51
|
});
|
|
45
52
|
```
|
|
@@ -51,8 +58,9 @@ curl -X POST http://localhost:3000/api/pdf \
|
|
|
51
58
|
-H "content-type: application/json" \
|
|
52
59
|
-d '{
|
|
53
60
|
"filename": "invoice.pdf",
|
|
61
|
+
"engine": "native",
|
|
54
62
|
"templateKey": "basic",
|
|
55
|
-
"templatePath": "
|
|
63
|
+
"templatePath": "native.html",
|
|
56
64
|
"data": {
|
|
57
65
|
"title": "Invoice #001",
|
|
58
66
|
"subtitle": "Thanks for your business",
|
|
@@ -88,6 +96,7 @@ Body:
|
|
|
88
96
|
```json
|
|
89
97
|
{
|
|
90
98
|
"filename": "document.pdf",
|
|
99
|
+
"engine": "chromium",
|
|
91
100
|
"templateKey": "basic",
|
|
92
101
|
"templatePath": "basic.html",
|
|
93
102
|
"data": {
|
|
@@ -96,6 +105,16 @@ Body:
|
|
|
96
105
|
}
|
|
97
106
|
```
|
|
98
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
|
+
|
|
99
118
|
`templateKey` is available when you use a custom template resolver.
|
|
100
119
|
|
|
101
120
|
Template resolution behavior:
|
|
@@ -107,7 +126,7 @@ Template resolution behavior:
|
|
|
107
126
|
## Runtime requirements
|
|
108
127
|
|
|
109
128
|
- Next route runtime must be `nodejs`
|
|
110
|
-
- Chrome/Chromium must be available
|
|
129
|
+
- Chrome/Chromium must be available only when using `chromium` engine
|
|
111
130
|
- Set executable path if needed:
|
|
112
131
|
|
|
113
132
|
```bash
|
|
@@ -119,6 +138,7 @@ export PUPPETEER_EXECUTABLE_PATH="/Applications/Google Chrome.app/Contents/MacOS
|
|
|
119
138
|
- Prewarms Chromium when route handler is created
|
|
120
139
|
- Reuses hot pages via an internal pool (`poolSize`)
|
|
121
140
|
- Caches compiled templates with file mtime invalidation
|
|
141
|
+
- Native engine skips browser boot and is typically much faster for text/document layouts
|
|
122
142
|
|
|
123
143
|
Note: true zero cold start is not possible if your hosting platform starts a brand-new container/process.
|
|
124
144
|
|
|
@@ -132,6 +152,7 @@ await prewarmPdfEngine({ poolSize: 2 });
|
|
|
132
152
|
export const POST = createPdfRouteHandler({
|
|
133
153
|
templatesDir: process.cwd() + "/templates/pdf",
|
|
134
154
|
defaultTemplate: "basic.html",
|
|
155
|
+
defaultEngine: "chromium",
|
|
135
156
|
poolSize: 2
|
|
136
157
|
});
|
|
137
158
|
```
|
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>;
|
|
@@ -10,6 +24,7 @@ type PdfRequestBody = {
|
|
|
10
24
|
templatePath?: string;
|
|
11
25
|
templateKey?: string;
|
|
12
26
|
filename?: string;
|
|
27
|
+
engine?: PdfEngine;
|
|
13
28
|
};
|
|
14
29
|
type ResolveTemplateContext = {
|
|
15
30
|
body: PdfRequestBody;
|
|
@@ -19,22 +34,11 @@ type ResolveTemplateContext = {
|
|
|
19
34
|
type CreatePdfRouteHandlerOptions = {
|
|
20
35
|
templatesDir?: string;
|
|
21
36
|
defaultTemplate?: string;
|
|
37
|
+
defaultEngine?: PdfEngine;
|
|
22
38
|
prewarm?: boolean;
|
|
23
39
|
poolSize?: number;
|
|
24
40
|
resolveTemplate?: (context: ResolveTemplateContext) => Promise<string | null>;
|
|
25
41
|
};
|
|
26
42
|
declare function createPdfRouteHandler(options?: CreatePdfRouteHandlerOptions): (request: Request) => Promise<Response>;
|
|
27
43
|
|
|
28
|
-
type GeneratePdfInput
|
|
29
|
-
html: string;
|
|
30
|
-
pdf?: PDFOptions;
|
|
31
|
-
executablePath?: string;
|
|
32
|
-
};
|
|
33
|
-
type PrewarmPdfEngineOptions = {
|
|
34
|
-
executablePath?: string;
|
|
35
|
-
poolSize?: number;
|
|
36
|
-
};
|
|
37
|
-
declare function prewarmPdfEngine(options?: PrewarmPdfEngineOptions): Promise<void>;
|
|
38
|
-
declare function generatePdfBuffer(input: GeneratePdfInput): Promise<Buffer>;
|
|
39
|
-
|
|
40
|
-
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,14 +571,16 @@ 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";
|
|
583
|
+
const engine = body.engine ?? defaultEngine;
|
|
192
584
|
const resolvedTemplate = options.resolveTemplate ? await options.resolveTemplate({
|
|
193
585
|
body,
|
|
194
586
|
templatesDir,
|
|
@@ -209,7 +601,10 @@ function createPdfRouteHandler(options = {}) {
|
|
|
209
601
|
await access(safeTemplatePath, fsConstants.R_OK);
|
|
210
602
|
html = await renderTemplateFile(safeTemplatePath, body.data ?? {});
|
|
211
603
|
}
|
|
212
|
-
const pdfBuffer = await generatePdfBuffer({
|
|
604
|
+
const pdfBuffer = await generatePdfBuffer({
|
|
605
|
+
html,
|
|
606
|
+
engine
|
|
607
|
+
});
|
|
213
608
|
return new Response(new Uint8Array(pdfBuffer), {
|
|
214
609
|
status: 200,
|
|
215
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": {
|
|
@@ -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>
|