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.
@@ -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
- if (outputType.pdf === OutputPdfType.Download) {
135
- return await instance.save(fileName);
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
- else {
138
- return await instance.outputPdf(outputType.pdf, {
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
- * Render HTML content to a canvas using a clean, minimal iframe. This avoids the massive
159
- * performance penalty from html2canvas cloning the entire host page document (including all custom
160
- * elements, shadow DOM, and styles) just to render a small content div.
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
- async function renderHtmlToCanvas(htmlString, stylesString, html2PdfOptions) {
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
- try {
185
- await new Promise((resolve) => {
186
- iframe.addEventListener('load', () => {
187
- resolve();
188
- }, {
189
- once: true,
190
- });
275
+ await new Promise((resolve) => {
276
+ iframe.addEventListener('load', () => {
277
+ resolve();
278
+ }, {
279
+ once: true,
191
280
  });
192
- const iframeDoc = assertWrap.isDefined(iframe.contentDocument);
193
- await iframeDoc.fonts.ready;
194
- /**
195
- * Elements from the iframe exist in a different window context, so `instanceof HTMLElement`
196
- * (from the parent window) fails. Use assertWrap.isDefined since we control the HTML
197
- * written to the iframe and know the first child is always an element.
198
- */
199
- const contentElement = assertWrap.isDefined(iframeDoc.body.firstElementChild);
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.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.3",
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",