@lupinum/nuxt-pdf 0.3.0
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/API_REPORT.md +262 -0
- package/CHANGELOG.md +168 -0
- package/CONFORMANCE.md +496 -0
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/THIRD_PARTY_NOTICES.md +75 -0
- package/dist/module.d.mts +24 -0
- package/dist/module.json +12 -0
- package/dist/module.mjs +368 -0
- package/dist/runtime/authoring.d.ts +141 -0
- package/dist/runtime/authoring.js +25 -0
- package/dist/runtime/components/_props.d.ts +196 -0
- package/dist/runtime/components/_props.js +63 -0
- package/dist/runtime/components/document.d.ts +9 -0
- package/dist/runtime/components/document.js +26 -0
- package/dist/runtime/components/index.d.ts +4 -0
- package/dist/runtime/components/index.js +27 -0
- package/dist/runtime/components/svg.d.ts +17 -0
- package/dist/runtime/components/svg.js +50 -0
- package/dist/runtime/composables/index.d.ts +2 -0
- package/dist/runtime/composables/index.js +3 -0
- package/dist/runtime/composables/use-pdf-page-numbers.d.ts +39 -0
- package/dist/runtime/composables/use-pdf-page-numbers.js +17 -0
- package/dist/runtime/define-pdf.d.ts +6 -0
- package/dist/runtime/define-pdf.js +5 -0
- package/dist/runtime/fonts.d.ts +16 -0
- package/dist/runtime/fonts.js +0 -0
- package/dist/runtime/renderer/index.d.ts +3 -0
- package/dist/runtime/renderer/index.js +1 -0
- package/dist/runtime/renderer/node-ops.d.ts +5 -0
- package/dist/runtime/renderer/node-ops.js +281 -0
- package/dist/runtime/renderer/patch-prop.d.ts +3 -0
- package/dist/runtime/renderer/patch-prop.js +223 -0
- package/dist/runtime/renderer/render-component.d.ts +14 -0
- package/dist/runtime/renderer/render-component.js +201 -0
- package/dist/runtime/renderer/types.d.ts +33 -0
- package/dist/runtime/renderer/types.js +56 -0
- package/dist/runtime/renderer/validate-tree.d.ts +3 -0
- package/dist/runtime/renderer/validate-tree.js +428 -0
- package/dist/runtime/server/assets/errors.d.ts +12 -0
- package/dist/runtime/server/assets/errors.js +15 -0
- package/dist/runtime/server/assets/remote.d.ts +48 -0
- package/dist/runtime/server/assets/remote.js +234 -0
- package/dist/runtime/server/assets/resolve-asset.d.ts +53 -0
- package/dist/runtime/server/assets/resolve-asset.js +482 -0
- package/dist/runtime/server/engine/CONTRACTS.md +386 -0
- package/dist/runtime/server/engine/fonts.d.ts +8 -0
- package/dist/runtime/server/engine/fonts.js +19 -0
- package/dist/runtime/server/engine/layout-passes.d.ts +42 -0
- package/dist/runtime/server/engine/layout-passes.js +58 -0
- package/dist/runtime/server/engine/react-pdf-pdfkit.d.ts +7 -0
- package/dist/runtime/server/engine/render-document.d.ts +31 -0
- package/dist/runtime/server/engine/render-document.js +236 -0
- package/dist/runtime/server/index.d.ts +5 -0
- package/dist/runtime/server/index.js +9 -0
- package/dist/runtime/server/preview.d.ts +17 -0
- package/dist/runtime/server/preview.js +332 -0
- package/dist/runtime/server/registry.d.ts +41 -0
- package/dist/runtime/server/registry.js +270 -0
- package/dist/runtime/server/render-limits.d.ts +51 -0
- package/dist/runtime/server/render-limits.js +143 -0
- package/dist/runtime/server/result.d.ts +6 -0
- package/dist/runtime/server/result.js +81 -0
- package/dist/runtime/server/tsconfig.json +3 -0
- package/dist/runtime/shared/errors.d.ts +22 -0
- package/dist/runtime/shared/errors.js +22 -0
- package/dist/runtime/shared/index.d.ts +4 -0
- package/dist/runtime/shared/index.js +7 -0
- package/dist/runtime/shared/template.d.ts +63 -0
- package/dist/runtime/shared/template.js +1 -0
- package/dist/shared/nuxt-pdf.D2ZziYn4.mjs +1132 -0
- package/dist/test.d.mts +198 -0
- package/dist/test.mjs +696 -0
- package/dist/types.d.mts +13 -0
- package/package.json +135 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import layoutDocument from "@react-pdf/layout";
|
|
3
|
+
import PDFDocument from "@react-pdf/pdfkit";
|
|
4
|
+
import renderPDF from "@react-pdf/render";
|
|
5
|
+
import {
|
|
6
|
+
createPdfFontStore
|
|
7
|
+
} from "./fonts.js";
|
|
8
|
+
import {
|
|
9
|
+
NuxtPdfError,
|
|
10
|
+
PDF_ERROR_CODES
|
|
11
|
+
} from "../../shared/errors.js";
|
|
12
|
+
import {
|
|
13
|
+
PDF_PRIMITIVES
|
|
14
|
+
} from "../../authoring.js";
|
|
15
|
+
import {
|
|
16
|
+
enforceMaxPages
|
|
17
|
+
} from "../render-limits.js";
|
|
18
|
+
const runLayout = layoutDocument;
|
|
19
|
+
const DYNAMIC_LINE_HEIGHT_SENTINEL = "";
|
|
20
|
+
const normalizeDynamicTextLineHeight = (node) => {
|
|
21
|
+
if (node.type === PDF_PRIMITIVES.Text && typeof node.props.render === "function") {
|
|
22
|
+
node.style = Array.isArray(node.style) ? [...node.style, { lineHeight: DYNAMIC_LINE_HEIGHT_SENTINEL }] : { ...node.style ?? {}, lineHeight: DYNAMIC_LINE_HEIGHT_SENTINEL };
|
|
23
|
+
}
|
|
24
|
+
for (const child of node.children) {
|
|
25
|
+
if ("children" in child) {
|
|
26
|
+
normalizeDynamicTextLineHeight(child);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const compact = (values) => Object.fromEntries(
|
|
31
|
+
Object.entries(values).filter(([, value]) => value !== void 0 && value !== null)
|
|
32
|
+
);
|
|
33
|
+
const collectStream = (stream, limits) => new Promise((resolve, reject) => {
|
|
34
|
+
const chunks = [];
|
|
35
|
+
let total = 0;
|
|
36
|
+
stream.on("data", (chunk) => {
|
|
37
|
+
try {
|
|
38
|
+
limits?.deadline.check();
|
|
39
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk);
|
|
40
|
+
total += bytes.byteLength;
|
|
41
|
+
if (limits && total > limits.maxOutputBytes) {
|
|
42
|
+
const error = new NuxtPdfError(
|
|
43
|
+
PDF_ERROR_CODES.LimitExceeded,
|
|
44
|
+
`PDF output exceeded pdf.limits.maxOutputBytes (${limits.maxOutputBytes}).`
|
|
45
|
+
);
|
|
46
|
+
limits.abortController.abort(error);
|
|
47
|
+
stream.destroy(error);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
chunks.push(bytes);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
53
|
+
limits?.abortController.abort(cause);
|
|
54
|
+
stream.destroy(cause);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
stream.on("end", () => {
|
|
58
|
+
try {
|
|
59
|
+
limits?.deadline.check();
|
|
60
|
+
resolve(Buffer.concat(chunks, total));
|
|
61
|
+
} catch (error) {
|
|
62
|
+
reject(error);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
stream.on("error", reject);
|
|
66
|
+
});
|
|
67
|
+
const createContext = (props, compress) => new PDFDocument({
|
|
68
|
+
compress,
|
|
69
|
+
pdfVersion: props.pdfVersion,
|
|
70
|
+
lang: props.language,
|
|
71
|
+
displayTitle: true,
|
|
72
|
+
autoFirstPage: false,
|
|
73
|
+
pageLayout: props.pageLayout,
|
|
74
|
+
info: compact({
|
|
75
|
+
Title: props.title,
|
|
76
|
+
Author: props.author,
|
|
77
|
+
Subject: props.subject,
|
|
78
|
+
Keywords: props.keywords,
|
|
79
|
+
Creator: props.creator ?? "nuxt-pdf",
|
|
80
|
+
Producer: props.producer ?? "nuxt-pdf",
|
|
81
|
+
CreationDate: props.creationDate ?? /* @__PURE__ */ new Date()
|
|
82
|
+
})
|
|
83
|
+
});
|
|
84
|
+
export const layoutPdfTree = async (document, fontStore, limits) => {
|
|
85
|
+
limits?.deadline.check();
|
|
86
|
+
if (document.type !== "DOCUMENT") {
|
|
87
|
+
throw new NuxtPdfError(
|
|
88
|
+
PDF_ERROR_CODES.TreeInvalid,
|
|
89
|
+
`Expected a DOCUMENT root, received ${document.type}.`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
normalizeDynamicTextLineHeight(document);
|
|
93
|
+
let layout;
|
|
94
|
+
try {
|
|
95
|
+
layout = await runLayout(
|
|
96
|
+
document,
|
|
97
|
+
fontStore
|
|
98
|
+
);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw new NuxtPdfError(
|
|
101
|
+
PDF_ERROR_CODES.LayoutError,
|
|
102
|
+
`PDF layout failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
103
|
+
{ cause: error }
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
limits?.deadline.check();
|
|
107
|
+
if (limits) enforceMaxPages(countPages(layout), limits.maxPages);
|
|
108
|
+
return layout;
|
|
109
|
+
};
|
|
110
|
+
const visitPageNodes = (page, visit) => {
|
|
111
|
+
if (!("props" in page)) return;
|
|
112
|
+
visit(page);
|
|
113
|
+
const children = page.children;
|
|
114
|
+
if (Array.isArray(children)) {
|
|
115
|
+
for (const child of children) visitPageNodes(child, visit);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
const documentPages = (layout) => layout.children ?? [];
|
|
119
|
+
export const countPages = (layout) => documentPages(layout).length;
|
|
120
|
+
const nodeId = (node) => {
|
|
121
|
+
const id = node.props?.id;
|
|
122
|
+
return typeof id === "string" && id.length > 0 ? id : void 0;
|
|
123
|
+
};
|
|
124
|
+
export const extractDestinationPages = (layout) => {
|
|
125
|
+
const pages = /* @__PURE__ */ Object.create(null);
|
|
126
|
+
documentPages(layout).forEach((page, index) => {
|
|
127
|
+
const pageNumber = index + 1;
|
|
128
|
+
visitPageNodes(page, (node) => {
|
|
129
|
+
const id = nodeId(node);
|
|
130
|
+
if (id !== void 0 && !Object.hasOwn(pages, id)) pages[id] = pageNumber;
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
return pages;
|
|
134
|
+
};
|
|
135
|
+
const anchorDestinationsAtFirstPage = (layout) => {
|
|
136
|
+
const seen = /* @__PURE__ */ new Set();
|
|
137
|
+
const strip = (node) => {
|
|
138
|
+
if (!("props" in node)) return node;
|
|
139
|
+
const element = node;
|
|
140
|
+
let next = element;
|
|
141
|
+
const id = nodeId(element);
|
|
142
|
+
if (id !== void 0) {
|
|
143
|
+
if (!seen.has(id)) {
|
|
144
|
+
seen.add(id);
|
|
145
|
+
} else {
|
|
146
|
+
const { id: _dropped, ...rest } = element.props;
|
|
147
|
+
next = { ...element, props: rest };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const children = next.children;
|
|
151
|
+
if (Array.isArray(children)) {
|
|
152
|
+
let changed = false;
|
|
153
|
+
const stripped = children.map((child) => {
|
|
154
|
+
const result = strip(child);
|
|
155
|
+
if (result !== child) changed = true;
|
|
156
|
+
return result;
|
|
157
|
+
});
|
|
158
|
+
if (changed) next = { ...next, children: stripped };
|
|
159
|
+
}
|
|
160
|
+
return next;
|
|
161
|
+
};
|
|
162
|
+
const root = layout;
|
|
163
|
+
root.children = documentPages(layout).map(strip);
|
|
164
|
+
};
|
|
165
|
+
const SVG_SHAPE_TYPES = /* @__PURE__ */ new Set([
|
|
166
|
+
PDF_PRIMITIVES.Circle,
|
|
167
|
+
PDF_PRIMITIVES.Ellipse,
|
|
168
|
+
PDF_PRIMITIVES.G,
|
|
169
|
+
PDF_PRIMITIVES.Line,
|
|
170
|
+
PDF_PRIMITIVES.Path,
|
|
171
|
+
PDF_PRIMITIVES.Polygon,
|
|
172
|
+
PDF_PRIMITIVES.Polyline,
|
|
173
|
+
PDF_PRIMITIVES.Rect,
|
|
174
|
+
PDF_PRIMITIVES.Text
|
|
175
|
+
]);
|
|
176
|
+
const GRADIENT_ZERO_KEYS = {
|
|
177
|
+
[PDF_PRIMITIVES.LinearGradient]: ["x2"],
|
|
178
|
+
[PDF_PRIMITIVES.RadialGradient]: ["cx", "cy", "fx", "fy", "r"]
|
|
179
|
+
};
|
|
180
|
+
const normalizeGradientZeros = (value) => {
|
|
181
|
+
if (typeof value !== "object" || value === null || !("type" in value) || !("props" in value) || value.type !== PDF_PRIMITIVES.LinearGradient && value.type !== PDF_PRIMITIVES.RadialGradient || typeof value.props !== "object" || value.props === null) return;
|
|
182
|
+
const props = value.props;
|
|
183
|
+
const keys = GRADIENT_ZERO_KEYS[value.type];
|
|
184
|
+
for (const key of keys) {
|
|
185
|
+
if (props[key] === 0) props[key] = "0";
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const normalizeResolvedSvgZeros = (layout) => {
|
|
189
|
+
for (const page of documentPages(layout)) {
|
|
190
|
+
visitPageNodes(page, (node) => {
|
|
191
|
+
normalizeGradientZeros(node.props.fill);
|
|
192
|
+
if (!SVG_SHAPE_TYPES.has(node.type)) return;
|
|
193
|
+
if (node.props.fillOpacity === 0) node.props.fillOpacity = "0";
|
|
194
|
+
if (node.props.strokeWidth === 0) node.props.stroke = null;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
export const serializePdfLayout = async (props, layout, compress, limits) => {
|
|
199
|
+
limits?.deadline.check();
|
|
200
|
+
try {
|
|
201
|
+
anchorDestinationsAtFirstPage(layout);
|
|
202
|
+
normalizeResolvedSvgZeros(layout);
|
|
203
|
+
const context = createContext(props, compress);
|
|
204
|
+
const collected = collectStream(context, limits);
|
|
205
|
+
try {
|
|
206
|
+
renderPDF(
|
|
207
|
+
context,
|
|
208
|
+
layout
|
|
209
|
+
);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
212
|
+
context.destroy(cause);
|
|
213
|
+
await collected.catch(() => void 0);
|
|
214
|
+
throw cause;
|
|
215
|
+
}
|
|
216
|
+
return await collected;
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (error instanceof NuxtPdfError) throw error;
|
|
219
|
+
throw new NuxtPdfError(
|
|
220
|
+
PDF_ERROR_CODES.RenderError,
|
|
221
|
+
`PDF serialization failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
222
|
+
{ cause: error }
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
export const renderDocument = async (document, options = {}) => {
|
|
227
|
+
const fontStore = options.fontStore ?? createPdfFontStore();
|
|
228
|
+
const layout = await layoutPdfTree(document, fontStore, options.limits);
|
|
229
|
+
const bytes = await serializePdfLayout(
|
|
230
|
+
document.props,
|
|
231
|
+
layout,
|
|
232
|
+
options.compress ?? true,
|
|
233
|
+
options.limits
|
|
234
|
+
);
|
|
235
|
+
return { bytes, layout };
|
|
236
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createPdfPreviewEntry, createPdfRegistry, createPdfTemplate, } from './registry.js';
|
|
2
|
+
export { NuxtPdfError, PDF_ERROR_CODES, } from '../shared/errors.js';
|
|
3
|
+
export type { PdfErrorCode } from '../shared/errors.js';
|
|
4
|
+
export type { PdfPreviewEntry, PdfPreviewEntryOptions, PdfRegistry, PdfRegistryEntries, } from './registry.js';
|
|
5
|
+
export type { PdfRenderDiagnostics, PdfRenderResult, PdfComponentProps, PdfTemplate, } from '../shared/template.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PdfPreviewEntry } from './registry.js';
|
|
2
|
+
type PreviewTemplate = PdfPreviewEntry<object>;
|
|
3
|
+
export type PdfPreviewRegistry = Readonly<Record<string, PreviewTemplate>>;
|
|
4
|
+
export interface PdfPreviewRequest {
|
|
5
|
+
path?: string;
|
|
6
|
+
rootPath?: string;
|
|
7
|
+
scenario?: string;
|
|
8
|
+
/** Token of a parked viewer render the raw route should serve verbatim. */
|
|
9
|
+
render?: string;
|
|
10
|
+
/** Serve a raw response as a download instead of inline. */
|
|
11
|
+
download?: boolean;
|
|
12
|
+
/** Nuxt/Vite client path used by the development-only auto-refresh hook. */
|
|
13
|
+
hmrClientPath?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare const renderPdfPreview: (registry: PdfPreviewRegistry, request?: PdfPreviewRequest) => Promise<Response>;
|
|
16
|
+
declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<Response>>;
|
|
17
|
+
export default _default;
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
defineEventHandler,
|
|
4
|
+
getQuery,
|
|
5
|
+
getRequestURL
|
|
6
|
+
} from "h3";
|
|
7
|
+
import { pdfPreview } from "#pdf";
|
|
8
|
+
import { hmrClientPath } from "#pdf-preview-config";
|
|
9
|
+
import { NuxtPdfError } from "../shared/errors.js";
|
|
10
|
+
const PREVIEW_ROUTE = "/_pdf";
|
|
11
|
+
const parkedRenders = /* @__PURE__ */ new Map();
|
|
12
|
+
const lastSuccessfulRenders = /* @__PURE__ */ new WeakMap();
|
|
13
|
+
const PARKED_RENDER_LIMIT = 8;
|
|
14
|
+
const PARKED_RENDER_TTL_MS = 3e4;
|
|
15
|
+
const DEFAULT_HMR_CLIENT_PATH = "/_nuxt/@vite/client";
|
|
16
|
+
const scenarioCacheKey = (scenario) => scenario ?? "\0";
|
|
17
|
+
const rememberSuccessfulRender = (template, result, scenario) => {
|
|
18
|
+
let renders = lastSuccessfulRenders.get(template);
|
|
19
|
+
if (!renders) {
|
|
20
|
+
renders = /* @__PURE__ */ new Map();
|
|
21
|
+
lastSuccessfulRenders.set(template, renders);
|
|
22
|
+
}
|
|
23
|
+
renders.set(scenarioCacheKey(scenario), result);
|
|
24
|
+
};
|
|
25
|
+
const previousSuccessfulRender = (template, scenario) => lastSuccessfulRenders.get(template)?.get(scenarioCacheKey(scenario));
|
|
26
|
+
const pruneExpiredRenders = (now) => {
|
|
27
|
+
for (const [token, render] of parkedRenders) {
|
|
28
|
+
if (render.expiresAt <= now) parkedRenders.delete(token);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const createRenderToken = () => {
|
|
32
|
+
let token;
|
|
33
|
+
do
|
|
34
|
+
token = randomBytes(32).toString("base64url");
|
|
35
|
+
while (parkedRenders.has(token));
|
|
36
|
+
return token;
|
|
37
|
+
};
|
|
38
|
+
const parkRender = (result, key, scenario) => {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
pruneExpiredRenders(now);
|
|
41
|
+
const token = createRenderToken();
|
|
42
|
+
parkedRenders.set(token, {
|
|
43
|
+
expiresAt: now + PARKED_RENDER_TTL_MS,
|
|
44
|
+
key,
|
|
45
|
+
result,
|
|
46
|
+
scenario
|
|
47
|
+
});
|
|
48
|
+
while (parkedRenders.size > PARKED_RENDER_LIMIT) {
|
|
49
|
+
const oldest = parkedRenders.keys().next().value;
|
|
50
|
+
if (oldest === void 0) break;
|
|
51
|
+
parkedRenders.delete(oldest);
|
|
52
|
+
}
|
|
53
|
+
return token;
|
|
54
|
+
};
|
|
55
|
+
const takeParkedRender = (token, key, scenario) => {
|
|
56
|
+
const render = parkedRenders.get(token);
|
|
57
|
+
if (!render) return void 0;
|
|
58
|
+
if (render.expiresAt <= Date.now()) {
|
|
59
|
+
parkedRenders.delete(token);
|
|
60
|
+
return void 0;
|
|
61
|
+
}
|
|
62
|
+
if (render.key !== key || render.scenario !== scenario) return void 0;
|
|
63
|
+
parkedRenders.delete(token);
|
|
64
|
+
return render.result;
|
|
65
|
+
};
|
|
66
|
+
const escapeHtml = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
67
|
+
const htmlResponse = (title, content, status = 200) => new Response(`<!doctype html>
|
|
68
|
+
<html lang="en">
|
|
69
|
+
<head>
|
|
70
|
+
<meta charset="utf-8">
|
|
71
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
72
|
+
<title>${escapeHtml(title)} \xB7 Nuxt PDF</title>
|
|
73
|
+
<style>
|
|
74
|
+
:root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, sans-serif; }
|
|
75
|
+
* { box-sizing: border-box; }
|
|
76
|
+
body { margin: 0; background: #101211; color: #f3f5f2; }
|
|
77
|
+
main { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0; }
|
|
78
|
+
a { color: #a9e477; }
|
|
79
|
+
header { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 24px; }
|
|
80
|
+
h1 { margin: 0; font-size: clamp(1.5rem, 4vw, 2.5rem); letter-spacing: -0.04em; }
|
|
81
|
+
p { color: #b6beb6; line-height: 1.6; }
|
|
82
|
+
code { color: #dce4dc; }
|
|
83
|
+
.actions { display: flex; align-items: baseline; gap: 16px; }
|
|
84
|
+
nav { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
|
|
85
|
+
nav a { border: 1px solid #3f493f; border-radius: 999px; padding: 6px 11px; text-decoration: none; color: #dce4dc; }
|
|
86
|
+
nav a.active { background: #a9e477; border-color: #a9e477; color: #101211; font-weight: 600; }
|
|
87
|
+
iframe { width: 100%; min-height: calc(100vh - 300px); border: 1px solid #343a35; border-radius: 10px; background: white; }
|
|
88
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
|
|
89
|
+
.card { border: 1px solid #343a35; border-radius: 10px; padding: 16px; display: flex; flex-direction: column; gap: 6px; }
|
|
90
|
+
.card strong { font-size: 1.05rem; }
|
|
91
|
+
.card .file { color: #8b948b; font-size: 0.8rem; word-break: break-all; }
|
|
92
|
+
.card .meta { color: #b6beb6; font-size: 0.85rem; }
|
|
93
|
+
.card .links { margin-top: 8px; display: flex; gap: 14px; }
|
|
94
|
+
.diagnostics { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; margin-bottom: 16px; }
|
|
95
|
+
.stat { border: 1px solid #343a35; border-radius: 10px; padding: 12px 14px; }
|
|
96
|
+
.stat .label { color: #8b948b; font-size: 0.72rem; letter-spacing: 0.06em; text-transform: uppercase; }
|
|
97
|
+
.stat .value { font-size: 1.35rem; font-variant-numeric: tabular-nums; margin-top: 4px; }
|
|
98
|
+
.font-faces { border: 1px solid #343a35; background: #151815; border-radius: 10px; padding: 12px 16px; margin-bottom: 16px; }
|
|
99
|
+
.font-faces .label { color: #8b948b; font-size: 0.78rem; letter-spacing: 0.04em; text-transform: uppercase; margin-bottom: 8px; }
|
|
100
|
+
.font-faces ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
|
101
|
+
.font-faces li { color: #d7ddd7; font-size: 0.85rem; line-height: 1.5; }
|
|
102
|
+
.error { border: 1px solid #6b2f2b; background: #1d1210; border-left: 3px solid #ef786f; border-radius: 10px; padding: 18px 20px; }
|
|
103
|
+
.error h2 { margin: 0 0 12px; font-size: 1.1rem; color: #f3d7d3; }
|
|
104
|
+
.error dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0 0 12px; }
|
|
105
|
+
.error dt { color: #c69a95; font-size: 0.8rem; }
|
|
106
|
+
.error dd { margin: 0; font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.85rem; word-break: break-all; }
|
|
107
|
+
.error .message { color: #f0d9d6; line-height: 1.6; white-space: pre-wrap; }
|
|
108
|
+
.stale { border: 1px solid #755d25; background: #211b0e; color: #f1d88c; border-radius: 10px; padding: 12px 16px; margin: 16px 0; }
|
|
109
|
+
</style>
|
|
110
|
+
</head>
|
|
111
|
+
<body><main>${content}</main></body>
|
|
112
|
+
</html>`, {
|
|
113
|
+
status,
|
|
114
|
+
headers: {
|
|
115
|
+
"cache-control": "no-store",
|
|
116
|
+
"content-type": "text/html; charset=utf-8",
|
|
117
|
+
"x-content-type-options": "nosniff"
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
const encodeTemplatePath = (key) => key.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
121
|
+
const templateByKey = (registry, key) => Object.values(registry).find((entry) => entry.template.key === key);
|
|
122
|
+
const scenarioCount = (template) => template.scenarioNames.length > 0 ? `${template.scenarioNames.length} scenario${template.scenarioNames.length === 1 ? "" : "s"}` : "sample data";
|
|
123
|
+
const indexPage = (registry, rootPath) => {
|
|
124
|
+
const templates = Object.values(registry).sort(
|
|
125
|
+
(left, right) => left.template.key.localeCompare(right.template.key)
|
|
126
|
+
);
|
|
127
|
+
if (templates.length === 0) {
|
|
128
|
+
return htmlResponse(
|
|
129
|
+
"PDF templates",
|
|
130
|
+
"<header><h1>PDF templates</h1></header><p>No templates found. Add <code>pdfs/invoice.vue</code> and restart the development server.</p>"
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const cards = templates.map((template) => {
|
|
134
|
+
const base = `${rootPath}/${encodeTemplatePath(template.template.key)}`;
|
|
135
|
+
const file = template.file ? `<span class="file">${escapeHtml(template.file)}</span>` : "";
|
|
136
|
+
return `<article class="card"><strong>${escapeHtml(template.template.key)}</strong>` + file + `<span class="meta">${scenarioCount(template)}</span><span class="links"><a href="${escapeHtml(base)}">Preview</a><a href="${escapeHtml(`${base}.pdf`)}">Raw PDF</a></span></article>`;
|
|
137
|
+
}).join("");
|
|
138
|
+
return htmlResponse(
|
|
139
|
+
"PDF templates",
|
|
140
|
+
`<header><h1>PDF templates</h1><span>${templates.length}</span></header><div class="cards">${cards}</div>`
|
|
141
|
+
);
|
|
142
|
+
};
|
|
143
|
+
const errorPage = (title, message, rootPath, status) => htmlResponse(
|
|
144
|
+
title,
|
|
145
|
+
`<header><h1>${escapeHtml(title)}</h1><a href="${escapeHtml(rootPath)}">All templates</a></header><div class="error"><p class="message">${escapeHtml(message)}</p></div>`,
|
|
146
|
+
status
|
|
147
|
+
);
|
|
148
|
+
const rawUrl = (rootPath, key, scenario, renderToken, download = false) => {
|
|
149
|
+
const params = new URLSearchParams();
|
|
150
|
+
if (scenario !== void 0) params.set("scenario", scenario);
|
|
151
|
+
if (renderToken !== void 0) params.set("render", renderToken);
|
|
152
|
+
if (download) params.set("download", "1");
|
|
153
|
+
const query = params.size > 0 ? `?${params.toString()}` : "";
|
|
154
|
+
return `${rootPath}/${encodeTemplatePath(key)}.pdf${query}`;
|
|
155
|
+
};
|
|
156
|
+
const scenarioNav = (template, rootPath, active) => {
|
|
157
|
+
const base = `${rootPath}/${encodeTemplatePath(template.template.key)}`;
|
|
158
|
+
const tab = (label, href, isActive) => `<a href="${escapeHtml(href)}"${isActive ? ' class="active" aria-current="page"' : ""}>${escapeHtml(label)}</a>`;
|
|
159
|
+
const tabs = [
|
|
160
|
+
tab("Default", base, active === void 0),
|
|
161
|
+
...template.scenarioNames.map(
|
|
162
|
+
(name) => tab(name, `${base}?scenario=${encodeURIComponent(name)}`, active === name)
|
|
163
|
+
)
|
|
164
|
+
].join("");
|
|
165
|
+
return `<nav>${tabs}</nav>`;
|
|
166
|
+
};
|
|
167
|
+
const formatDuration = (ms) => ms >= 1e3 ? `${(ms / 1e3).toFixed(2)} s` : `${Math.round(ms)} ms`;
|
|
168
|
+
const formatBytes = (bytes) => bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(2)} MB` : `${(bytes / 1024).toFixed(1)} KB`;
|
|
169
|
+
const diagnosticsPanel = (diagnostics) => {
|
|
170
|
+
const stat = (label, value) => `<div class="stat"><div class="label">${label}</div><div class="value">${escapeHtml(value)}</div></div>`;
|
|
171
|
+
const stats = [
|
|
172
|
+
stat("Duration", formatDuration(diagnostics.durationMs)),
|
|
173
|
+
stat("Size", formatBytes(diagnostics.byteLength)),
|
|
174
|
+
stat("Pages", String(diagnostics.pageCount)),
|
|
175
|
+
stat("Layout passes", String(diagnostics.passes)),
|
|
176
|
+
stat("Font faces", String(diagnostics.registeredFontFaces.length))
|
|
177
|
+
].join("");
|
|
178
|
+
const fonts = diagnostics.registeredFontFaces.length > 0 ? `<div class="font-faces"><div class="label">Registered font faces</div><ul>${diagnostics.registeredFontFaces.map((face) => {
|
|
179
|
+
const attributes = [face.fontWeight, face.fontStyle].filter((value) => value !== void 0);
|
|
180
|
+
return `<li>${escapeHtml(face.family)}${attributes.length > 0 ? ` \u2014 ${escapeHtml(attributes.join(" "))}` : ""}</li>`;
|
|
181
|
+
}).join("")}</ul></div>` : "";
|
|
182
|
+
return `<div class="diagnostics">${stats}</div>${fonts}`;
|
|
183
|
+
};
|
|
184
|
+
const errorDetails = (error, fallbackKey, fallbackFile) => {
|
|
185
|
+
const code = error instanceof NuxtPdfError ? error.code : "PDF_RENDER_ERROR";
|
|
186
|
+
const name = error instanceof NuxtPdfError && error.templateKey ? error.templateKey : fallbackKey;
|
|
187
|
+
const file = error instanceof NuxtPdfError && error.templateFile ? error.templateFile : fallbackFile;
|
|
188
|
+
const message = error instanceof NuxtPdfError ? {
|
|
189
|
+
PDF_ASSET_BLOCKED: "A PDF resource was blocked by the configured policy.",
|
|
190
|
+
PDF_ASSET_INVALID: "A PDF resource failed validation.",
|
|
191
|
+
PDF_LAYOUT_ERROR: "PDF layout failed. Check the server output for details.",
|
|
192
|
+
PDF_LIMIT_EXCEEDED: "The PDF exceeded a configured render limit.",
|
|
193
|
+
PDF_RENDER_ERROR: "PDF serialization failed. Check the server output for details.",
|
|
194
|
+
PDF_TEMPLATE_INVALID: "The PDF template definition is invalid.",
|
|
195
|
+
PDF_TEMPLATE_NOT_FOUND: "The PDF template is not registered.",
|
|
196
|
+
PDF_TREE_INVALID: "The rendered PDF component tree is invalid."
|
|
197
|
+
}[error.code] : `Failed to render PDF template "${fallbackKey}".`;
|
|
198
|
+
const safeFile = file && !/^(?:[A-Z]:\\|\/)/u.test(file) ? file : void 0;
|
|
199
|
+
const fileRow = safeFile ? `<dt>File</dt><dd>${escapeHtml(safeFile)}</dd>` : "";
|
|
200
|
+
return `<div class="error"><h2>This template failed to render</h2><dl><dt>Code</dt><dd>${escapeHtml(code)}</dd><dt>Template</dt><dd>${escapeHtml(name)}</dd>` + fileRow + `</dl><p class="message">${escapeHtml(message)}</p></div>`;
|
|
201
|
+
};
|
|
202
|
+
const viewerPage = async (template, props, rootPath, scenario, hmrClientPath2 = DEFAULT_HMR_CLIENT_PATH) => {
|
|
203
|
+
const handle = template.template;
|
|
204
|
+
const base = `${rootPath}/${encodeTemplatePath(handle.key)}`;
|
|
205
|
+
const scenarioQuery = scenario === void 0 ? "" : `?scenario=${encodeURIComponent(scenario)}`;
|
|
206
|
+
const refreshHref = `${base}${scenarioQuery}${scenarioQuery ? "&" : "?"}_r=${Date.now()}`;
|
|
207
|
+
const nav = scenarioNav(template, rootPath, scenario);
|
|
208
|
+
const actions = `<span class="actions"><a href="${escapeHtml(refreshHref)}">Refresh</a><a href="${escapeHtml(rawUrl(rootPath, handle.key, scenario))}">Raw PDF</a><a href="${escapeHtml(rawUrl(rootPath, handle.key, scenario, void 0, true))}">Download</a><a href="${escapeHtml(rootPath)}">All templates</a></span>`;
|
|
209
|
+
let body;
|
|
210
|
+
let title;
|
|
211
|
+
try {
|
|
212
|
+
const result = await handle.render(props);
|
|
213
|
+
rememberSuccessfulRender(template, result, scenario);
|
|
214
|
+
title = result.metadata.title || handle.key;
|
|
215
|
+
const token = parkRender(result, handle.key, scenario);
|
|
216
|
+
body = diagnosticsPanel(result.diagnostics) + `<iframe title="${escapeHtml(title)}" src="${escapeHtml(rawUrl(rootPath, handle.key, scenario, token))}"></iframe>`;
|
|
217
|
+
} catch (error) {
|
|
218
|
+
title = handle.key;
|
|
219
|
+
body = errorDetails(error, handle.key, template.file);
|
|
220
|
+
const stale = previousSuccessfulRender(template, scenario);
|
|
221
|
+
if (stale) {
|
|
222
|
+
const token = parkRender(stale, handle.key, scenario);
|
|
223
|
+
body += `<div class="stale" role="status">Render failed. Showing the previous successful PDF; it is stale.</div><iframe title="${escapeHtml(title)} (stale)" src="${escapeHtml(rawUrl(rootPath, handle.key, scenario, token))}"></iframe>`;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return htmlResponse(
|
|
227
|
+
title,
|
|
228
|
+
`<header><h1>${escapeHtml(title)}</h1>${actions}</header>${nav}${body}<script type="module">import { createHotContext } from ${JSON.stringify(hmrClientPath2)};createHotContext('/_pdf').on('nuxt-pdf:update',()=>location.reload());<\/script>`
|
|
229
|
+
);
|
|
230
|
+
};
|
|
231
|
+
export const renderPdfPreview = async (registry, request = {}) => {
|
|
232
|
+
const rootPath = request.rootPath?.replace(/\/$/, "") || PREVIEW_ROUTE;
|
|
233
|
+
let path;
|
|
234
|
+
try {
|
|
235
|
+
path = decodeURIComponent(request.path?.replace(/^\/+|\/+$/g, "") || "");
|
|
236
|
+
} catch {
|
|
237
|
+
return errorPage(
|
|
238
|
+
"Invalid preview URL",
|
|
239
|
+
"The template path contains invalid URL encoding.",
|
|
240
|
+
rootPath,
|
|
241
|
+
400
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
if (!path) return indexPage(registry, rootPath);
|
|
245
|
+
const raw = path.endsWith(".pdf");
|
|
246
|
+
const key = raw ? path.slice(0, -4) : path;
|
|
247
|
+
const template = templateByKey(registry, key);
|
|
248
|
+
if (!template) {
|
|
249
|
+
return errorPage(
|
|
250
|
+
"Template not found",
|
|
251
|
+
`No PDF template is registered as "${key}".`,
|
|
252
|
+
rootPath,
|
|
253
|
+
404
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
if (request.scenario !== void 0 && !template.scenarioNames.includes(request.scenario)) {
|
|
257
|
+
const available = template.scenarioNames.length > 0 ? template.scenarioNames.join(", ") : "none";
|
|
258
|
+
return errorPage(
|
|
259
|
+
"Scenario not found",
|
|
260
|
+
`Unknown scenario "${request.scenario}" for "${key}". Available scenarios: ${available}.`,
|
|
261
|
+
rootPath,
|
|
262
|
+
404
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
const props = template.getPreviewProps(request.scenario);
|
|
266
|
+
if (!props) {
|
|
267
|
+
return errorPage(
|
|
268
|
+
"Preview data required",
|
|
269
|
+
`Add sampleData to definePdf() in "${key}", or choose one of its named scenarios.`,
|
|
270
|
+
rootPath,
|
|
271
|
+
422
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
if (!raw) {
|
|
275
|
+
return viewerPage(
|
|
276
|
+
template,
|
|
277
|
+
props,
|
|
278
|
+
rootPath,
|
|
279
|
+
request.scenario,
|
|
280
|
+
request.hmrClientPath
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
const disposition = request.download ? "attachment" : "inline";
|
|
284
|
+
const parked = request.render === void 0 ? void 0 : takeParkedRender(request.render, key, request.scenario);
|
|
285
|
+
if (parked) {
|
|
286
|
+
return parked.response({
|
|
287
|
+
disposition,
|
|
288
|
+
headers: {
|
|
289
|
+
"cache-control": "no-store",
|
|
290
|
+
"x-content-type-options": "nosniff"
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
const result = await template.template.render(props);
|
|
296
|
+
return result.response({
|
|
297
|
+
disposition,
|
|
298
|
+
headers: {
|
|
299
|
+
"cache-control": "no-store",
|
|
300
|
+
"x-content-type-options": "nosniff"
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
} catch (error) {
|
|
304
|
+
return errorPage(
|
|
305
|
+
"PDF render failed",
|
|
306
|
+
error instanceof NuxtPdfError ? `${error.code}: The PDF could not be rendered. Check the server output for attributed details.` : `Failed to render PDF template "${key}". Check the server output for details.`,
|
|
307
|
+
rootPath,
|
|
308
|
+
500
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
const requestRoute = (pathname) => {
|
|
313
|
+
const routeIndex = pathname.lastIndexOf(PREVIEW_ROUTE);
|
|
314
|
+
if (routeIndex < 0) return { path: "", rootPath: PREVIEW_ROUTE };
|
|
315
|
+
const rootEnd = routeIndex + PREVIEW_ROUTE.length;
|
|
316
|
+
return {
|
|
317
|
+
path: pathname.slice(rootEnd),
|
|
318
|
+
rootPath: pathname.slice(0, rootEnd)
|
|
319
|
+
};
|
|
320
|
+
};
|
|
321
|
+
export default defineEventHandler(async (event) => {
|
|
322
|
+
const url = getRequestURL(event);
|
|
323
|
+
const query = getQuery(event);
|
|
324
|
+
const route = requestRoute(url.pathname);
|
|
325
|
+
return renderPdfPreview(pdfPreview, {
|
|
326
|
+
...route,
|
|
327
|
+
scenario: typeof query.scenario === "string" ? query.scenario : void 0,
|
|
328
|
+
render: typeof query.render === "string" ? query.render : void 0,
|
|
329
|
+
download: query.download === "1",
|
|
330
|
+
hmrClientPath
|
|
331
|
+
});
|
|
332
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Component } from 'vue';
|
|
2
|
+
import { type PdfRenderResult, type PdfTemplate } from '../shared/template.js';
|
|
3
|
+
import { type PdfImageAssetMap } from './assets/resolve-asset.js';
|
|
4
|
+
import type { RemoteAssetPolicy } from './assets/remote.js';
|
|
5
|
+
import { type PdfRenderLimits } from './render-limits.js';
|
|
6
|
+
import type { BundledPdfFontDescriptor } from '../fonts.js';
|
|
7
|
+
export type { PdfRenderDiagnostics } from '../shared/template.js';
|
|
8
|
+
export interface PdfTemplateRuntimeOptions {
|
|
9
|
+
assets?: PdfImageAssetMap;
|
|
10
|
+
file?: string;
|
|
11
|
+
fonts?: readonly BundledPdfFontDescriptor[];
|
|
12
|
+
remote?: RemoteAssetPolicy;
|
|
13
|
+
/**
|
|
14
|
+
* Render limits (time budget + page cap). Absent means the generous built-in
|
|
15
|
+
* defaults apply, so every render is bounded even with no `pdf.limits` config.
|
|
16
|
+
*/
|
|
17
|
+
limits?: PdfRenderLimits;
|
|
18
|
+
}
|
|
19
|
+
type PdfTemplateIdentity = Pick<PdfTemplate<object>, 'key' | 'render'>;
|
|
20
|
+
export type PdfRegistryEntries = Readonly<Record<string, PdfTemplateIdentity>>;
|
|
21
|
+
export interface PdfRegistry<Entries extends PdfRegistryEntries = PdfRegistryEntries> {
|
|
22
|
+
readonly pdf: Readonly<Entries>;
|
|
23
|
+
readonly pdfTemplateKeys: readonly string[];
|
|
24
|
+
getPdfTemplate(key: string): Entries[keyof Entries] | undefined;
|
|
25
|
+
renderPdf(key: string, props: object): Promise<PdfRenderResult>;
|
|
26
|
+
}
|
|
27
|
+
export declare const createPdfTemplate: <Props extends object>(key: string, component: Component, options?: PdfTemplateRuntimeOptions) => PdfTemplate<Props>;
|
|
28
|
+
/**
|
|
29
|
+
* Internal development-only information for the preview UI. It wraps the
|
|
30
|
+
* production handle instead of extending it, so preview fixtures and source
|
|
31
|
+
* paths cannot accidentally become part of the public template contract.
|
|
32
|
+
*/
|
|
33
|
+
export interface PdfPreviewEntry<Props extends object = Record<string, unknown>> {
|
|
34
|
+
readonly template: PdfTemplate<Props>;
|
|
35
|
+
readonly file?: string;
|
|
36
|
+
readonly scenarioNames: readonly string[];
|
|
37
|
+
getPreviewProps(scenario?: string): Props | undefined;
|
|
38
|
+
}
|
|
39
|
+
export type PdfPreviewEntryOptions = Pick<PdfTemplateRuntimeOptions, 'file'>;
|
|
40
|
+
export declare const createPdfPreviewEntry: <Props extends object>(template: PdfTemplate<Props>, component: Component, options?: PdfPreviewEntryOptions) => PdfPreviewEntry<Props>;
|
|
41
|
+
export declare const createPdfRegistry: <const Entries extends Record<string, PdfTemplateIdentity>>(entries: Entries) => PdfRegistry<Entries>;
|