structured-render 0.0.1 → 0.0.3

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:
@@ -116,48 +115,141 @@ export async function renderInBrowser(structuredRenderData, { fileName, outputTy
116
115
  throw new Error(`${renderInNode.name} cannot run outside of a browser.`);
117
116
  }
118
117
  const options = mergeDefinedProperties(defaultRenderMarkdownOptions, userOptions);
119
- const htmlToPdf = await importHtml2Pdf();
118
+ const [htmlToPdf] = await Promise.all([
119
+ importHtml2Pdf(),
120
+ preloadHtml2Canvas(),
121
+ ]);
120
122
  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
123
  const htmlString = convertTemplateToString(html `
129
124
  <div class=${contentDivClass}>${DOMPurify.sanitize(dirtyHtml)}</div>
130
125
  `);
131
- const instance = htmlToPdf().set(createHtml2PdfOptions(fileName)).from(htmlString);
132
- try {
133
- if (outputType.pdf) {
134
- if (outputType.pdf === OutputPdfType.Download) {
135
- return await instance.save(fileName);
136
- }
137
- else {
138
- return await instance.outputPdf(outputType.pdf, {
139
- filename: fileName,
140
- });
141
- }
142
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
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
+ const html2PdfOptions = createHtml2PdfOptions(fileName);
131
+ const canvas = await renderHtmlToCanvas(htmlString, String(options.styles), html2PdfOptions);
132
+ const instance = htmlToPdf().set(html2PdfOptions).from(canvas);
133
+ if (outputType.pdf) {
134
+ if (outputType.pdf === OutputPdfType.Download) {
135
+ return await instance.save(fileName);
143
136
  }
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
- }
137
+ else {
138
+ return await instance.outputPdf(outputType.pdf, {
139
+ filename: fileName,
140
+ });
141
+ }
142
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
143
+ }
144
+ else if (outputType.image) {
145
+ if (outputType.image === OutputImageType.Download) {
146
+ return await instance.toImg().save(fileName);
151
147
  }
152
148
  else {
153
- assert.tsType(outputType).equals();
154
- throw new Error(`Invalid output type: ${stringify(outputType)}`);
149
+ return await instance.outputImg(outputType.image);
155
150
  }
156
151
  }
152
+ else {
153
+ assert.tsType(outputType).equals();
154
+ throw new Error(`Invalid output type: ${stringify(outputType)}`);
155
+ }
156
+ }
157
+ /**
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.
161
+ */
162
+ async function renderHtmlToCanvas(htmlString, stylesString, html2PdfOptions) {
163
+ const marginArray = normalizeMargin(html2PdfOptions.margin);
164
+ /** A4 page width in mm. */
165
+ const pageWidthMm = 210;
166
+ const innerWidthMm = pageWidthMm - marginArray[1] - marginArray[3];
167
+ const iframe = globalThis.document.createElement('iframe');
168
+ iframe.style.position = 'fixed';
169
+ iframe.style.left = '-10000px';
170
+ iframe.style.top = '0';
171
+ iframe.style.width = `${innerWidthMm}mm`;
172
+ iframe.style.height = '0';
173
+ iframe.style.border = 'none';
174
+ iframe.setAttribute('aria-hidden', 'true');
175
+ iframe.srcdoc = [
176
+ '<!DOCTYPE html><html><head><style>',
177
+ baseContentResetStyles,
178
+ stylesString,
179
+ '</style></head><body style="margin:0;padding:0;">',
180
+ htmlString,
181
+ '</body></html>',
182
+ ].join('');
183
+ globalThis.document.body.append(iframe);
184
+ try {
185
+ await new Promise((resolve) => {
186
+ iframe.addEventListener('load', () => {
187
+ resolve();
188
+ }, {
189
+ once: true,
190
+ });
191
+ });
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);
200
+ return await importHtml2Canvas()(contentElement, {
201
+ ...html2PdfOptions.html2canvas,
202
+ });
203
+ }
157
204
  finally {
158
- styleElement.remove();
205
+ iframe.remove();
206
+ }
207
+ }
208
+ let cachedHtml2Canvas;
209
+ function importHtml2Canvas() {
210
+ if (!cachedHtml2Canvas) {
211
+ throw new Error('html2canvas has not been loaded yet. Call preloadHtml2Canvas() first.');
212
+ }
213
+ return cachedHtml2Canvas;
214
+ }
215
+ async function preloadHtml2Canvas() {
216
+ if (!cachedHtml2Canvas) {
217
+ // @ts-expect-error: html2canvas is a CJS module that doesn't properly resolve under node16 module resolution.
218
+ cachedHtml2Canvas = (await import('html2canvas')).default;
159
219
  }
160
220
  }
221
+ function normalizeMargin(margin) {
222
+ if (margin == undefined) {
223
+ return [
224
+ 0,
225
+ 0,
226
+ 0,
227
+ 0,
228
+ ];
229
+ }
230
+ if (typeof margin === 'number') {
231
+ return [
232
+ margin,
233
+ margin,
234
+ margin,
235
+ margin,
236
+ ];
237
+ }
238
+ if (margin.length === 2) {
239
+ return [
240
+ margin[0],
241
+ margin[1],
242
+ margin[0],
243
+ margin[1],
244
+ ];
245
+ }
246
+ return [
247
+ margin[0],
248
+ margin[1],
249
+ margin[2],
250
+ margin[3],
251
+ ];
252
+ }
161
253
  /**
162
254
  * Internal common Node.js structure render renderer.
163
255
  *
@@ -187,12 +279,20 @@ export async function renderInNode(structuredRenderData, { saveLocationPath, out
187
279
  const browser = await chromium.launch();
188
280
  try {
189
281
  const page = await browser.newPage();
190
- await page.setContent(baseHtmlString, { waitUntil: 'networkidle' });
191
- await page.addScriptTag({ content: html2pdfScript });
192
- await page.addScriptTag({ content: domPurifyScript });
282
+ await page.setContent(baseHtmlString, {
283
+ waitUntil: 'networkidle',
284
+ });
285
+ await page.addScriptTag({
286
+ content: html2pdfScript,
287
+ });
288
+ await page.addScriptTag({
289
+ content: domPurifyScript,
290
+ });
193
291
  const html2pdfOptions = createHtml2PdfOptions(basename(saveLocationPath));
194
292
  if (outputType.image) {
195
- html2pdfOptions.image = { type: 'png' };
293
+ html2pdfOptions.image = {
294
+ type: 'png',
295
+ };
196
296
  }
197
297
  const pdfBase64 = await page.evaluate(async ({ html2pdfOptions, outputType, dirtyMarkdown, wrapperClass, outputImageType, outputPdfType, }) => {
198
298
  const cleanHtml = DOMPurify.sanitize(dirtyMarkdown);
@@ -223,7 +323,9 @@ export async function renderInNode(structuredRenderData, { saveLocationPath, out
223
323
  outputPdfType: OutputPdfType.DataUriString,
224
324
  });
225
325
  const base64Data = pdfBase64.split(',')[1];
226
- await mkdir(dirname(saveLocationPath), { recursive: true });
326
+ await mkdir(dirname(saveLocationPath), {
327
+ recursive: true,
328
+ });
227
329
  await writeFile(saveLocationPath, Buffer.from(base64Data, 'base64'));
228
330
  }
229
331
  finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "structured-render",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "A library for safely rendering arbitrary data generated from any source.",
5
5
  "keywords": [
6
6
  "render",
@@ -39,22 +39,23 @@
39
39
  "test:web": "virmator test web"
40
40
  },
41
41
  "dependencies": {
42
- "@augment-vir/assert": "^31.65.0",
43
- "@augment-vir/common": "^31.65.0",
44
- "@augment-vir/web": "^31.65.0",
42
+ "@augment-vir/assert": "^31.67.0",
43
+ "@augment-vir/common": "^31.67.0",
44
+ "@augment-vir/web": "^31.67.0",
45
45
  "@electrovir/color": "^1.7.8",
46
46
  "dompurify": "^3.3.1",
47
- "element-vir": "^26.14.3",
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",
51
52
  "object-shape-tester": "^6.11.0",
52
53
  "theme-vir": "^28.22.0",
53
54
  "type-fest": "^5.4.4",
54
- "vira": "^29.6.1"
55
+ "vira": "^29.7.2"
55
56
  },
56
57
  "devDependencies": {
57
- "@augment-vir/test": "^31.65.0",
58
+ "@augment-vir/test": "^31.67.0",
58
59
  "@virmator/test": "^14.7.2",
59
60
  "@web/dev-server-esbuild": "^1.0.5",
60
61
  "@web/test-runner": "^0.20.2",