structured-render 0.0.4 → 0.0.5

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.
@@ -10,8 +10,24 @@ import { type RenderHtmlOptions, type RenderInput } from '../render/render-types
10
10
  export declare const VirStructuredRender: import("element-vir").DeclarativeElementDefinition<"vir-structured-render", {
11
11
  data: Readonly<RenderInput>;
12
12
  options?: Readonly<PartialWithUndefined<RenderHtmlOptions & {
13
- isTableSize: boolean;
13
+ /**
14
+ * If `true`, smaller, tablet-compatible styles are used.
15
+ *
16
+ * @default false
17
+ */
18
+ isTabletSize: boolean;
19
+ /**
20
+ * If `true`, smaller, phone-compatible styles are used.
21
+ *
22
+ * @default false
23
+ */
14
24
  isPhoneSize: boolean;
25
+ /**
26
+ * If `true`, all sections will automatically be expanded at first.
27
+ *
28
+ * @default false
29
+ */
30
+ expandAllSections: boolean;
15
31
  }>> | undefined;
16
32
  }, {
17
33
  currentlyExpanded: Record<string, boolean>;
@@ -29,7 +29,7 @@ export const VirStructuredRender = defineElement()({
29
29
  },
30
30
  hostClasses: {
31
31
  'vir-structured-render-phone-size': ({ inputs }) => !!inputs.options?.isPhoneSize,
32
- 'vir-structured-render-tablet-size': ({ inputs }) => !!inputs.options?.isTableSize,
32
+ 'vir-structured-render-tablet-size': ({ inputs }) => !!inputs.options?.isTabletSize,
33
33
  },
34
34
  styles: ({ cssVars, hostClasses }) => css `
35
35
  :host {
@@ -73,44 +73,6 @@ 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
- }
114
76
  function createHtml2PdfOptions(fileName) {
115
77
  return {
116
78
  margin: [
@@ -161,44 +123,35 @@ export async function renderInBrowser(structuredRenderData, { fileName, outputTy
161
123
  const htmlString = convertTemplateToString(html `
162
124
  <div class=${contentDivClass}>${DOMPurify.sanitize(dirtyHtml)}</div>
163
125
  `);
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
+ */
164
130
  const html2PdfOptions = createHtml2PdfOptions(fileName);
131
+ const canvas = await renderHtmlToCanvas(htmlString, String(options.styles), html2PdfOptions);
132
+ const instance = htmlToPdf().set(html2PdfOptions).from(canvas);
165
133
  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);
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
- }
134
+ if (outputType.pdf === OutputPdfType.Download) {
135
+ const pdfBlob = (await instance.outputPdf(OutputPdfType.Blob));
136
+ const blobUrl = URL.createObjectURL(pdfBlob);
137
+ const anchor = globalThis.document.createElement('a');
138
+ anchor.href = blobUrl;
139
+ anchor.download = fileName.endsWith('.pdf') ? fileName : `${fileName}.pdf`;
140
+ anchor.style.display = 'none';
141
+ globalThis.document.body.append(anchor);
142
+ anchor.click();
143
+ anchor.remove();
144
+ URL.revokeObjectURL(blobUrl);
145
+ return;
188
146
  }
189
- finally {
190
- wrapper.remove();
147
+ else {
148
+ return await instance.outputPdf(outputType.pdf, {
149
+ filename: fileName,
150
+ });
191
151
  }
192
152
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
193
153
  }
194
154
  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
155
  if (outputType.image === OutputImageType.Download) {
203
156
  return await instance.toImg().save(fileName);
204
157
  }
@@ -212,45 +165,11 @@ export async function renderInBrowser(structuredRenderData, { fileName, outputTy
212
165
  }
213
166
  }
214
167
  /**
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.
168
+ * Render HTML content to a canvas using a clean, minimal iframe. This avoids the massive
169
+ * performance penalty from html2canvas cloning the entire host page document (including all custom
170
+ * elements, shadow DOM, and styles) just to render a small content div.
252
171
  */
253
- async function createIsolatedContentIframe(htmlString, stylesString, html2PdfOptions) {
172
+ async function renderHtmlToCanvas(htmlString, stylesString, html2PdfOptions) {
254
173
  const marginArray = normalizeMargin(html2PdfOptions.margin);
255
174
  /** A4 page width in mm. */
256
175
  const pageWidthMm = 210;
@@ -272,31 +191,22 @@ async function createIsolatedContentIframe(htmlString, stylesString, html2PdfOpt
272
191
  '</body></html>',
273
192
  ].join('');
274
193
  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
194
  try {
195
+ await new Promise((resolve) => {
196
+ iframe.addEventListener('load', () => {
197
+ resolve();
198
+ }, {
199
+ once: true,
200
+ });
201
+ });
202
+ const iframeDoc = assertWrap.isDefined(iframe.contentDocument);
203
+ await iframeDoc.fonts.ready;
204
+ /**
205
+ * Elements from the iframe exist in a different window context, so `instanceof HTMLElement`
206
+ * (from the parent window) fails. Use assertWrap.isDefined since we control the HTML
207
+ * written to the iframe and know the first child is always an element.
208
+ */
209
+ const contentElement = assertWrap.isDefined(iframeDoc.body.firstElementChild);
300
210
  return await importHtml2Canvas()(contentElement, {
301
211
  ...html2PdfOptions.html2canvas,
302
212
  });
@@ -371,25 +371,48 @@ function structuredRenderToHtmlArray(data, options, keyChain) {
371
371
  const sectionTitle = ('sectionTitle' in data && keyChain.length > 0 && data.sectionTitle) || undefined;
372
372
  const sectionTemplate = htmlRenderers[data.type](data, options, keyChain);
373
373
  const sources = ('sources' in data && data.sources) || undefined;
374
+ const sectionContent = html `
375
+ <div
376
+ class=${classMap({
377
+ 'section-wrapper': true,
378
+ 'top-section-wrapper': keyChain.length === 0,
379
+ [structuredRenderSectionHtmlNames[data.type]]: true,
380
+ })}
381
+ ${testId(structuredRenderSectionHtmlTestId)}
382
+ ${testId(structuredRenderSectionHtmlNames[data.type])}
383
+ >
384
+ ${createSourceWrapper(sectionTemplate, options, keyChain, sources)}
385
+ </div>
386
+ `;
387
+ if (sectionTitle) {
388
+ const sectionKeyChain = [
389
+ ...keyChain,
390
+ 'section-collapsible',
391
+ ];
392
+ const sectionKey = makeChainKey(sectionKeyChain);
393
+ const isSectionExpanded = !!options.currentlyExpanded[sectionKey];
394
+ return [
395
+ html `
396
+ <${ViraCollapsibleWrapper.assign({
397
+ expanded: isSectionExpanded,
398
+ })}
399
+ class="section-collapsible-wrapper"
400
+ ${listen(ViraCollapsibleWrapper.events.expandChange, (event) => {
401
+ const eventTarget = extractEventTarget(event, HTMLElement);
402
+ eventTarget.dispatchEvent(new SourceExpansionEvent({
403
+ expanded: event.detail,
404
+ key: sectionKey,
405
+ }));
406
+ })}
407
+ >
408
+ <h3 slot=${ViraCollapsibleWrapper.slotNames.header}>${sectionTitle}</h3>
409
+ ${sectionContent}
410
+ </${ViraCollapsibleWrapper}>
411
+ `,
412
+ ];
413
+ }
374
414
  return [
375
- sectionTitle
376
- ? html `
377
- <h3>${sectionTitle}</h3>
378
- `
379
- : undefined,
380
- html `
381
- <div
382
- class=${classMap({
383
- 'section-wrapper': true,
384
- 'top-section-wrapper': keyChain.length === 0,
385
- [structuredRenderSectionHtmlNames[data.type]]: true,
386
- })}
387
- ${testId(structuredRenderSectionHtmlTestId)}
388
- ${testId(structuredRenderSectionHtmlNames[data.type])}
389
- >
390
- ${createSourceWrapper(sectionTemplate, options, keyChain, sources)}
391
- </div>
392
- `,
415
+ sectionContent,
393
416
  ];
394
417
  }
395
418
  else if ('sections' in data) {
@@ -106,8 +106,12 @@ export async function printPdf(renderInput, params) {
106
106
  }
107
107
  contentWindow.addEventListener('afterprint', () => {
108
108
  resolveOnce();
109
- }, { once: true });
110
- globalThis.window.addEventListener('focus', focusFallback, { once: true });
109
+ }, {
110
+ once: true,
111
+ });
112
+ globalThis.window.addEventListener('focus', focusFallback, {
113
+ once: true,
114
+ });
111
115
  contentWindow.print();
112
116
  };
113
117
  });
@@ -0,0 +1 @@
1
+ export declare const VirApp: import("element-vir").DeclarativeElementDefinition<"vir-app", {}, {}, {}, "vir-app-", "vir-app-", readonly [], readonly []>;
@@ -0,0 +1,9 @@
1
+ import { defineElement, html } from 'element-vir';
2
+ export const VirApp = defineElement()({
3
+ tagName: 'vir-app',
4
+ render() {
5
+ return html `
6
+ Vir App goes here!
7
+ `;
8
+ },
9
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "structured-render",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "A library for safely rendering arbitrary data generated from any source.",
5
5
  "keywords": [
6
6
  "render",
@@ -52,7 +52,7 @@
52
52
  "object-shape-tester": "^6.11.0",
53
53
  "theme-vir": "^28.22.0",
54
54
  "type-fest": "^5.4.4",
55
- "vira": "^29.7.2"
55
+ "vira": "^29.8.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@augment-vir/test": "^31.67.0",