structured-render 0.0.3 → 0.0.4
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/dist/render/browser-rendering.js +131 -31
- package/dist/render/render-pdf.js +54 -5
- package/package.json +1 -1
|
@@ -73,6 +73,44 @@ export var OutputImageType;
|
|
|
73
73
|
OutputImageType["Download"] = "download";
|
|
74
74
|
})(OutputImageType || (OutputImageType = {}));
|
|
75
75
|
// cspell:enable
|
|
76
|
+
/**
|
|
77
|
+
* Triggers a file download from a Blob without opening the content in the browser.
|
|
78
|
+
*
|
|
79
|
+
* Firefox's content handler auto-opens downloaded PDFs in a new tab when it detects the `.pdf`
|
|
80
|
+
* extension on a blob URL download. To prevent this, Firefox uses a `data:` URI with an
|
|
81
|
+
* `application/octet-stream` MIME type. Data URIs don't carry a URL path, so Firefox can't perform
|
|
82
|
+
* extension-based content-type sniffing and won't match the download against its PDF handler.
|
|
83
|
+
*
|
|
84
|
+
* Other browsers use a standard blob URL + anchor download approach.
|
|
85
|
+
*/
|
|
86
|
+
async function triggerBlobDownload(blob, downloadFileName) {
|
|
87
|
+
const anchor = globalThis.document.createElement('a');
|
|
88
|
+
anchor.download = downloadFileName;
|
|
89
|
+
anchor.style.display = 'none';
|
|
90
|
+
if (navigator.userAgent.toLowerCase().includes('firefox')) {
|
|
91
|
+
const dataUrl = await new Promise((resolve, reject) => {
|
|
92
|
+
const reader = new FileReader();
|
|
93
|
+
reader.onload = () => {
|
|
94
|
+
resolve(String(reader.result).replace(/^data:[^;]*;/, 'data:application/octet-stream;'));
|
|
95
|
+
};
|
|
96
|
+
reader.onerror = () => {
|
|
97
|
+
reject(new Error('Failed to read blob for download.'));
|
|
98
|
+
};
|
|
99
|
+
reader.readAsDataURL(blob);
|
|
100
|
+
});
|
|
101
|
+
anchor.href = dataUrl;
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
const downloadBlob = new Blob([blob], { type: 'application/octet-stream' });
|
|
105
|
+
anchor.href = URL.createObjectURL(downloadBlob);
|
|
106
|
+
globalThis.setTimeout(() => {
|
|
107
|
+
URL.revokeObjectURL(anchor.href);
|
|
108
|
+
}, 40_000);
|
|
109
|
+
}
|
|
110
|
+
globalThis.document.body.append(anchor);
|
|
111
|
+
anchor.click();
|
|
112
|
+
anchor.remove();
|
|
113
|
+
}
|
|
76
114
|
function createHtml2PdfOptions(fileName) {
|
|
77
115
|
return {
|
|
78
116
|
margin: [
|
|
@@ -123,25 +161,44 @@ export async function renderInBrowser(structuredRenderData, { fileName, outputTy
|
|
|
123
161
|
const htmlString = convertTemplateToString(html `
|
|
124
162
|
<div class=${contentDivClass}>${DOMPurify.sanitize(dirtyHtml)}</div>
|
|
125
163
|
`);
|
|
126
|
-
/**
|
|
127
|
-
* Render in a clean iframe context so html2canvas only clones the minimal iframe document
|
|
128
|
-
* instead of the entire host page DOM (which is extremely slow on complex pages).
|
|
129
|
-
*/
|
|
130
164
|
const html2PdfOptions = createHtml2PdfOptions(fileName);
|
|
131
|
-
const canvas = await renderHtmlToCanvas(htmlString, String(options.styles), html2PdfOptions);
|
|
132
|
-
const instance = htmlToPdf().set(html2PdfOptions).from(canvas);
|
|
133
165
|
if (outputType.pdf) {
|
|
134
|
-
|
|
135
|
-
|
|
166
|
+
/**
|
|
167
|
+
* For PDFs, create a hidden container in the main document (not an iframe) so html2pdf can
|
|
168
|
+
* inspect the DOM for page break positions while also having access to the styles. Passing
|
|
169
|
+
* a canvas loses page break info (tables get sliced). Passing an iframe element loses
|
|
170
|
+
* styles (html2pdf clones elements into the main document, dropping iframe styles).
|
|
171
|
+
*/
|
|
172
|
+
const { wrapper, contentElement } = createIsolatedContentDiv(htmlString, String(options.styles), html2PdfOptions);
|
|
173
|
+
try {
|
|
174
|
+
const instance = htmlToPdf().set(html2PdfOptions).from(contentElement);
|
|
175
|
+
if (outputType.pdf === OutputPdfType.Download) {
|
|
176
|
+
const pdfBlob = (await instance.outputPdf(OutputPdfType.Blob));
|
|
177
|
+
const pdfFileName = fileName.endsWith('.pdf')
|
|
178
|
+
? fileName
|
|
179
|
+
: `${fileName}.pdf`;
|
|
180
|
+
await triggerBlobDownload(pdfBlob, pdfFileName);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
return await instance.outputPdf(outputType.pdf, {
|
|
185
|
+
filename: fileName,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
136
188
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
filename: fileName,
|
|
140
|
-
});
|
|
189
|
+
finally {
|
|
190
|
+
wrapper.remove();
|
|
141
191
|
}
|
|
142
192
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
143
193
|
}
|
|
144
194
|
else if (outputType.image) {
|
|
195
|
+
/**
|
|
196
|
+
* For images, render to a canvas in a clean iframe context so html2canvas only clones the
|
|
197
|
+
* minimal iframe document instead of the entire host page DOM (which is extremely slow on
|
|
198
|
+
* complex pages). No page break logic is needed for images.
|
|
199
|
+
*/
|
|
200
|
+
const canvas = await renderHtmlToCanvas(htmlString, String(options.styles), html2PdfOptions);
|
|
201
|
+
const instance = htmlToPdf().set(html2PdfOptions).from(canvas);
|
|
145
202
|
if (outputType.image === OutputImageType.Download) {
|
|
146
203
|
return await instance.toImg().save(fileName);
|
|
147
204
|
}
|
|
@@ -155,11 +212,45 @@ export async function renderInBrowser(structuredRenderData, { fileName, outputTy
|
|
|
155
212
|
}
|
|
156
213
|
}
|
|
157
214
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
215
|
+
* Create a hidden container div in the main document with styles and content. Unlike an iframe, the
|
|
216
|
+
* styles live in the same document context so html2pdf.js can see them when it clones the element.
|
|
217
|
+
* This allows html2pdf to both render styles correctly AND inspect the DOM for page break positions.
|
|
218
|
+
*
|
|
219
|
+
* The caller is responsible for removing the returned wrapper when done.
|
|
161
220
|
*/
|
|
162
|
-
|
|
221
|
+
function createIsolatedContentDiv(htmlString, stylesString, html2PdfOptions) {
|
|
222
|
+
const marginArray = normalizeMargin(html2PdfOptions.margin);
|
|
223
|
+
/** A4 page width in mm. */
|
|
224
|
+
const pageWidthMm = 210;
|
|
225
|
+
const innerWidthMm = pageWidthMm - marginArray[1] - marginArray[3];
|
|
226
|
+
const wrapper = globalThis.document.createElement('div');
|
|
227
|
+
wrapper.style.position = 'fixed';
|
|
228
|
+
wrapper.style.left = '-10000px';
|
|
229
|
+
wrapper.style.top = '0';
|
|
230
|
+
wrapper.style.width = `${innerWidthMm}mm`;
|
|
231
|
+
wrapper.style.overflow = 'hidden';
|
|
232
|
+
wrapper.setAttribute('aria-hidden', 'true');
|
|
233
|
+
const styleElement = globalThis.document.createElement('style');
|
|
234
|
+
styleElement.textContent = [
|
|
235
|
+
baseContentResetStyles,
|
|
236
|
+
stylesString,
|
|
237
|
+
].join('\n');
|
|
238
|
+
wrapper.append(styleElement);
|
|
239
|
+
const contentContainer = globalThis.document.createElement('div');
|
|
240
|
+
contentContainer.innerHTML = htmlString;
|
|
241
|
+
wrapper.append(contentContainer);
|
|
242
|
+
globalThis.document.body.append(wrapper);
|
|
243
|
+
const contentElement = assertWrap.isDefined(contentContainer.firstElementChild);
|
|
244
|
+
return { wrapper, contentElement };
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Create an isolated iframe containing the rendered HTML content and styles. The iframe provides a
|
|
248
|
+
* minimal document context so that html2canvas / html2pdf only need to process a small DOM tree
|
|
249
|
+
* instead of the entire host page.
|
|
250
|
+
*
|
|
251
|
+
* The caller is responsible for removing the iframe when done.
|
|
252
|
+
*/
|
|
253
|
+
async function createIsolatedContentIframe(htmlString, stylesString, html2PdfOptions) {
|
|
163
254
|
const marginArray = normalizeMargin(html2PdfOptions.margin);
|
|
164
255
|
/** A4 page width in mm. */
|
|
165
256
|
const pageWidthMm = 210;
|
|
@@ -181,22 +272,31 @@ async function renderHtmlToCanvas(htmlString, stylesString, html2PdfOptions) {
|
|
|
181
272
|
'</body></html>',
|
|
182
273
|
].join('');
|
|
183
274
|
globalThis.document.body.append(iframe);
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
once: true,
|
|
190
|
-
});
|
|
275
|
+
await new Promise((resolve) => {
|
|
276
|
+
iframe.addEventListener('load', () => {
|
|
277
|
+
resolve();
|
|
278
|
+
}, {
|
|
279
|
+
once: true,
|
|
191
280
|
});
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
281
|
+
});
|
|
282
|
+
const iframeDoc = assertWrap.isDefined(iframe.contentDocument);
|
|
283
|
+
await iframeDoc.fonts.ready;
|
|
284
|
+
/**
|
|
285
|
+
* Elements from the iframe exist in a different window context, so `instanceof HTMLElement`
|
|
286
|
+
* (from the parent window) fails. Use assertWrap.isDefined since we control the HTML written to
|
|
287
|
+
* the iframe and know the first child is always an element.
|
|
288
|
+
*/
|
|
289
|
+
const contentElement = assertWrap.isDefined(iframeDoc.body.firstElementChild);
|
|
290
|
+
return { iframe, contentElement };
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Render HTML content to a canvas using a clean, minimal iframe. This avoids the massive
|
|
294
|
+
* performance penalty from html2canvas cloning the entire host page document (including all custom
|
|
295
|
+
* elements, shadow DOM, and styles) just to render a small content div.
|
|
296
|
+
*/
|
|
297
|
+
async function renderHtmlToCanvas(htmlString, stylesString, html2PdfOptions) {
|
|
298
|
+
const { iframe, contentElement } = await createIsolatedContentIframe(htmlString, stylesString, html2PdfOptions);
|
|
299
|
+
try {
|
|
200
300
|
return await importHtml2Canvas()(contentElement, {
|
|
201
301
|
...html2PdfOptions.html2canvas,
|
|
202
302
|
});
|
|
@@ -50,16 +50,65 @@ export async function printPdf(renderInput, params) {
|
|
|
50
50
|
...params,
|
|
51
51
|
}));
|
|
52
52
|
const blobUrl = URL.createObjectURL(pdfBlob);
|
|
53
|
+
/**
|
|
54
|
+
* Firefox uses PDF.js to render PDFs in iframes, which doesn't support printing via
|
|
55
|
+
* `contentWindow.print()` on blob URLs. Open a new window instead so Firefox's native PDF
|
|
56
|
+
* viewer handles it.
|
|
57
|
+
*/
|
|
58
|
+
if (navigator.userAgent.toLowerCase().includes('firefox')) {
|
|
59
|
+
const printWindow = globalThis.window.open(blobUrl);
|
|
60
|
+
if (!printWindow) {
|
|
61
|
+
URL.revokeObjectURL(blobUrl);
|
|
62
|
+
throw new Error('Failed to open print window. Check your popup blocker settings.');
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
53
66
|
const printFrame = globalThis.document.createElement('iframe');
|
|
54
|
-
printFrame.style.
|
|
67
|
+
printFrame.style.position = 'fixed';
|
|
68
|
+
printFrame.style.left = '-10000px';
|
|
69
|
+
printFrame.style.top = '0';
|
|
70
|
+
printFrame.style.width = '1px';
|
|
71
|
+
printFrame.style.height = '1px';
|
|
72
|
+
printFrame.style.border = 'none';
|
|
73
|
+
printFrame.style.opacity = '0';
|
|
55
74
|
printFrame.src = blobUrl;
|
|
56
75
|
globalThis.document.body.append(printFrame);
|
|
57
76
|
return new Promise((resolve) => {
|
|
58
77
|
printFrame.onload = () => {
|
|
59
|
-
printFrame.contentWindow
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
78
|
+
const contentWindow = printFrame.contentWindow;
|
|
79
|
+
if (!contentWindow) {
|
|
80
|
+
URL.revokeObjectURL(blobUrl);
|
|
81
|
+
printFrame.remove();
|
|
82
|
+
resolve();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
function cleanup() {
|
|
86
|
+
URL.revokeObjectURL(blobUrl);
|
|
87
|
+
printFrame.remove();
|
|
88
|
+
resolve();
|
|
89
|
+
}
|
|
90
|
+
let resolved = false;
|
|
91
|
+
function resolveOnce() {
|
|
92
|
+
if (resolved) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
resolved = true;
|
|
96
|
+
globalThis.window.removeEventListener('focus', focusFallback);
|
|
97
|
+
cleanup();
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Fallback: when the print dialog closes (print or cancel), focus returns to the main
|
|
101
|
+
* window. Some browsers don't fire `afterprint` on the iframe's contentWindow when the
|
|
102
|
+
* user cancels.
|
|
103
|
+
*/
|
|
104
|
+
function focusFallback() {
|
|
105
|
+
resolveOnce();
|
|
106
|
+
}
|
|
107
|
+
contentWindow.addEventListener('afterprint', () => {
|
|
108
|
+
resolveOnce();
|
|
109
|
+
}, { once: true });
|
|
110
|
+
globalThis.window.addEventListener('focus', focusFallback, { once: true });
|
|
111
|
+
contentWindow.print();
|
|
63
112
|
};
|
|
64
113
|
});
|
|
65
114
|
}
|