diff --git a/apps/sim/lib/pptx-renderer/utils/pdf-renderer.test.ts b/apps/sim/lib/pptx-renderer/utils/pdf-renderer.test.ts new file mode 100644 index 00000000000..d692ec529c3 --- /dev/null +++ b/apps/sim/lib/pptx-renderer/utils/pdf-renderer.test.ts @@ -0,0 +1,238 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderPdfToImage, WORKER_SRC } from '@/lib/pptx-renderer/utils/pdf-renderer' + +const PAGE_URL = 'https://example.com/preview' +const LIBRARY_URL = 'https://example.com/_next/static/media/pdf.min.mjs' +const WORKER_URL = 'https://example.com/_next/static/media/pdf.worker.min.mjs' + +/** + * Reproduce the root-relative asset strings a production bundler emits for the + * two pdfjs assets, so the absolute-URL resolution is exercised rather than + * assumed. Only `toString()` is stubbed; the module resolves the final value + * through `href`. + */ +function stubBundlerAssetUrls() { + vi.spyOn(URL.prototype, 'toString').mockImplementation(function (this: URL) { + return `/_next/static/media/${this.pathname.split('/').pop()}` + }) +} + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('renderPdfToImage', () => { + beforeEach(() => { + vi.stubGlobal('window', { location: { href: PAGE_URL } }) + stubBundlerAssetUrls() + }) + + it('skips rendering when isolated canvas rendering is unavailable', async () => { + vi.stubGlobal('Worker', class {}) + vi.stubGlobal('OffscreenCanvas', undefined) + + expect(await renderPdfToImage(new Uint8Array([1]), 10, 10)).toBeNull() + }) + + it('skips rendering when workers are unavailable', async () => { + vi.stubGlobal('OffscreenCanvas', class {}) + vi.stubGlobal('Worker', undefined) + + expect(await renderPdfToImage(new Uint8Array([1]), 10, 10)).toBeNull() + }) + + it('supplies absolute library and worker assets and preserves result/error behavior', async () => { + vi.useFakeTimers() + const postMessage = vi.fn() + const worker = { postMessage, onmessage: null as ((event: MessageEvent) => void) | null } + vi.stubGlobal('OffscreenCanvas', class {}) + vi.stubGlobal( + 'Worker', + vi.fn(function Worker() { + return worker + }) + ) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:rendered-pdf') + const input = new Uint8Array([1, 2, 3]) + + const result = renderPdfToImage(input, 20, 10) + const [message, transfer] = postMessage.mock.calls[0] + expect(message).toMatchObject({ + width: 20, + height: 10, + pdfjsUrl: LIBRARY_URL, + pdfjsWorkerUrl: WORKER_URL, + }) + expect(message.pdfData).toEqual(input) + expect(message.pdfData).not.toBe(input) + expect(transfer).toEqual([message.pdfData.buffer]) + worker.onmessage?.({ data: { id: message.id, blob: new Blob(['png']) } } as MessageEvent) + expect(await result).toBe('blob:rendered-pdf') + + const failed = renderPdfToImage(input, 20, 10) + worker.onmessage?.({ + data: { id: postMessage.mock.calls[1][0].id, error: 'Invalid PDF' }, + } as MessageEvent) + expect(await failed).toBeNull() + }) +}) + +interface WorkerScope { + onmessage: ((event: { data: Record }) => Promise) | null + postMessage: (message: Record) => void +} + +interface WorkerHarness { + imported: string[] + posted: Array> + globalWorkerOptions: Record + destroy: ReturnType + send: (data: Record) => Promise +} + +/** + * Execute {@link WORKER_SRC} in-process against a stand-in pdfjs. + * + * Node refuses dynamic `import()` inside `new Function` ("A dynamic import + * callback was not specified") and the `node:vm` hook needs + * `--experimental-vm-modules`, so the two import sites are redirected to an + * injected loader. The substitution count is asserted, so a source change that + * drops either import fails here rather than silently testing nothing. + */ +function runWorkerSource( + overrides: { pages?: number; getDocument?: () => { promise: Promise } } = {} +): WorkerHarness { + const imported: string[] = [] + const posted: Array> = [] + const globalWorkerOptions: Record = {} + const destroy = vi.fn() + + const page = { + getViewport: ({ scale }: { scale: number }) => ({ width: 100 * scale, height: 50 * scale }), + render: () => ({ promise: Promise.resolve() }), + } + const doc = { numPages: overrides.pages ?? 1, getPage: async () => page, destroy } + const library = { + GlobalWorkerOptions: globalWorkerOptions, + getDocument: overrides.getDocument ?? (() => ({ promise: Promise.resolve(doc) })), + } + + const load = async (url: string) => { + imported.push(url) + return url.includes('pdf.worker') ? {} : library + } + + class FakeOffscreenCanvas { + constructor( + public width: number, + public height: number + ) {} + getContext() { + return {} + } + async convertToBlob() { + return new Blob(['png']) + } + } + + expect(WORKER_SRC.split('await import(').length - 1).toBe(2) + const source = WORKER_SRC.replaceAll('await import(', 'await __load(') + + const scope: WorkerScope = { + onmessage: null, + postMessage: (message) => posted.push(message), + } + new Function('self', '__load', 'OffscreenCanvas', source)(scope, load, FakeOffscreenCanvas) + + return { + imported, + posted, + globalWorkerOptions, + destroy, + send: async (data) => { + await scope.onmessage?.({ data }) + }, + } +} + +describe('WORKER_SRC', () => { + it('imports the worker module before the library and renders a blob', async () => { + const harness = runWorkerSource() + + await harness.send({ + id: 7, + pdfData: new Uint8Array([1]), + width: 20, + height: 10, + pdfjsUrl: LIBRARY_URL, + pdfjsWorkerUrl: WORKER_URL, + }) + + expect(harness.imported).toEqual([WORKER_URL, LIBRARY_URL]) + expect(harness.posted).toHaveLength(1) + expect(harness.posted[0].id).toBe(7) + expect(harness.posted[0].blob).toBeInstanceOf(Blob) + expect(harness.destroy).toHaveBeenCalledOnce() + }) + + /** + * The original defect: pdfjs reads `workerSrc` through a getter that throws + * when falsy, outside its own try/catch, so assigning it here broke every + * render. The worker must leave pdfjs configuration untouched. + */ + it('never writes to GlobalWorkerOptions', async () => { + const harness = runWorkerSource() + + await harness.send({ + id: 1, + pdfData: new Uint8Array([1]), + width: 20, + height: 10, + pdfjsUrl: LIBRARY_URL, + pdfjsWorkerUrl: WORKER_URL, + }) + + expect(harness.globalWorkerOptions).toEqual({}) + expect('workerSrc' in harness.globalWorkerOptions).toBe(false) + }) + + it('reports an error instead of a blob when the document has no pages', async () => { + const harness = runWorkerSource({ pages: 0 }) + + await harness.send({ + id: 2, + pdfData: new Uint8Array([1]), + width: 20, + height: 10, + pdfjsUrl: LIBRARY_URL, + pdfjsWorkerUrl: WORKER_URL, + }) + + expect(harness.posted).toEqual([{ id: 2, error: 'no pages' }]) + expect(harness.destroy).toHaveBeenCalledOnce() + }) + + it('reports an error when pdfjs rejects', async () => { + const harness = runWorkerSource({ + getDocument: () => ({ promise: Promise.reject(new Error('Invalid PDF')) }), + }) + + await harness.send({ + id: 3, + pdfData: new Uint8Array([1]), + width: 20, + height: 10, + pdfjsUrl: LIBRARY_URL, + pdfjsWorkerUrl: WORKER_URL, + }) + + expect(harness.posted).toHaveLength(1) + expect(harness.posted[0].id).toBe(3) + expect(harness.posted[0].error).toContain('Invalid PDF') + }) +}) diff --git a/apps/sim/lib/pptx-renderer/utils/pdf-renderer.ts b/apps/sim/lib/pptx-renderer/utils/pdf-renderer.ts index c51bfcaeab7..3bb2eec6ecf 100644 --- a/apps/sim/lib/pptx-renderer/utils/pdf-renderer.ts +++ b/apps/sim/lib/pptx-renderer/utils/pdf-renderer.ts @@ -15,43 +15,61 @@ * fallback, no global state pollution. */ -// Resolved pdfjs URL — computed once from main thread's module resolution +// Resolved pdfjs URLs — computed once from main thread's module resolution -let _pdfjsUrl: string | null = null +interface PdfjsUrls { + library: string + worker: string +} + +let _pdfjsUrls: PdfjsUrls | null = null +let _pdfjsUrlsResolved = false -function getPdfjsUrl(): string | null { - if (_pdfjsUrl !== null) return _pdfjsUrl +function getPdfjsUrls(): PdfjsUrls | null { + if (_pdfjsUrlsResolved) return _pdfjsUrls + _pdfjsUrlsResolved = true try { - // Resolve via the bundler/dev server so the URL is usable from a Worker - _pdfjsUrl = new URL('pdfjs-dist/build/pdf.min.mjs', import.meta.url).toString() + const library = new URL('pdfjs-dist/build/pdf.min.mjs', import.meta.url).toString() + const worker = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString() + /** Bundlers can emit root-relative asset URLs, which cannot resolve inside a blob worker. */ + _pdfjsUrls = { + library: new URL(library, window.location.href).href, + worker: new URL(worker, window.location.href).href, + } } catch { - _pdfjsUrl = '' + _pdfjsUrls = null } - return _pdfjsUrl || null + return _pdfjsUrls } // Worker-based renderer (fully isolated from main thread pdfjs) /** * Inline source for the PDF render worker. - * Receives: { id, pdfData, width, height, pdfjsUrl } + * Receives: { id, pdfData, width, height, pdfjsUrl, pdfjsWorkerUrl } * Posts back: { id, blob } or { id, error } * * The worker loads its OWN pdfjs instance via dynamic import, so its static * PagesMapper state is completely independent of the main thread. - * pdfjs's own internal worker is disabled (workerPort = null, workerSrc = '') - * so pdfjs runs single-threaded inside this worker — acceptable for tiny - * 1-page EMF PDFs. + * Loading the matching worker module installs its WorkerMessageHandler in + * this isolated global scope. PDF.js then uses its in-context worker fallback + * without creating another worker or changing the host app's configuration. + * + * Never assign `GlobalWorkerOptions.workerSrc` here: pdfjs reads it through a + * getter that throws when falsy, and the read happens outside its own + * try/catch, so a falsy assignment makes every `getDocument` call fail. + * + * @internal Exported so tests can execute this source directly. */ -const WORKER_SRC = /* js */ ` +export const WORKER_SRC = /* js */ ` let pdfjsLib = null; self.onmessage = async (e) => { - const { id, pdfData, width, height, pdfjsUrl } = e.data; + const { id, pdfData, width, height, pdfjsUrl, pdfjsWorkerUrl } = e.data; try { if (!pdfjsLib) { + await import(pdfjsWorkerUrl); pdfjsLib = await import(pdfjsUrl); - pdfjsLib.GlobalWorkerOptions.workerSrc = ''; } const doc = await pdfjsLib.getDocument({ data: pdfData }).promise; @@ -88,7 +106,7 @@ const _pending = new Map< { resolve: (b: Blob | null) => void; reject: (e: Error) => void } >() -function getWorker(_pdfjsUrl: string): Worker | null { +function getWorker(): Worker | null { if (_workerFailed) return null if (_worker) return _worker @@ -130,10 +148,10 @@ function renderInWorker( pdfData: Uint8Array, width: number, height: number, - pdfjsUrl: string + pdfjsUrls: PdfjsUrls ): Promise { return new Promise((resolve) => { - const worker = getWorker(pdfjsUrl) + const worker = getWorker() if (!worker) { resolve(null) return @@ -147,7 +165,17 @@ function renderInWorker( // Transfer the buffer to avoid copying const copy = pdfData.slice() // copy so caller retains original - worker.postMessage({ id, pdfData: copy, width, height, pdfjsUrl }, [copy.buffer]) + worker.postMessage( + { + id, + pdfData: copy, + width, + height, + pdfjsUrl: pdfjsUrls.library, + pdfjsWorkerUrl: pdfjsUrls.worker, + }, + [copy.buffer] + ) // Timeout: if worker doesn't respond in 15s, give up setTimeout(() => { @@ -175,14 +203,14 @@ export async function renderPdfToImage( width: number, height: number ): Promise { - const pdfjsUrl = getPdfjsUrl() + const pdfjsUrls = getPdfjsUrls() - if (!pdfjsUrl || typeof OffscreenCanvas === 'undefined' || typeof Worker === 'undefined') { + if (!pdfjsUrls || typeof OffscreenCanvas === 'undefined' || typeof Worker === 'undefined') { return null } try { - const blob = await renderInWorker(pdfData, width, height, pdfjsUrl) + const blob = await renderInWorker(pdfData, width, height, pdfjsUrls) if (blob) return URL.createObjectURL(blob) } catch { // Worker failed — no fallback, return null