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.
- package/dist/elements/vir-structured-render.element.d.ts +17 -1
- package/dist/elements/vir-structured-render.element.js +1 -1
- package/dist/render/browser-rendering.js +41 -131
- package/dist/render/render-html.js +41 -18
- package/dist/render/render-pdf.js +6 -2
- package/dist/ui/elements/vir-app.element.d.ts +1 -0
- package/dist/ui/elements/vir-app.element.js +9 -0
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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?.
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
}, {
|
|
110
|
-
|
|
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 []>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "structured-render",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
55
|
+
"vira": "^29.8.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@augment-vir/test": "^31.67.0",
|