| 1 | import { createRoot, type Root } from "react-dom/client"; |
| 2 | import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; |
| 3 | import type { Components } from "react-markdown"; |
| 4 | import katexCss from "katex/dist/katex.min.css?inline"; |
| 5 | import { highlightToHtml } from "./highlight"; |
| 6 | import { reasonixRehypePlugins, reasonixRemarkPlugins } from "../components/markdownRemarkPlugins"; |
| 7 | import { normalizeMath } from "../components/mathNormalize"; |
| 8 | import { |
| 9 | createRasterPdf, |
| 10 | isSafeInlineExportImage, |
| 11 | neutralizeExternalCssResources, |
| 12 | PDF_CONTENT_ASPECT, |
| 13 | planRasterSlices, |
| 14 | transformExportMarkdownUrl, |
| 15 | type RasterSlice, |
| 16 | } from "./sessionExportCore"; |
| 17 | |
| 18 | const EXPORT_WIDTH = 920; |
| 19 | const MAX_CANVAS_SIDE = 8192; |
| 20 | |
| 21 | const EXPORT_STYLES = neutralizeExternalCssResources(` |
| 22 | ${katexCss} |
| 23 | .session-export-page, |
| 24 | .session-export-page * { |
| 25 | box-sizing: border-box; |
| 26 | } |
| 27 | .session-export-page { |
| 28 | --fg: #111827; |
| 29 | --fg-dim: #374151; |
| 30 | --fg-faint: #6b7280; |
| 31 | --bg: #ffffff; |
| 32 | --bg-soft: #f7f8fb; |
| 33 | --bg-elev-2: #eef2f7; |
| 34 | --border: #d8dee8; |
| 35 | --border-soft: #e7ebf1; |
| 36 | --accent: #2563eb; |
| 37 | --font-content: 15px; |
| 38 | --font-code: 13px; |
| 39 | --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; |
| 40 | width: ${EXPORT_WIDTH}px; |
| 41 | min-height: 1px; |
| 42 | padding: 48px 56px; |
| 43 | background: #ffffff; |
| 44 | color: var(--fg); |
| 45 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "Microsoft YaHei", sans-serif; |
| 46 | font-size: 16px; |
| 47 | line-height: 1.68; |
| 48 | overflow-wrap: anywhere; |
| 49 | } |
| 50 | .session-export-page .md > :first-child { |
| 51 | margin-top: 0; |
| 52 | } |
| 53 | .session-export-page .md > :last-child { |
| 54 | margin-bottom: 0; |
| 55 | } |
| 56 | .session-export-page .md p { |
| 57 | margin: 0 0 12px; |
| 58 | } |
| 59 | .session-export-page .md h1, |
| 60 | .session-export-page .md h2, |
| 61 | .session-export-page .md h3, |
| 62 | .session-export-page .md h4 { |
| 63 | margin: 20px 0 10px; |
| 64 | line-height: 1.3; |
| 65 | font-weight: 650; |
| 66 | letter-spacing: 0; |
| 67 | } |
| 68 | .session-export-page .md h1 { |
| 69 | font-size: 1.65em; |
| 70 | padding-bottom: 14px; |
| 71 | border-bottom: 1px solid var(--border-soft); |
| 72 | } |
| 73 | .session-export-page .md h2 { |
| 74 | font-size: 1.28em; |
| 75 | } |
| 76 | .session-export-page .md h3 { |
| 77 | font-size: 1.12em; |
| 78 | } |
| 79 | .session-export-page .md h4 { |
| 80 | font-size: 1em; |
| 81 | color: var(--fg-dim); |
| 82 | } |
| 83 | .session-export-page .md ul, |
| 84 | .session-export-page .md ol { |
| 85 | margin: 0 0 12px; |
| 86 | padding-left: 24px; |
| 87 | } |
| 88 | .session-export-page .md li { |
| 89 | margin: 3px 0; |
| 90 | } |
| 91 | .session-export-page .md li::marker { |
| 92 | color: var(--fg-faint); |
| 93 | } |
| 94 | .session-export-page .md a { |
| 95 | color: var(--accent); |
| 96 | text-decoration: none; |
| 97 | } |
| 98 | .session-export-page .md blockquote { |
| 99 | margin: 0 0 14px; |
| 100 | padding: 4px 14px; |
| 101 | border-left: 3px solid var(--border); |
| 102 | color: var(--fg-dim); |
| 103 | background: #fafbfc; |
| 104 | } |
| 105 | .session-export-page .md hr { |
| 106 | border: none; |
| 107 | border-top: 1px solid var(--border); |
| 108 | margin: 20px 0; |
| 109 | } |
| 110 | .session-export-page .md-code { |
| 111 | font-family: var(--mono); |
| 112 | font-size: 0.88em; |
| 113 | background: var(--bg-elev-2); |
| 114 | border: 1px solid var(--border-soft); |
| 115 | border-radius: 5px; |
| 116 | padding: 1px 5px; |
| 117 | } |
| 118 | .session-export-page .md table { |
| 119 | width: 100%; |
| 120 | border-collapse: collapse; |
| 121 | margin: 0 0 14px; |
| 122 | font-size: var(--font-content); |
| 123 | display: table; |
| 124 | overflow: visible; |
| 125 | } |
| 126 | .session-export-page .md th, |
| 127 | .session-export-page .md td { |
| 128 | border: 1px solid var(--border); |
| 129 | padding: 7px 10px; |
| 130 | text-align: left; |
| 131 | vertical-align: top; |
| 132 | } |
| 133 | .session-export-page .md th { |
| 134 | background: var(--bg-soft); |
| 135 | font-weight: 650; |
| 136 | } |
| 137 | .session-export-page .code-block { |
| 138 | position: relative; |
| 139 | } |
| 140 | .session-export-page .copybtn, |
| 141 | .session-export-page .code-block__copy { |
| 142 | display: none !important; |
| 143 | } |
| 144 | .session-export-page .code { |
| 145 | margin: 10px 0; |
| 146 | padding: 12px 14px; |
| 147 | background: var(--bg-soft); |
| 148 | border: 1px solid var(--border-soft); |
| 149 | border-radius: 8px; |
| 150 | font-family: var(--mono); |
| 151 | font-size: var(--font-code); |
| 152 | line-height: 1.58; |
| 153 | overflow: visible; |
| 154 | white-space: pre-wrap; |
| 155 | color: var(--fg); |
| 156 | } |
| 157 | .session-export-page .hljs-comment, |
| 158 | .session-export-page .hljs-quote { |
| 159 | color: #6b7280; |
| 160 | } |
| 161 | .session-export-page .hljs-keyword, |
| 162 | .session-export-page .hljs-selector-tag, |
| 163 | .session-export-page .hljs-literal, |
| 164 | .session-export-page .hljs-section, |
| 165 | .session-export-page .hljs-doctag, |
| 166 | .session-export-page .hljs-type, |
| 167 | .session-export-page .hljs-name, |
| 168 | .session-export-page .hljs-strong { |
| 169 | color: #7c3aed; |
| 170 | } |
| 171 | .session-export-page .hljs-string, |
| 172 | .session-export-page .hljs-regexp, |
| 173 | .session-export-page .hljs-attribute, |
| 174 | .session-export-page .hljs-meta .hljs-string { |
| 175 | color: #047857; |
| 176 | } |
| 177 | .session-export-page .hljs-number, |
| 178 | .session-export-page .hljs-symbol, |
| 179 | .session-export-page .hljs-bullet, |
| 180 | .session-export-page .hljs-link, |
| 181 | .session-export-page .hljs-selector-attr, |
| 182 | .session-export-page .hljs-selector-pseudo { |
| 183 | color: #b45309; |
| 184 | } |
| 185 | .session-export-page .hljs-title, |
| 186 | .session-export-page .hljs-title.function_, |
| 187 | .session-export-page .hljs-section .hljs-title { |
| 188 | color: #1d4ed8; |
| 189 | } |
| 190 | .session-export-page .hljs-class .hljs-title, |
| 191 | .session-export-page .hljs-title.class_, |
| 192 | .session-export-page .hljs-built_in, |
| 193 | .session-export-page .hljs-attr { |
| 194 | color: #be123c; |
| 195 | } |
| 196 | .session-export-page .export-media-placeholder { |
| 197 | display: inline-block; |
| 198 | max-width: 100%; |
| 199 | margin: 4px 0; |
| 200 | padding: 7px 10px; |
| 201 | border: 1px solid var(--border-soft); |
| 202 | border-radius: 6px; |
| 203 | color: var(--fg-faint); |
| 204 | background: var(--bg-soft); |
| 205 | font-size: 0.9em; |
| 206 | } |
| 207 | `); |
| 208 | |
| 209 | const staticMarkdownComponents: Components = { |
| 210 | pre: ({ children }) => <>{children}</>, |
| 211 | code: ({ className, children }) => { |
| 212 | const text = String(children ?? ""); |
| 213 | const match = /language-([\w-]+)/.exec(className ?? ""); |
| 214 | const isBlock = match !== null || text.includes("\n"); |
| 215 | if (!isBlock) return <code className="md-code">{children}</code>; |
| 216 | return ( |
| 217 | <pre className="code hljs" data-lang={match?.[1]}> |
| 218 | <code dangerouslySetInnerHTML={{ __html: highlightToHtml(text.replace(/\n$/, ""), match?.[1]) }} /> |
| 219 | </pre> |
| 220 | ); |
| 221 | }, |
| 222 | a: ({ href, children }) => <a href={href}>{children}</a>, |
| 223 | img: ({ src, alt, title }) => { |
| 224 | if (isSafeInlineExportImage(src)) { |
| 225 | return <img src={src} alt={alt ?? ""} title={title} />; |
| 226 | } |
| 227 | return <span className="export-media-placeholder">[{alt || title || "image"}]</span>; |
| 228 | }, |
| 229 | }; |
| 230 | |
| 231 | function StaticMarkdown({ text }: { text: string }) { |
| 232 | return ( |
| 233 | <div className="md"> |
| 234 | <ReactMarkdown |
| 235 | remarkPlugins={reasonixRemarkPlugins} |
| 236 | rehypePlugins={reasonixRehypePlugins} |
| 237 | components={staticMarkdownComponents} |
| 238 | urlTransform={(value, key) => transformExportMarkdownUrl(value, key, defaultUrlTransform)} |
| 239 | > |
| 240 | {normalizeMath(text)} |
| 241 | </ReactMarkdown> |
| 242 | </div> |
| 243 | ); |
| 244 | } |
| 245 | |
| 246 | function ExportSurface({ markdown }: { markdown: string }) { |
| 247 | return ( |
| 248 | <> |
| 249 | <style>{EXPORT_STYLES}</style> |
| 250 | <div className="session-export-page"> |
| 251 | <StaticMarkdown text={markdown} /> |
| 252 | </div> |
| 253 | </> |
| 254 | ); |
| 255 | } |
| 256 | |
| 257 | interface RenderedExport { |
| 258 | root: Root; |
| 259 | host: HTMLDivElement; |
| 260 | surface: HTMLElement; |
| 261 | } |
| 262 | |
| 263 | function nextFrame(): Promise<void> { |
| 264 | return new Promise((resolve) => requestAnimationFrame(() => resolve())); |
| 265 | } |
| 266 | |
| 267 | async function waitForInlineImages(surface: HTMLElement): Promise<void> { |
| 268 | await Promise.all( |
| 269 | Array.from(surface.querySelectorAll("img")).map(async (image) => { |
| 270 | if (typeof image.decode === "function") { |
| 271 | try { |
| 272 | await image.decode(); |
| 273 | } catch { |
| 274 | // Broken inline data stays as the browser's normal missing-image UI. |
| 275 | } |
| 276 | return; |
| 277 | } |
| 278 | if (image.complete) return; |
| 279 | await new Promise<void>((resolve) => { |
| 280 | const done = () => { |
| 281 | image.removeEventListener("load", done); |
| 282 | image.removeEventListener("error", done); |
| 283 | resolve(); |
| 284 | }; |
| 285 | image.addEventListener("load", done, { once: true }); |
| 286 | image.addEventListener("error", done, { once: true }); |
| 287 | if (image.complete) done(); |
| 288 | }); |
| 289 | }), |
| 290 | ); |
| 291 | } |
| 292 | |
| 293 | async function renderExportSurface(markdown: string): Promise<RenderedExport> { |
| 294 | const host = document.createElement("div"); |
| 295 | host.style.position = "fixed"; |
| 296 | host.style.left = "-100000px"; |
| 297 | host.style.top = "0"; |
| 298 | host.style.width = `${EXPORT_WIDTH}px`; |
| 299 | host.style.pointerEvents = "none"; |
| 300 | host.style.background = "#ffffff"; |
| 301 | document.body.appendChild(host); |
| 302 | |
| 303 | const root = createRoot(host); |
| 304 | root.render(<ExportSurface markdown={markdown} />); |
| 305 | |
| 306 | await nextFrame(); |
| 307 | await nextFrame(); |
| 308 | await document.fonts?.ready.catch(() => undefined); |
| 309 | await nextFrame(); |
| 310 | |
| 311 | const surface = host.querySelector<HTMLElement>(".session-export-page"); |
| 312 | if (!surface) { |
| 313 | root.unmount(); |
| 314 | host.remove(); |
| 315 | throw new Error("Export surface was not rendered"); |
| 316 | } |
| 317 | |
| 318 | await waitForInlineImages(surface); |
| 319 | await nextFrame(); |
| 320 | |
| 321 | return { root, host, surface }; |
| 322 | } |
| 323 | |
| 324 | function disposeExport(rendered: RenderedExport): void { |
| 325 | rendered.root.unmount(); |
| 326 | rendered.host.remove(); |
| 327 | } |
| 328 | |
| 329 | function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality?: number): Promise<Blob> { |
| 330 | return new Promise((resolve, reject) => { |
| 331 | canvas.toBlob((blob) => { |
| 332 | if (blob) resolve(blob); |
| 333 | else reject(new Error("Could not encode export image")); |
| 334 | }, type, quality); |
| 335 | }); |
| 336 | } |
| 337 | |
| 338 | function loadImage(url: string): Promise<HTMLImageElement> { |
| 339 | return new Promise((resolve, reject) => { |
| 340 | const img = new Image(); |
| 341 | img.onload = () => resolve(img); |
| 342 | img.onerror = () => reject(new Error("Could not render export image")); |
| 343 | img.src = url; |
| 344 | }); |
| 345 | } |
| 346 | |
| 347 | function replaceExternalMedia(root: HTMLElement): void { |
| 348 | root.querySelectorAll("img, video, audio, iframe, object, embed").forEach((element) => { |
| 349 | if (element instanceof HTMLImageElement && isSafeInlineExportImage(element.getAttribute("src") ?? undefined)) return; |
| 350 | const placeholder = document.createElement("span"); |
| 351 | placeholder.className = "export-media-placeholder"; |
| 352 | const label = element.getAttribute("alt") || element.getAttribute("title") || element.tagName.toLowerCase(); |
| 353 | placeholder.textContent = `[${label}]`; |
| 354 | element.replaceWith(placeholder); |
| 355 | }); |
| 356 | } |
| 357 | |
| 358 | function pruneSliceClone(source: HTMLElement, clone: HTMLElement, slice: RasterSlice): void { |
| 359 | const sourceMarkdown = source.querySelector<HTMLElement>(".md"); |
| 360 | const cloneMarkdown = clone.querySelector<HTMLElement>(".md"); |
| 361 | if (!sourceMarkdown || !cloneMarkdown) return; |
| 362 | const surfaceTop = source.getBoundingClientRect().top; |
| 363 | const markdownTop = sourceMarkdown.getBoundingClientRect().top; |
| 364 | const selected: Array<{ node: Element; top: number }> = []; |
| 365 | Array.from(sourceMarkdown.children).forEach((node) => { |
| 366 | const rect = node.getBoundingClientRect(); |
| 367 | const top = rect.top - surfaceTop; |
| 368 | const bottom = rect.bottom - surfaceTop; |
| 369 | if (bottom > slice.offset && top < slice.offset + slice.height) { |
| 370 | selected.push({ node, top: rect.top - markdownTop }); |
| 371 | } |
| 372 | }); |
| 373 | if (selected.length === 0) return; |
| 374 | |
| 375 | cloneMarkdown.replaceChildren(); |
| 376 | cloneMarkdown.style.position = "relative"; |
| 377 | cloneMarkdown.style.height = `${Math.max(1, sourceMarkdown.scrollHeight)}px`; |
| 378 | for (const item of selected) { |
| 379 | const child = item.node.cloneNode(true) as HTMLElement; |
| 380 | child.style.position = "absolute"; |
| 381 | child.style.top = `${item.top}px`; |
| 382 | child.style.left = "0"; |
| 383 | child.style.right = "0"; |
| 384 | child.style.margin = "0"; |
| 385 | cloneMarkdown.appendChild(child); |
| 386 | } |
| 387 | } |
| 388 | |
| 389 | function serializeSurfaceSlice(surface: HTMLElement, width: number, slice: RasterSlice): string { |
| 390 | const clone = surface.cloneNode(true) as HTMLElement; |
| 391 | pruneSliceClone(surface, clone, slice); |
| 392 | replaceExternalMedia(clone); |
| 393 | clone.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); |
| 394 | clone.style.width = `${width}px`; |
| 395 | clone.style.minHeight = `${slice.offset + slice.height}px`; |
| 396 | clone.style.transform = `translateY(-${slice.offset}px)`; |
| 397 | clone.style.transformOrigin = "top left"; |
| 398 | const style = document.createElement("style"); |
| 399 | style.textContent = EXPORT_STYLES; |
| 400 | clone.insertBefore(style, clone.firstChild); |
| 401 | const viewport = document.createElement("div"); |
| 402 | viewport.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); |
| 403 | viewport.style.width = `${width}px`; |
| 404 | viewport.style.height = `${slice.height}px`; |
| 405 | viewport.style.overflow = "hidden"; |
| 406 | viewport.style.background = "#ffffff"; |
| 407 | viewport.appendChild(clone); |
| 408 | return new XMLSerializer().serializeToString(viewport); |
| 409 | } |
| 410 | |
| 411 | function exportScale(): number { |
| 412 | return Math.min(2, Math.max(1.5, window.devicePixelRatio || 1)); |
| 413 | } |
| 414 | |
| 415 | function naturalPageBreaks(surface: HTMLElement): number[] { |
| 416 | const surfaceTop = surface.getBoundingClientRect().top; |
| 417 | const markdown = surface.querySelector<HTMLElement>(".md"); |
| 418 | if (!markdown) return []; |
| 419 | return Array.from(markdown.children).map((node) => Math.ceil(node.getBoundingClientRect().bottom - surfaceTop)); |
| 420 | } |
| 421 | |
| 422 | function planSurfaceSlices(surface: HTMLElement, maxSliceHeight: number): RasterSlice[] { |
| 423 | const height = Math.max(1, Math.ceil(surface.scrollHeight || surface.getBoundingClientRect().height || 1)); |
| 424 | const breakpoints = naturalPageBreaks(surface); |
| 425 | const contentEnd = breakpoints.length > 0 |
| 426 | ? breakpoints.reduce((maximum, value) => Math.max(maximum, value), 1) |
| 427 | : height; |
| 428 | return planRasterSlices(height, maxSliceHeight, breakpoints, contentEnd); |
| 429 | } |
| 430 | |
| 431 | async function renderSurfaceSliceToCanvas( |
| 432 | surface: HTMLElement, |
| 433 | slice: RasterSlice, |
| 434 | scale: number, |
| 435 | ): Promise<HTMLCanvasElement> { |
| 436 | const width = Math.max(1, Math.ceil(surface.scrollWidth || surface.getBoundingClientRect().width || EXPORT_WIDTH)); |
| 437 | const canvasWidth = Math.max(1, Math.floor(width * scale)); |
| 438 | const canvasHeight = Math.max(1, Math.floor(slice.height * scale)); |
| 439 | const serialized = serializeSurfaceSlice(surface, width, slice); |
| 440 | const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${slice.height}" viewBox="0 0 ${width} ${slice.height}"><foreignObject width="100%" height="100%">${serialized}</foreignObject></svg>`; |
| 441 | // Chromium/WebView2 versions used by shipped desktop builds mark an SVG |
| 442 | // foreignObject loaded from a blob URL as cross-origin. A self-contained data |
| 443 | // URL keeps the image origin-clean so toBlob() can encode the canvas. |
| 444 | const svgUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; |
| 445 | const image = await loadImage(svgUrl); |
| 446 | const canvas = document.createElement("canvas"); |
| 447 | canvas.width = canvasWidth; |
| 448 | canvas.height = canvasHeight; |
| 449 | const ctx = canvas.getContext("2d"); |
| 450 | if (!ctx) throw new Error("Canvas is not available"); |
| 451 | ctx.fillStyle = "#ffffff"; |
| 452 | ctx.fillRect(0, 0, canvasWidth, canvasHeight); |
| 453 | ctx.scale(scale, scale); |
| 454 | ctx.drawImage(image, 0, 0, width, slice.height); |
| 455 | return canvas; |
| 456 | } |
| 457 | |
| 458 | function arrayBufferFromBytes(bytes: Uint8Array): ArrayBuffer { |
| 459 | const copy = new Uint8Array(bytes.byteLength); |
| 460 | copy.set(bytes); |
| 461 | return copy.buffer; |
| 462 | } |
| 463 | |
| 464 | export function blobToBase64(blob: Blob): Promise<string> { |
| 465 | return new Promise((resolve, reject) => { |
| 466 | const reader = new FileReader(); |
| 467 | reader.onload = () => { |
| 468 | const result = String(reader.result ?? ""); |
| 469 | resolve(result.includes(",") ? result.slice(result.indexOf(",") + 1) : result); |
| 470 | }; |
| 471 | reader.onerror = () => reject(reader.error ?? new Error("Could not read export payload")); |
| 472 | reader.readAsDataURL(blob); |
| 473 | }); |
| 474 | } |
| 475 | |
| 476 | async function renderSessionImages<T>(markdown: string, encode: (blob: Blob) => Promise<T>): Promise<T[]> { |
| 477 | const rendered = await renderExportSurface(markdown); |
| 478 | try { |
| 479 | const scale = exportScale(); |
| 480 | const slices = planSurfaceSlices(rendered.surface, Math.floor(MAX_CANVAS_SIDE / scale)); |
| 481 | const images: T[] = []; |
| 482 | for (const slice of slices) { |
| 483 | const canvas = await renderSurfaceSliceToCanvas(rendered.surface, slice, scale); |
| 484 | try { |
| 485 | images.push(await encode(await canvasToBlob(canvas, "image/png"))); |
| 486 | } finally { |
| 487 | canvas.width = 1; |
| 488 | canvas.height = 1; |
| 489 | } |
| 490 | } |
| 491 | return images; |
| 492 | } finally { |
| 493 | disposeExport(rendered); |
| 494 | } |
| 495 | } |
| 496 | |
| 497 | export function renderSessionImageBlobs(markdown: string): Promise<Blob[]> { |
| 498 | return renderSessionImages(markdown, async (blob) => blob); |
| 499 | } |
| 500 | |
| 501 | export function renderSessionImageBase64Payloads(markdown: string): Promise<string[]> { |
| 502 | return renderSessionImages(markdown, blobToBase64); |
| 503 | } |
| 504 | |
| 505 | export async function renderSessionPdfBlob(markdown: string, title: string): Promise<Blob> { |
| 506 | const rendered = await renderExportSurface(markdown); |
| 507 | try { |
| 508 | const scale = exportScale(); |
| 509 | const width = Math.max(1, Math.ceil(rendered.surface.scrollWidth || rendered.surface.getBoundingClientRect().width || EXPORT_WIDTH)); |
| 510 | const pageHeight = Math.max(1, Math.floor(width * PDF_CONTENT_ASPECT)); |
| 511 | const slices = planSurfaceSlices(rendered.surface, pageHeight); |
| 512 | const images = []; |
| 513 | for (const slice of slices) { |
| 514 | const canvas = await renderSurfaceSliceToCanvas(rendered.surface, slice, scale); |
| 515 | try { |
| 516 | const jpegBlob = await canvasToBlob(canvas, "image/jpeg", 0.9); |
| 517 | images.push({ bytes: new Uint8Array(await jpegBlob.arrayBuffer()), width: canvas.width, height: canvas.height }); |
| 518 | } finally { |
| 519 | canvas.width = 1; |
| 520 | canvas.height = 1; |
| 521 | } |
| 522 | } |
| 523 | const pdfBytes = createRasterPdf(images, title); |
| 524 | return new Blob([arrayBufferFromBytes(pdfBytes)], { type: "application/pdf" }); |
| 525 | } finally { |
| 526 | disposeExport(rendered); |
| 527 | } |
| 528 | } |
| 529 |