Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions apps/sim/lib/pptx-renderer/utils/pdf-renderer.test.ts
Original file line number Diff line number Diff line change
@@ -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])
Comment thread
BillLeoutsakosvl346 marked this conversation as resolved.

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<string, unknown> }) => Promise<void>) | null
postMessage: (message: Record<string, unknown>) => void
}

interface WorkerHarness {
imported: string[]
posted: Array<Record<string, unknown>>
globalWorkerOptions: Record<string, unknown>
destroy: ReturnType<typeof vi.fn>
send: (data: Record<string, unknown>) => Promise<void>
}

/**
* 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<unknown> } } = {}
): WorkerHarness {
const imported: string[] = []
const posted: Array<Record<string, unknown>> = []
const globalWorkerOptions: Record<string, unknown> = {}
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')
})
})
72 changes: 50 additions & 22 deletions apps/sim/lib/pptx-renderer/utils/pdf-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -130,10 +148,10 @@ function renderInWorker(
pdfData: Uint8Array,
width: number,
height: number,
pdfjsUrl: string
pdfjsUrls: PdfjsUrls
): Promise<Blob | null> {
return new Promise((resolve) => {
const worker = getWorker(pdfjsUrl)
const worker = getWorker()
if (!worker) {
resolve(null)
return
Expand All @@ -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(() => {
Expand Down Expand Up @@ -175,14 +203,14 @@ export async function renderPdfToImage(
width: number,
height: number
): Promise<string | null> {
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
Expand Down
Loading