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({ renderedElement: element });
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 importHtml2Pdf();
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 instance = htmlToPdf().set(createHtml2PdfOptions(fileName)).from(htmlString);
132
- try {
133
- if (outputType.pdf) {
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
- return await instance.save(fileName);
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
- else if (outputType.image) {
145
- if (outputType.image === OutputImageType.Download) {
146
- return await instance.toImg().save(fileName);
147
- }
148
- else {
149
- return await instance.outputImg(outputType.image);
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
- assert.tsType(outputType).equals();
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
- styleElement.remove();
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, { waitUntil: 'networkidle' });
191
- await page.addScriptTag({ content: html2pdfScript });
192
- await page.addScriptTag({ content: domPurifyScript });
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 = { type: 'png' };
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), { recursive: true });
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.display = 'none';
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?.print();
60
- URL.revokeObjectURL(blobUrl);
61
- resolve();
62
- printFrame.remove();
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.2",
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",