structured-render 0.0.2 → 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.
|
@@ -55,7 +55,9 @@ export const VirMarkdown = defineElement()({
|
|
|
55
55
|
class=${contentDivClass}
|
|
56
56
|
${onDomCreated((element) => {
|
|
57
57
|
if (element instanceof HTMLElement) {
|
|
58
|
-
updateState({
|
|
58
|
+
updateState({
|
|
59
|
+
renderedElement: element,
|
|
60
|
+
});
|
|
59
61
|
}
|
|
60
62
|
})}
|
|
61
63
|
>
|
|
@@ -4,10 +4,9 @@ import DOMPurify from 'dompurify';
|
|
|
4
4
|
import { convertTemplateToString, html } from 'element-vir';
|
|
5
5
|
import { marked } from 'marked';
|
|
6
6
|
import { importHtml2Pdf } from './html-to-pdf.js';
|
|
7
|
-
import { contentDivClass } from './render-markdown-styles.js';
|
|
7
|
+
import { baseContentResetStyles, contentDivClass } from './render-markdown-styles.js';
|
|
8
8
|
import { renderStructuredMarkdown } from './render-markdown.js';
|
|
9
9
|
import { defaultRenderMarkdownOptions, } from './render-types.js';
|
|
10
|
-
const globalStyleId = 'structured-rendering-style-id';
|
|
11
10
|
// cspell:disable
|
|
12
11
|
/**
|
|
13
12
|
* Output method types for jsPDF. Extracted from:
|
|
@@ -74,6 +73,44 @@ export var OutputImageType;
|
|
|
74
73
|
OutputImageType["Download"] = "download";
|
|
75
74
|
})(OutputImageType || (OutputImageType = {}));
|
|
76
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
|
+
}
|
|
77
114
|
function createHtml2PdfOptions(fileName) {
|
|
78
115
|
return {
|
|
79
116
|
margin: [
|
|
@@ -116,48 +153,203 @@ export async function renderInBrowser(structuredRenderData, { fileName, outputTy
|
|
|
116
153
|
throw new Error(`${renderInNode.name} cannot run outside of a browser.`);
|
|
117
154
|
}
|
|
118
155
|
const options = mergeDefinedProperties(defaultRenderMarkdownOptions, userOptions);
|
|
119
|
-
const htmlToPdf = await
|
|
156
|
+
const [htmlToPdf] = await Promise.all([
|
|
157
|
+
importHtml2Pdf(),
|
|
158
|
+
preloadHtml2Canvas(),
|
|
159
|
+
]);
|
|
120
160
|
const dirtyHtml = await marked.parse(renderStructuredMarkdown(structuredRenderData, options));
|
|
121
|
-
const styleElement = assertWrap.instanceOf(globalThis.document.head.querySelector(`#${globalStyleId}`) ||
|
|
122
|
-
globalThis.document.createElement('style'), HTMLStyleElement);
|
|
123
|
-
styleElement.id = globalStyleId;
|
|
124
|
-
if (!styleElement.isConnected) {
|
|
125
|
-
globalThis.document.head.append(styleElement);
|
|
126
|
-
}
|
|
127
|
-
styleElement.textContent = String(options.styles);
|
|
128
161
|
const htmlString = convertTemplateToString(html `
|
|
129
162
|
<div class=${contentDivClass}>${DOMPurify.sanitize(dirtyHtml)}</div>
|
|
130
163
|
`);
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
164
|
+
const html2PdfOptions = createHtml2PdfOptions(fileName);
|
|
165
|
+
if (outputType.pdf) {
|
|
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);
|
|
134
175
|
if (outputType.pdf === OutputPdfType.Download) {
|
|
135
|
-
|
|
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;
|
|
136
182
|
}
|
|
137
183
|
else {
|
|
138
184
|
return await instance.outputPdf(outputType.pdf, {
|
|
139
185
|
filename: fileName,
|
|
140
186
|
});
|
|
141
187
|
}
|
|
142
|
-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
143
188
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
189
|
+
finally {
|
|
190
|
+
wrapper.remove();
|
|
191
|
+
}
|
|
192
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
193
|
+
}
|
|
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);
|
|
202
|
+
if (outputType.image === OutputImageType.Download) {
|
|
203
|
+
return await instance.toImg().save(fileName);
|
|
151
204
|
}
|
|
152
205
|
else {
|
|
153
|
-
|
|
154
|
-
throw new Error(`Invalid output type: ${stringify(outputType)}`);
|
|
206
|
+
return await instance.outputImg(outputType.image);
|
|
155
207
|
}
|
|
156
208
|
}
|
|
209
|
+
else {
|
|
210
|
+
assert.tsType(outputType).equals();
|
|
211
|
+
throw new Error(`Invalid output type: ${stringify(outputType)}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
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.
|
|
220
|
+
*/
|
|
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) {
|
|
254
|
+
const marginArray = normalizeMargin(html2PdfOptions.margin);
|
|
255
|
+
/** A4 page width in mm. */
|
|
256
|
+
const pageWidthMm = 210;
|
|
257
|
+
const innerWidthMm = pageWidthMm - marginArray[1] - marginArray[3];
|
|
258
|
+
const iframe = globalThis.document.createElement('iframe');
|
|
259
|
+
iframe.style.position = 'fixed';
|
|
260
|
+
iframe.style.left = '-10000px';
|
|
261
|
+
iframe.style.top = '0';
|
|
262
|
+
iframe.style.width = `${innerWidthMm}mm`;
|
|
263
|
+
iframe.style.height = '0';
|
|
264
|
+
iframe.style.border = 'none';
|
|
265
|
+
iframe.setAttribute('aria-hidden', 'true');
|
|
266
|
+
iframe.srcdoc = [
|
|
267
|
+
'<!DOCTYPE html><html><head><style>',
|
|
268
|
+
baseContentResetStyles,
|
|
269
|
+
stylesString,
|
|
270
|
+
'</style></head><body style="margin:0;padding:0;">',
|
|
271
|
+
htmlString,
|
|
272
|
+
'</body></html>',
|
|
273
|
+
].join('');
|
|
274
|
+
globalThis.document.body.append(iframe);
|
|
275
|
+
await new Promise((resolve) => {
|
|
276
|
+
iframe.addEventListener('load', () => {
|
|
277
|
+
resolve();
|
|
278
|
+
}, {
|
|
279
|
+
once: true,
|
|
280
|
+
});
|
|
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 {
|
|
300
|
+
return await importHtml2Canvas()(contentElement, {
|
|
301
|
+
...html2PdfOptions.html2canvas,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
157
304
|
finally {
|
|
158
|
-
|
|
305
|
+
iframe.remove();
|
|
159
306
|
}
|
|
160
307
|
}
|
|
308
|
+
let cachedHtml2Canvas;
|
|
309
|
+
function importHtml2Canvas() {
|
|
310
|
+
if (!cachedHtml2Canvas) {
|
|
311
|
+
throw new Error('html2canvas has not been loaded yet. Call preloadHtml2Canvas() first.');
|
|
312
|
+
}
|
|
313
|
+
return cachedHtml2Canvas;
|
|
314
|
+
}
|
|
315
|
+
async function preloadHtml2Canvas() {
|
|
316
|
+
if (!cachedHtml2Canvas) {
|
|
317
|
+
// @ts-expect-error: html2canvas is a CJS module that doesn't properly resolve under node16 module resolution.
|
|
318
|
+
cachedHtml2Canvas = (await import('html2canvas')).default;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function normalizeMargin(margin) {
|
|
322
|
+
if (margin == undefined) {
|
|
323
|
+
return [
|
|
324
|
+
0,
|
|
325
|
+
0,
|
|
326
|
+
0,
|
|
327
|
+
0,
|
|
328
|
+
];
|
|
329
|
+
}
|
|
330
|
+
if (typeof margin === 'number') {
|
|
331
|
+
return [
|
|
332
|
+
margin,
|
|
333
|
+
margin,
|
|
334
|
+
margin,
|
|
335
|
+
margin,
|
|
336
|
+
];
|
|
337
|
+
}
|
|
338
|
+
if (margin.length === 2) {
|
|
339
|
+
return [
|
|
340
|
+
margin[0],
|
|
341
|
+
margin[1],
|
|
342
|
+
margin[0],
|
|
343
|
+
margin[1],
|
|
344
|
+
];
|
|
345
|
+
}
|
|
346
|
+
return [
|
|
347
|
+
margin[0],
|
|
348
|
+
margin[1],
|
|
349
|
+
margin[2],
|
|
350
|
+
margin[3],
|
|
351
|
+
];
|
|
352
|
+
}
|
|
161
353
|
/**
|
|
162
354
|
* Internal common Node.js structure render renderer.
|
|
163
355
|
*
|
|
@@ -187,12 +379,20 @@ export async function renderInNode(structuredRenderData, { saveLocationPath, out
|
|
|
187
379
|
const browser = await chromium.launch();
|
|
188
380
|
try {
|
|
189
381
|
const page = await browser.newPage();
|
|
190
|
-
await page.setContent(baseHtmlString, {
|
|
191
|
-
|
|
192
|
-
|
|
382
|
+
await page.setContent(baseHtmlString, {
|
|
383
|
+
waitUntil: 'networkidle',
|
|
384
|
+
});
|
|
385
|
+
await page.addScriptTag({
|
|
386
|
+
content: html2pdfScript,
|
|
387
|
+
});
|
|
388
|
+
await page.addScriptTag({
|
|
389
|
+
content: domPurifyScript,
|
|
390
|
+
});
|
|
193
391
|
const html2pdfOptions = createHtml2PdfOptions(basename(saveLocationPath));
|
|
194
392
|
if (outputType.image) {
|
|
195
|
-
html2pdfOptions.image = {
|
|
393
|
+
html2pdfOptions.image = {
|
|
394
|
+
type: 'png',
|
|
395
|
+
};
|
|
196
396
|
}
|
|
197
397
|
const pdfBase64 = await page.evaluate(async ({ html2pdfOptions, outputType, dirtyMarkdown, wrapperClass, outputImageType, outputPdfType, }) => {
|
|
198
398
|
const cleanHtml = DOMPurify.sanitize(dirtyMarkdown);
|
|
@@ -223,7 +423,9 @@ export async function renderInNode(structuredRenderData, { saveLocationPath, out
|
|
|
223
423
|
outputPdfType: OutputPdfType.DataUriString,
|
|
224
424
|
});
|
|
225
425
|
const base64Data = pdfBase64.split(',')[1];
|
|
226
|
-
await mkdir(dirname(saveLocationPath), {
|
|
426
|
+
await mkdir(dirname(saveLocationPath), {
|
|
427
|
+
recursive: true,
|
|
428
|
+
});
|
|
227
429
|
await writeFile(saveLocationPath, Buffer.from(base64Data, 'base64'));
|
|
228
430
|
}
|
|
229
431
|
finally {
|
|
@@ -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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "structured-render",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "A library for safely rendering arbitrary data generated from any source.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"render",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"@electrovir/color": "^1.7.8",
|
|
46
46
|
"dompurify": "^3.3.1",
|
|
47
47
|
"element-vir": "^26.14.4",
|
|
48
|
+
"html2canvas": "^1.4.1",
|
|
48
49
|
"html2pdf.js": "^0.14.0",
|
|
49
50
|
"lit-css-vars": "^3.5.0",
|
|
50
51
|
"marked": "^17.0.3",
|