@lupinum/nuxt-pdf 0.3.1 → 0.4.0-beta.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.
@@ -72,6 +72,12 @@ const formatFromExtension = (key) => {
72
72
  if (extension === ".jpg" || extension === ".jpeg") return "jpg";
73
73
  return invalid("PDF images must be PNG or JPEG files.");
74
74
  };
75
+ export const pdfImageFormatFromKey = (key) => {
76
+ const extension = extname(key).toLowerCase();
77
+ if (extension === ".png") return "png";
78
+ if (extension === ".jpg" || extension === ".jpeg") return "jpg";
79
+ throw new TypeError(`PDF images must be PNG or JPEG files: "${key}".`);
80
+ };
75
81
  const claimedFormat = (value) => {
76
82
  if (value === void 0) return void 0;
77
83
  if (typeof value !== "string") {
@@ -270,7 +276,30 @@ export const loadPdfImageAsset = async (relativePath, options) => {
270
276
  "The local PDF image was not found in a configured asset root."
271
277
  );
272
278
  };
273
- const resolveLocalImage = (source, assets, maxBytes, maxPixels, declaredFormat) => {
279
+ const RESOLVED_IMAGE_CACHE_BYTES = 32 * 1024 * 1024;
280
+ const resolvedImageCache = /* @__PURE__ */ new Map();
281
+ let resolvedImageCacheBytes = 0;
282
+ const embeddedImageCache = /* @__PURE__ */ new WeakMap();
283
+ const rememberResolvedImage = (cacheKey, image) => {
284
+ if (resolvedImageCache.has(cacheKey)) return;
285
+ resolvedImageCache.set(cacheKey, image);
286
+ resolvedImageCacheBytes += image.data.byteLength;
287
+ while (resolvedImageCacheBytes > RESOLVED_IMAGE_CACHE_BYTES && resolvedImageCache.size > 1) {
288
+ const oldest = resolvedImageCache.keys().next().value;
289
+ if (oldest === void 0) break;
290
+ resolvedImageCacheBytes -= resolvedImageCache.get(oldest).data.byteLength;
291
+ resolvedImageCache.delete(oldest);
292
+ }
293
+ };
294
+ const cachedResolvedImage = (cacheKey) => {
295
+ const cached = resolvedImageCache.get(cacheKey);
296
+ if (!cached) return void 0;
297
+ return {
298
+ ...cached,
299
+ data: Buffer.from(cached.data)
300
+ };
301
+ };
302
+ const resolveLocalImage = async (source, assets, maxBytes, maxPixels, declaredFormat) => {
274
303
  const key = canonicalAssetKey(source);
275
304
  const pathFormat = formatFromExtension(key);
276
305
  const sourceFormat = claimedFormat(declaredFormat);
@@ -279,20 +308,66 @@ const resolveLocalImage = (source, assets, maxBytes, maxPixels, declaredFormat)
279
308
  }
280
309
  if (!hasOwn(assets, key)) {
281
310
  return invalid(
282
- "The local PDF image was not included in the generated asset map."
311
+ `The local PDF image "${key}" was not found under pdfs/assets. Add the file or fix the path.`
283
312
  );
284
313
  }
285
- const asset = assets[key];
286
- if (!asset || typeof asset !== "object") {
314
+ const entry = assets[key];
315
+ if (!entry || typeof entry !== "object") {
287
316
  return invalid("The generated PDF image asset is invalid.");
288
317
  }
289
- const assetFormat = claimedFormat(asset.format);
318
+ const assetFormat = claimedFormat(entry.format);
290
319
  if (!assetFormat || assetFormat !== pathFormat) {
291
320
  return invalid(
292
321
  "The generated PDF image format does not match its asset key."
293
322
  );
294
323
  }
295
- return validateImageBytes(asset.data, maxBytes, assetFormat, maxPixels);
324
+ if ("dataB64" in entry) {
325
+ const limitsKey = `${maxBytes}\0${maxPixels}`;
326
+ let byLimits = embeddedImageCache.get(entry);
327
+ if (!byLimits) {
328
+ byLimits = /* @__PURE__ */ new Map();
329
+ embeddedImageCache.set(entry, byLimits);
330
+ }
331
+ const cached2 = byLimits.get(limitsKey);
332
+ if (cached2) return { ...cached2, data: Buffer.from(cached2.data) };
333
+ const resolved2 = validateImageBytes(
334
+ Buffer.from(entry.dataB64, "base64"),
335
+ maxBytes,
336
+ assetFormat,
337
+ maxPixels
338
+ );
339
+ byLimits.set(limitsKey, resolved2);
340
+ return { ...resolved2, data: Buffer.from(resolved2.data) };
341
+ }
342
+ let fileStat;
343
+ try {
344
+ fileStat = await stat(resolve(entry.root, ...key.split("/")));
345
+ } catch (error) {
346
+ if (isMissingFileError(error)) {
347
+ return invalid(`The local PDF image "${key}" was not found under pdfs/assets.`);
348
+ }
349
+ return invalid("The local PDF image cannot be inspected.", error);
350
+ }
351
+ if (!fileStat.isFile()) {
352
+ return invalid("The local PDF image is not a regular file.");
353
+ }
354
+ const cacheKey = `disk\0${key}\0${fileStat.mtimeMs}\0${fileStat.size}\0${maxBytes}\0${maxPixels}`;
355
+ const cached = cachedResolvedImage(cacheKey);
356
+ if (cached) return cached;
357
+ const loaded = await loadPdfImageAsset(key, {
358
+ roots: [entry.root],
359
+ maxBytes,
360
+ maxPixels
361
+ });
362
+ const resolved = Object.freeze({
363
+ data: loaded.data,
364
+ format: assetFormat,
365
+ height: loaded.height,
366
+ pixels: loaded.pixels,
367
+ width: loaded.width
368
+ });
369
+ rememberResolvedImage(cacheKey, resolved);
370
+ return { ...resolved, data: Buffer.from(resolved.data) };
296
371
  };
297
372
  const isRemoteCandidate = (source) => /^https?:/i.test(source);
298
373
  const imageResolutionCacheKey = (source) => {
@@ -443,12 +518,10 @@ export const resolvePdfImageAssets = async (document, options) => {
443
518
  if (hasOwn(node.props, "srcSet") && node.props.srcSet !== void 0) {
444
519
  return blocked("PDF image srcSet sources are blocked.");
445
520
  }
446
- const hasSrc = hasOwn(node.props, "src") && node.props.src !== void 0;
447
- const hasSource = hasOwn(node.props, "source") && node.props.source !== void 0;
448
- if (hasSrc === hasSource) {
449
- return invalid("Each PDF image must have exactly one src or source prop.");
521
+ if (!hasOwn(node.props, "src") || node.props.src === void 0) {
522
+ return invalid("Each PDF image must have a src prop.");
450
523
  }
451
- targets.push({ node, prop: hasSrc ? "src" : "source" });
524
+ targets.push({ node });
452
525
  }
453
526
  if (targets.length > limits.maxImages) {
454
527
  return imageLimitExceeded(
@@ -459,7 +532,7 @@ export const resolvePdfImageAssets = async (document, options) => {
459
532
  try {
460
533
  resolved = await Promise.all(targets.map(
461
534
  (target) => resolveImageBuffer(
462
- target.node.props[target.prop],
535
+ target.node.props.src,
463
536
  options.assets,
464
537
  limits,
465
538
  options.remote,
@@ -475,7 +548,7 @@ export const resolvePdfImageAssets = async (document, options) => {
475
548
  }
476
549
  targets.forEach((target, index) => {
477
550
  const data = resolved[index];
478
- target.node.props[target.prop] = data;
551
+ target.node.props.src = data;
479
552
  state.resolved.set(data, Promise.resolve(data));
480
553
  });
481
554
  return document;
@@ -5,4 +5,5 @@ export type PdfFontStore = {
5
5
  readonly [pdfFontStoreBrand]: true;
6
6
  };
7
7
  export declare const createPdfFontStore: (fonts?: readonly BundledPdfFontDescriptor[]) => PdfFontStore;
8
+ export declare const getSharedPdfFontStore: (fonts?: readonly BundledPdfFontDescriptor[]) => PdfFontStore;
8
9
  export {};
@@ -1,5 +1,5 @@
1
1
  import FontStore from "@react-pdf/font";
2
- const isBundledFontSource = (src) => /^data:font\/(?:otf|ttf);base64,[A-Za-z0-9+/]+={0,2}$/.test(src);
2
+ const isBundledFontSource = (src) => /^data:font\/(?:otf|ttf|woff2);base64,[A-Za-z0-9+/]+={0,2}$/.test(src);
3
3
  export const createPdfFontStore = (fonts = []) => {
4
4
  const fontStore = new FontStore();
5
5
  for (const font of fonts) {
@@ -17,3 +17,16 @@ export const createPdfFontStore = (fonts = []) => {
17
17
  }
18
18
  return fontStore;
19
19
  };
20
+ const sameFontList = (left, right) => left.length === right.length && left.every((font, index) => {
21
+ const other = right[index];
22
+ return font.family === other.family && font.src === other.src && font.fontStyle === other.fontStyle && font.fontWeight === other.fontWeight;
23
+ });
24
+ let sharedFontStore;
25
+ export const getSharedPdfFontStore = (fonts = []) => {
26
+ if (sharedFontStore && sameFontList(sharedFontStore.fonts, fonts)) {
27
+ return sharedFontStore.store;
28
+ }
29
+ const store = createPdfFontStore(fonts);
30
+ sharedFontStore = { fonts, store };
31
+ return store;
32
+ };
@@ -1,5 +1,6 @@
1
1
  import { type DocumentNode, type SafeDocumentNode } from '@react-pdf/layout';
2
2
  import { type PdfFontStore } from './fonts.js';
3
+ import type { PdfLayoutWarning } from '../../shared/template.js';
3
4
  import { type RenderLimits } from '../render-limits.js';
4
5
  export interface PdfEngineOptions {
5
6
  compress?: boolean;
@@ -16,6 +17,12 @@ type DocumentMetadata = DocumentNode['props'];
16
17
  export declare const layoutPdfTree: (document: DocumentNode, fontStore: PdfFontStore, limits?: RenderLimits) => Promise<SafeDocumentNode>;
17
18
  /** The number of laid-out pages in a serialized document. */
18
19
  export declare const countPages: (layout: SafeDocumentNode) => number;
20
+ /**
21
+ * Find blocks that cannot fit on any ordinary page because pagination is not
22
+ * allowed to split them. No text, ids, or other authored content enters the
23
+ * public diagnostics object.
24
+ */
25
+ export declare const collectLayoutWarnings: (layout: SafeDocumentNode) => PdfLayoutWarning[];
19
26
  /**
20
27
  * Map every `id` (named destination) to the **first** 1-based page it appears
21
28
  * on. `id` is the key `render/src/operations/setDestination.ts` emits and that a
@@ -12,6 +12,7 @@ import {
12
12
  import {
13
13
  PDF_PRIMITIVES
14
14
  } from "../../authoring.js";
15
+ import { PDF_PRIMITIVE_NAMES } from "../../renderer/index.js";
15
16
  import {
16
17
  enforceMaxPages
17
18
  } from "../render-limits.js";
@@ -117,6 +118,52 @@ const visitPageNodes = (page, visit) => {
117
118
  };
118
119
  const documentPages = (layout) => layout.children ?? [];
119
120
  export const countPages = (layout) => documentPages(layout).length;
121
+ const NON_WRAPPING_NODE_TYPES = /* @__PURE__ */ new Set([
122
+ PDF_PRIMITIVES.Image,
123
+ PDF_PRIMITIVES.Note,
124
+ PDF_PRIMITIVES.Svg
125
+ ]);
126
+ const WARNING_NODE_TYPES = /* @__PURE__ */ new Set([
127
+ PDF_PRIMITIVES.Image,
128
+ PDF_PRIMITIVES.Link,
129
+ PDF_PRIMITIVES.Note,
130
+ PDF_PRIMITIVES.Svg,
131
+ PDF_PRIMITIVES.Text,
132
+ PDF_PRIMITIVES.View
133
+ ]);
134
+ const finiteNumber = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
135
+ const roundedPoints = (value) => Math.round(value * 100) / 100;
136
+ const pageContentHeight = (page) => {
137
+ const style = page.style;
138
+ const pageHeight = finiteNumber(style?.height) ?? finiteNumber(page.box.height);
139
+ if (pageHeight === void 0) return void 0;
140
+ const paddingTop = finiteNumber(page.box.paddingTop) ?? finiteNumber(style?.paddingTop) ?? 0;
141
+ const paddingBottom = finiteNumber(page.box.paddingBottom) ?? finiteNumber(style?.paddingBottom) ?? 0;
142
+ return pageHeight - paddingTop - paddingBottom;
143
+ };
144
+ const cannotWrap = (node) => NON_WRAPPING_NODE_TYPES.has(node.type) || node.props.wrap === false;
145
+ export const collectLayoutWarnings = (layout) => {
146
+ const warnings = [];
147
+ documentPages(layout).forEach((pageNode, pageIndex) => {
148
+ if (!("props" in pageNode)) return;
149
+ const page = pageNode;
150
+ const availableHeight = pageContentHeight(page);
151
+ if (availableHeight === void 0 || availableHeight <= 0) return;
152
+ visitPageNodes(page, (node) => {
153
+ if (node === page || node.props.fixed === true || !WARNING_NODE_TYPES.has(node.type) || !cannotWrap(node)) return;
154
+ const nodeHeight = finiteNumber(node.box.height);
155
+ if (nodeHeight === void 0 || nodeHeight <= availableHeight + 1e-3) return;
156
+ warnings.push({
157
+ code: "PDF_UNBREAKABLE_NODE_OVERFLOW",
158
+ pageNumber: pageIndex + 1,
159
+ nodeType: PDF_PRIMITIVE_NAMES[node.type],
160
+ nodeHeight: roundedPoints(nodeHeight),
161
+ availableHeight: roundedPoints(availableHeight)
162
+ });
163
+ });
164
+ });
165
+ return warnings;
166
+ };
120
167
  const nodeId = (node) => {
121
168
  const id = node.props?.id;
122
169
  return typeof id === "string" && id.length > 0 ? id : void 0;
@@ -2,4 +2,4 @@ export { createPdfPreviewEntry, createPdfRegistry, createPdfTemplate, } from './
2
2
  export { NuxtPdfError, PDF_ERROR_CODES, } from '../shared/errors.js';
3
3
  export type { PdfErrorCode } from '../shared/errors.js';
4
4
  export type { PdfPreviewEntry, PdfPreviewEntryOptions, PdfRegistry, PdfRegistryEntries, } from './registry.js';
5
- export type { PdfRenderDiagnostics, PdfRenderResult, PdfComponentProps, PdfTemplate, } from '../shared/template.js';
5
+ export type { PdfLayoutWarning, PdfRenderDiagnostics, PdfRenderResult, PdfComponentProps, PdfTemplate, } from '../shared/template.js';
@@ -99,6 +99,10 @@ const htmlResponse = (title, content, status = 200) => new Response(`<!doctype h
99
99
  .font-faces .label { color: #8b948b; font-size: 0.78rem; letter-spacing: 0.04em; text-transform: uppercase; margin-bottom: 8px; }
100
100
  .font-faces ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
101
101
  .font-faces li { color: #d7ddd7; font-size: 0.85rem; line-height: 1.5; }
102
+ .layout-warnings { border: 1px solid #765b23; background: #211b0f; border-left: 3px solid #e2b653; border-radius: 10px; padding: 12px 16px; margin-bottom: 16px; }
103
+ .layout-warnings .label { color: #e8c977; font-size: 0.78rem; letter-spacing: 0.04em; text-transform: uppercase; margin-bottom: 8px; }
104
+ .layout-warnings ul { margin: 0; padding-left: 20px; display: grid; gap: 6px; }
105
+ .layout-warnings li { color: #f0dfb2; font-size: 0.85rem; line-height: 1.5; }
102
106
  .error { border: 1px solid #6b2f2b; background: #1d1210; border-left: 3px solid #ef786f; border-radius: 10px; padding: 18px 20px; }
103
107
  .error h2 { margin: 0 0 12px; font-size: 1.1rem; color: #f3d7d3; }
104
108
  .error dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0 0 12px; }
@@ -127,7 +131,7 @@ const indexPage = (registry, rootPath) => {
127
131
  if (templates.length === 0) {
128
132
  return htmlResponse(
129
133
  "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>"
134
+ "<header><h1>PDF templates</h1></header><p>No templates found. Add <code>pdfs/invoice.vue</code>; Nuxt PDF restarts and registers it automatically.</p>"
131
135
  );
132
136
  }
133
137
  const cards = templates.map((template) => {
@@ -173,13 +177,17 @@ const diagnosticsPanel = (diagnostics) => {
173
177
  stat("Size", formatBytes(diagnostics.byteLength)),
174
178
  stat("Pages", String(diagnostics.pageCount)),
175
179
  stat("Layout passes", String(diagnostics.passes)),
180
+ stat("Layout warnings", String(diagnostics.layoutWarnings.length)),
176
181
  stat("Font faces", String(diagnostics.registeredFontFaces.length))
177
182
  ].join("");
183
+ const warnings = diagnostics.layoutWarnings.length > 0 ? `<div class="layout-warnings"><div class="label">Layout warnings</div><ul>${diagnostics.layoutWarnings.map(
184
+ (warning) => `<li><strong>${escapeHtml(warning.nodeType)}</strong> on page ${warning.pageNumber} is ${warning.nodeHeight} pt tall, but only ${warning.availableHeight} pt is available. Allow it to wrap or make it smaller.</li>`
185
+ ).join("")}</ul></div>` : "";
178
186
  const fonts = diagnostics.registeredFontFaces.length > 0 ? `<div class="font-faces"><div class="label">Registered font faces</div><ul>${diagnostics.registeredFontFaces.map((face) => {
179
187
  const attributes = [face.fontWeight, face.fontStyle].filter((value) => value !== void 0);
180
188
  return `<li>${escapeHtml(face.family)}${attributes.length > 0 ? ` \u2014 ${escapeHtml(attributes.join(" "))}` : ""}</li>`;
181
189
  }).join("")}</ul></div>` : "";
182
- return `<div class="diagnostics">${stats}</div>${fonts}`;
190
+ return `<div class="diagnostics">${stats}</div>${warnings}${fonts}`;
183
191
  };
184
192
  const errorDetails = (error, fallbackKey, fallbackFile) => {
185
193
  const code = error instanceof NuxtPdfError ? error.code : "PDF_RENDER_ERROR";
@@ -225,7 +233,7 @@ const viewerPage = async (template, props, rootPath, scenario, hmrClientPath2 =
225
233
  }
226
234
  return htmlResponse(
227
235
  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>`
236
+ `<header><h1>${escapeHtml(title)}</h1>${actions}</header>${nav}${body}<script type="module">import { createHotContext } from ${JSON.stringify(hmrClientPath2)};createHotContext('/_pdf').on('nuxt-pdf:update',()=>{const frame=document.querySelector('iframe');if(frame&&frame.contentWindow){frame.contentWindow.location.reload();}else{location.reload();}});<\/script>`
229
237
  );
230
238
  };
231
239
  export const renderPdfPreview = async (registry, request = {}) => {
@@ -4,7 +4,7 @@ import { type PdfImageAssetMap } from './assets/resolve-asset.js';
4
4
  import type { RemoteAssetPolicy } from './assets/remote.js';
5
5
  import { type PdfRenderLimits } from './render-limits.js';
6
6
  import type { BundledPdfFontDescriptor } from '../fonts.js';
7
- export type { PdfRenderDiagnostics } from '../shared/template.js';
7
+ export type { PdfLayoutWarning, PdfRenderDiagnostics, } from '../shared/template.js';
8
8
  export interface PdfTemplateRuntimeOptions {
9
9
  assets?: PdfImageAssetMap;
10
10
  file?: string;
@@ -10,14 +10,18 @@ import {
10
10
  createPdfImageResolutionState,
11
11
  resolvePdfImageAssets
12
12
  } from "./assets/resolve-asset.js";
13
- import { countPages, renderDocument } from "./engine/render-document.js";
13
+ import {
14
+ collectLayoutWarnings,
15
+ countPages,
16
+ renderDocument
17
+ } from "./engine/render-document.js";
14
18
  import { renderDocumentMultiPass } from "./engine/layout-passes.js";
15
19
  import {
16
20
  createRenderLimits,
17
21
  enforceTreeLimits,
18
22
  resolvePdfRenderLimits
19
23
  } from "./render-limits.js";
20
- import { createPdfFontStore } from "./engine/fonts.js";
24
+ import { getSharedPdfFontStore } from "./engine/fonts.js";
21
25
  import { createPdfRenderResult } from "./result.js";
22
26
  const EMPTY_SCENARIOS = Object.freeze({});
23
27
  const EMPTY_ASSETS = Object.freeze({});
@@ -123,7 +127,7 @@ const renderTemplate = async (component, props, metadata, options, maxPasses, li
123
127
  component,
124
128
  props
125
129
  );
126
- const fontStore = createPdfFontStore(options.fonts);
130
+ const fontStore = getSharedPdfFontStore(options.fonts);
127
131
  if (mounted.usesPageNumbers) {
128
132
  const live = mounted;
129
133
  const result2 = await renderDocumentMultiPass(
@@ -140,6 +144,7 @@ const renderTemplate = async (component, props, metadata, options, maxPasses, li
140
144
  );
141
145
  return {
142
146
  bytes: result2.bytes,
147
+ layoutWarnings: collectLayoutWarnings(result2.layout),
143
148
  metadata: completedMetadata(live.document, metadata),
144
149
  passes: result2.passes,
145
150
  pageCount: countPages(result2.layout)
@@ -150,6 +155,7 @@ const renderTemplate = async (component, props, metadata, options, maxPasses, li
150
155
  const result = await renderDocument(document, { fontStore, limits });
151
156
  return {
152
157
  bytes: result.bytes,
158
+ layoutWarnings: collectLayoutWarnings(result.layout),
153
159
  metadata: completedMetadata(mounted.document, metadata),
154
160
  passes: 1,
155
161
  pageCount: countPages(result.layout)
@@ -176,6 +182,7 @@ export const createPdfTemplate = (key, component, options = {}) => {
176
182
  const definitionMetadata = resolveMetadata(ref, definition, props);
177
183
  const {
178
184
  bytes,
185
+ layoutWarnings,
179
186
  metadata,
180
187
  passes,
181
188
  pageCount
@@ -189,6 +196,7 @@ export const createPdfTemplate = (key, component, options = {}) => {
189
196
  );
190
197
  const result = createPdfRenderResult(bytes, metadata, {
191
198
  durationMs: performance.now() - start,
199
+ layoutWarnings,
192
200
  pageCount,
193
201
  passes,
194
202
  registeredFontFaces: (options.fonts ?? []).map((font) => ({
@@ -43,6 +43,7 @@ export const createPdfRenderResult = (source, metadata, diagnostics) => {
43
43
  const completedDiagnostics = Object.freeze({
44
44
  byteLength: bytes.byteLength,
45
45
  durationMs: diagnostics.durationMs,
46
+ layoutWarnings: Object.freeze(diagnostics.layoutWarnings.map((warning) => Object.freeze({ ...warning }))),
46
47
  pageCount: diagnostics.pageCount,
47
48
  passes: diagnostics.passes,
48
49
  registeredFontFaces: Object.freeze(diagnostics.registeredFontFaces.map((face) => Object.freeze({ ...face })))
@@ -1,4 +1,4 @@
1
1
  export { NuxtPdfError, PDF_ERROR_CODES, } from './errors.js';
2
2
  export type { NuxtPdfErrorOptions, PdfErrorCode, } from './errors.js';
3
3
  export { PDF_DEFINITION_PROPERTY, } from './template.js';
4
- export type { PdfComponent, PdfComponentProps, PdfDefinition, PdfDisposition, PdfMetadataValue, PdfRenderDiagnostics, PdfRenderResult, PdfResponseInit, PdfTemplate, ResolvedPdfMetadata, } from './template.js';
4
+ export type { PdfComponent, PdfComponentProps, PdfDefinition, PdfDisposition, PdfLayoutWarning, PdfMetadataValue, PdfRenderDiagnostics, PdfRenderResult, PdfResponseInit, PdfTemplate, ResolvedPdfMetadata, } from './template.js';
@@ -41,8 +41,16 @@ export interface PdfRenderDiagnostics {
41
41
  readonly byteLength: number;
42
42
  readonly pageCount: number;
43
43
  readonly passes: number;
44
+ readonly layoutWarnings: readonly PdfLayoutWarning[];
44
45
  readonly registeredFontFaces: readonly PdfRegisteredFontFace[];
45
46
  }
47
+ export interface PdfLayoutWarning {
48
+ readonly code: 'PDF_UNBREAKABLE_NODE_OVERFLOW';
49
+ readonly pageNumber: number;
50
+ readonly nodeType: 'PdfImage' | 'PdfLink' | 'PdfNote' | 'PdfSvg' | 'PdfText' | 'PdfView';
51
+ readonly nodeHeight: number;
52
+ readonly availableHeight: number;
53
+ }
46
54
  export interface PdfRegisteredFontFace {
47
55
  readonly family: string;
48
56
  readonly fontStyle?: string;
@@ -22,7 +22,7 @@ const classifyPdfWatchEvent = (event, absolutePath, layers, isIgnored) => {
22
22
  if (PDF_STRUCTURE_EVENTS.has(event)) return "restart";
23
23
  if (event !== "change") return "ignore";
24
24
  const [rootDirectory] = pathWithinPdfs.split(/[\\/]/);
25
- return rootDirectory === "assets" || rootDirectory === "fonts" ? "restart" : "refresh";
25
+ return rootDirectory === "fonts" ? "restart" : "refresh";
26
26
  }
27
27
  return "ignore";
28
28
  };
@@ -185,6 +185,7 @@ const discoverPdfImageFiles = async (layers, isIgnored) => {
185
185
  };
186
186
 
187
187
  const DEFAULT_MAX_PDF_FONT_BYTES = 5 * 1024 * 1024;
188
+ const DEFAULT_MAX_PDF_DECOMPRESSED_FONT_BYTES = 20 * 1024 * 1024;
188
189
  const FONT_WEIGHT_VALUES = {
189
190
  black: 900,
190
191
  bold: 700,
@@ -347,13 +348,53 @@ const validateDeclaration = (declaration) => {
347
348
  };
348
349
  };
349
350
  const detectFontFormat = (bytes) => {
350
- if (bytes.byteLength < 12) return void 0;
351
+ if (bytes.byteLength < 4) return void 0;
351
352
  const isTrueType = bytes[0] === 0 && bytes[1] === 1 && bytes[2] === 0 && bytes[3] === 0;
352
353
  const isOpenType = bytes[0] === 79 && bytes[1] === 84 && bytes[2] === 84 && bytes[3] === 79;
354
+ const isWoff2 = bytes[0] === 119 && bytes[1] === 79 && bytes[2] === 70 && bytes[3] === 50;
353
355
  if (isTrueType) return "ttf";
354
356
  if (isOpenType) return "otf";
357
+ if (isWoff2) return "woff2";
355
358
  return void 0;
356
359
  };
360
+ const validateWoff2Structure = (source, bytes) => {
361
+ if (bytes.byteLength < 48) {
362
+ throw fontError(source, "the WOFF2 header is corrupt or truncated.");
363
+ }
364
+ const data = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
365
+ const flavor = data.readUInt32BE(4);
366
+ const declaredLength = data.readUInt32BE(8);
367
+ const tableCount = data.readUInt16BE(12);
368
+ const reserved = data.readUInt16BE(14);
369
+ const decompressedSize = data.readUInt32BE(16);
370
+ const compressedSize = data.readUInt32BE(20);
371
+ const isTrueType = flavor === 65536;
372
+ const isOpenType = flavor === 1330926671;
373
+ if (!isTrueType && !isOpenType) {
374
+ throw fontError(source, "the WOFF2 font must wrap a TTF or OTF font.");
375
+ }
376
+ if (declaredLength !== bytes.byteLength || tableCount < 1 || tableCount > 4096 || reserved !== 0 || compressedSize < 1 || compressedSize > bytes.byteLength - 48) {
377
+ throw fontError(source, "the WOFF2 header is corrupt or truncated.");
378
+ }
379
+ if (decompressedSize < 12 || decompressedSize > DEFAULT_MAX_PDF_DECOMPRESSED_FONT_BYTES) {
380
+ throw fontError(
381
+ source,
382
+ `the decompressed WOFF2 font exceeds the ${DEFAULT_MAX_PDF_DECOMPRESSED_FONT_BYTES}-byte limit.`
383
+ );
384
+ }
385
+ const metadataOffset = data.readUInt32BE(28);
386
+ const metadataLength = data.readUInt32BE(32);
387
+ const metadataOriginalLength = data.readUInt32BE(36);
388
+ const privateOffset = data.readUInt32BE(40);
389
+ const privateLength = data.readUInt32BE(44);
390
+ const metadataAbsent = metadataOffset === 0 && metadataLength === 0 && metadataOriginalLength === 0;
391
+ const metadataPresent = metadataOffset >= 48 && metadataLength > 0 && metadataOriginalLength > 0 && metadataOffset + metadataLength <= bytes.byteLength;
392
+ const privateAbsent = privateOffset === 0 && privateLength === 0;
393
+ const privatePresent = privateOffset >= 48 && privateLength > 0 && privateOffset + privateLength <= bytes.byteLength;
394
+ if (!metadataAbsent && !metadataPresent || !privateAbsent && !privatePresent) {
395
+ throw fontError(source, "the WOFF2 optional data blocks are corrupt.");
396
+ }
397
+ };
357
398
  const validateSfntStructure = (source, bytes, format) => {
358
399
  const data = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
359
400
  const tableCount = data.readUInt16BE(4);
@@ -380,20 +421,21 @@ const validateSfntStructure = (source, bytes, format) => {
380
421
  };
381
422
  const fontFormat = (source, bytes) => {
382
423
  const extension = extname(source).toLowerCase();
383
- if (extension !== ".otf" && extension !== ".ttf") {
384
- throw fontError(source, "only .ttf and .otf files are supported.");
424
+ if (extension !== ".otf" && extension !== ".ttf" && extension !== ".woff2") {
425
+ throw fontError(source, "only .ttf, .otf, and .woff2 files are supported.");
385
426
  }
386
- if (bytes.byteLength < 12) {
387
- throw fontError(source, "the file is too small to be a TTF or OTF font.");
427
+ if (bytes.byteLength < 4) {
428
+ throw fontError(source, "the file is too small to be a supported font.");
388
429
  }
389
430
  const format = detectFontFormat(bytes);
390
431
  if (!format) {
391
- throw fontError(source, "the file has an unsupported TTF or OTF signature.");
432
+ throw fontError(source, "the file has an unsupported font signature.");
392
433
  }
393
434
  if (`.${format}` !== extension) {
394
- throw fontError(source, "the file extension does not match its TTF or OTF signature.");
435
+ throw fontError(source, "the file extension does not match its font signature.");
395
436
  }
396
- validateSfntStructure(source, bytes, format);
437
+ if (format === "woff2") validateWoff2Structure(source, bytes);
438
+ else validateSfntStructure(source, bytes, format);
397
439
  return format;
398
440
  };
399
441
  const readFont = async (source, filePath, maxBytes) => {
package/dist/test.mjs CHANGED
@@ -2,7 +2,7 @@ import { createRequire } from 'node:module';
2
2
  import { defineComponent, h } from 'vue';
3
3
  import { normalizeRemoteAssetPolicy } from '../dist/runtime/server/assets/remote.js';
4
4
  import { createPdfTemplate } from '../dist/runtime/server/registry.js';
5
- import { normalizePdfLimits, resolvePdfRenderLimits } from '../dist/runtime/server/render-limits.js';
5
+ import { normalizePdfLimits } from '../dist/runtime/server/render-limits.js';
6
6
  import { PDF_DEFINITION_PROPERTY } from '../dist/runtime/shared/template.js';
7
7
  import { Buffer } from 'node:buffer';
8
8
  import { existsSync } from 'node:fs';
@@ -10,8 +10,8 @@ import { mkdir, writeFile, rm, readFile, readdir } from 'node:fs/promises';
10
10
  import { resolve, dirname, join, relative, basename } from 'node:path';
11
11
  import { pathToFileURL, fileURLToPath } from 'node:url';
12
12
  import { build } from 'esbuild';
13
- import { t as templateKeyFromRelativePath, b as discoverPdfImageFiles, c as bundlePdfFonts, g as compilePdfSfc } from './shared/nuxt-pdf.D2ZziYn4.mjs';
14
- import { loadPdfImageAsset } from '../dist/runtime/server/assets/resolve-asset.js';
13
+ import { t as templateKeyFromRelativePath, b as discoverPdfImageFiles, c as bundlePdfFonts, g as compilePdfSfc } from './shared/nuxt-pdf.D0zmsct0.mjs';
14
+ import { pdfImageFormatFromKey } from '../dist/runtime/server/assets/resolve-asset.js';
15
15
  import '@vue/compiler-sfc';
16
16
  import '@jridgewell/remapping';
17
17
  import 'unimport';
@@ -571,18 +571,15 @@ async function renderPdfSfc(filename, props, options = {}) {
571
571
  throw new Error(`PDF SFC ${JSON.stringify(entry)} must be a template directly inside pdfs/ or one of its feature directories.`);
572
572
  }
573
573
  const normalizedLimits = normalizePdfLimits(options.limits);
574
- const limits = resolvePdfRenderLimits(normalizedLimits);
575
574
  const [component, imageFiles, fonts] = await Promise.all([
576
575
  loadPdfSfc(entry),
577
576
  discoverPdfImageFiles([{ name: "test", rootDir }]),
578
577
  bundlePdfFonts(options.fonts ?? [], { fontRoots: [join(rootDir, "pdfs", "fonts")] })
579
578
  ]);
580
- const loadedAssets = await Promise.all(imageFiles.map((image) => loadPdfImageAsset(image.key, {
581
- roots: [image.rootDir],
582
- maxBytes: limits.maxImageBytes,
583
- maxPixels: limits.maxImagePixels
584
- })));
585
- const assets = Object.fromEntries(loadedAssets.map((asset) => [asset.key, asset]));
579
+ const assets = Object.fromEntries(imageFiles.map((image) => [
580
+ image.key,
581
+ Object.freeze({ format: pdfImageFormatFromKey(image.key), root: image.rootDir })
582
+ ]));
586
583
  return renderPreparedPdfTemplate(component, props, {
587
584
  assets,
588
585
  file: `pdfs/${relativePath}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lupinum/nuxt-pdf",
3
- "version": "0.3.1",
3
+ "version": "0.4.0-beta.2",
4
4
  "description": "Author and render PDFs with Vue components in Nuxt",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -56,9 +56,10 @@
56
56
  "build": "nuxt prepare && nuxt-module-build build",
57
57
  "api:write": "node scripts/check-api-report.mjs --write",
58
58
  "changelog": "changelogen --no-output",
59
- "docs:build": "pnpm build && pnpm --dir docs build",
59
+ "docs:build": "pnpm docs:theme && pnpm build && pnpm --dir docs build",
60
60
  "docs:build:vercel": "node scripts/build-docs-vercel.mjs",
61
61
  "docs:dev": "pnpm --dir docs dev",
62
+ "docs:theme": "node scripts/check-docs-theme.mjs",
62
63
  "format": "eslint . --fix",
63
64
  "format:check": "eslint .",
64
65
  "lint": "eslint .",
@@ -70,7 +71,7 @@
70
71
  "release:verify": "pnpm verify && pnpm release:pack",
71
72
  "test:artifact": "node scripts/check-release-artifact.mjs",
72
73
  "test:dependencies": "node scripts/check-production-dependencies.mjs",
73
- "test:docs": "node scripts/check-documentation.mjs && pnpm --dir docs check",
74
+ "test:docs": "node scripts/check-documentation.mjs && pnpm docs:theme && pnpm --dir docs check",
74
75
  "test:package": "node scripts/check-package.mjs",
75
76
  "test:package-metadata": "node scripts/check-api-report.mjs",
76
77
  "test:performance": "node --expose-gc ./node_modules/vitest/vitest.mjs run --config vitest.performance.config.ts",
@@ -79,7 +80,7 @@
79
80
  "test:quickstart": "node scripts/test-package-quickstart.mjs",
80
81
  "test:raster": "vitest run --config vitest.raster.config.ts",
81
82
  "test:version-headings": "node scripts/check-version-headings.mjs",
82
- "test:workflows": "node scripts/check-workflow-policy.mjs",
83
+ "test:workflows": "node scripts/check-workflow-policy.mjs && node scripts/test-npm-recovery.mjs && node scripts/test-release-recovery.mjs",
83
84
  "check:vercel": "node scripts/check-vercel-config.mjs",
84
85
  "test:serverless": "vitest run --config vitest.serverless.config.ts",
85
86
  "test:watch": "vitest watch",
@@ -139,6 +140,7 @@
139
140
  "typescript": "~5.9.3",
140
141
  "vitest": "^4.1.8",
141
142
  "vue": "3.5.40",
142
- "vue-tsc": "^3.3.3"
143
+ "vue-tsc": "^3.3.3",
144
+ "yaml": "2.9.0"
143
145
  }
144
146
  }