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 CHANGED
@@ -1,12 +1,17 @@
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
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": "basic.html",
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({ html });
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.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>