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 CHANGED
@@ -1,14 +1,20 @@
1
1
  # nexdf
2
2
 
3
- Plug-and-play PDF generation for Next.js using customizable HTML/HTMX templates and Handlebars placeholders.
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
- - Tailwind-friendly templates you can edit directly
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
- "templatePath": "basic.html",
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 templateName = body.templatePath || defaultTemplate;
193
- const safeTemplatePath = path.resolve(templatesDir, templateName);
194
- if (!safeTemplatePath.startsWith(path.resolve(templatesDir))) {
195
- return Response.json(
196
- { error: "Invalid templatePath. Template must be inside templates directory." },
197
- { status: 400 }
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 access(safeTemplatePath, fsConstants.R_OK);
201
- const html = await renderTemplateFile(safeTemplatePath, body.data ?? {});
202
- const pdfBuffer = await generatePdfBuffer({ html });
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.0",
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>