Skip to content

Commit 628da73

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(pptx): initialize isolated embedded PDF renderer
1 parent 23afa2a commit 628da73

2 files changed

Lines changed: 93 additions & 20 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, describe, expect, it, vi } from 'vitest'
5+
import { renderPdfToImage } from '@/lib/pptx-renderer/utils/pdf-renderer'
6+
7+
afterEach(() => {
8+
vi.unstubAllGlobals()
9+
vi.restoreAllMocks()
10+
vi.useRealTimers()
11+
})
12+
13+
describe('renderPdfToImage', () => {
14+
it('skips rendering when isolated canvas rendering is unavailable', async () => {
15+
vi.stubGlobal('OffscreenCanvas', undefined)
16+
expect(await renderPdfToImage(new Uint8Array([1]), 10, 10)).toBeNull()
17+
})
18+
19+
it('supplies matching library and worker assets and preserves result/error behavior', async () => {
20+
vi.useFakeTimers()
21+
const postMessage = vi.fn()
22+
const worker = { postMessage, onmessage: null as ((event: MessageEvent) => void) | null }
23+
vi.stubGlobal('window', { location: { href: 'https://example.com/preview' } })
24+
/** Model the root-relative asset strings returned by the production bundler. */
25+
vi.spyOn(URL.prototype, 'toString').mockImplementation(function () {
26+
return `/_next/static/media/${this.pathname.split('/').pop()}`
27+
})
28+
vi.stubGlobal('OffscreenCanvas', class {})
29+
vi.stubGlobal(
30+
'Worker',
31+
vi.fn(function Worker() {
32+
return worker
33+
})
34+
)
35+
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:rendered-pdf')
36+
const input = new Uint8Array([1, 2, 3])
37+
38+
const result = renderPdfToImage(input, 20, 10)
39+
const [message, transfer] = postMessage.mock.calls[0]
40+
expect(message).toMatchObject({
41+
width: 20,
42+
height: 10,
43+
pdfjsUrl: 'https://example.com/_next/static/media/pdf.min.mjs',
44+
pdfjsWorkerUrl: 'https://example.com/_next/static/media/pdf.worker.min.mjs',
45+
})
46+
expect(message.pdfData).toEqual(input)
47+
expect(message.pdfData).not.toBe(input)
48+
expect(transfer).toEqual([message.pdfData.buffer])
49+
worker.onmessage?.({ data: { id: message.id, blob: new Blob(['png']) } } as MessageEvent)
50+
expect(await result).toBe('blob:rendered-pdf')
51+
52+
const failed = renderPdfToImage(input, 20, 10)
53+
worker.onmessage?.({
54+
data: { id: postMessage.mock.calls[1][0].id, error: 'Invalid PDF' },
55+
} as MessageEvent)
56+
expect(await failed).toBeNull()
57+
})
58+
})

apps/sim/lib/pptx-renderer/utils/pdf-renderer.ts

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,41 +17,46 @@
1717

1818
// Resolved pdfjs URL — computed once from main thread's module resolution
1919

20-
let _pdfjsUrl: string | null = null
20+
let _pdfjsUrls: { library: string; worker: string } | null = null
2121

22-
function getPdfjsUrl(): string | null {
23-
if (_pdfjsUrl !== null) return _pdfjsUrl
22+
function getPdfjsUrls(): { library: string; worker: string } | null {
23+
if (_pdfjsUrls !== null) return _pdfjsUrls
2424
try {
25-
// Resolve via the bundler/dev server so the URL is usable from a Worker
26-
_pdfjsUrl = new URL('pdfjs-dist/build/pdf.min.mjs', import.meta.url).toString()
25+
const library = new URL('pdfjs-dist/build/pdf.min.mjs', import.meta.url).toString()
26+
const worker = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString()
27+
/** Bundlers can emit root-relative asset URLs, which cannot resolve inside a blob worker. */
28+
_pdfjsUrls = {
29+
library: new URL(library, window.location.href).href,
30+
worker: new URL(worker, window.location.href).href,
31+
}
2732
} catch {
28-
_pdfjsUrl = ''
33+
return null
2934
}
30-
return _pdfjsUrl || null
35+
return _pdfjsUrls
3136
}
3237

3338
// Worker-based renderer (fully isolated from main thread pdfjs)
3439

3540
/**
3641
* Inline source for the PDF render worker.
37-
* Receives: { id, pdfData, width, height, pdfjsUrl }
42+
* Receives: { id, pdfData, width, height, pdfjsUrl, pdfjsWorkerUrl }
3843
* Posts back: { id, blob } or { id, error }
3944
*
4045
* The worker loads its OWN pdfjs instance via dynamic import, so its static
4146
* PagesMapper state is completely independent of the main thread.
42-
* pdfjs's own internal worker is disabled (workerPort = null, workerSrc = '')
43-
* so pdfjs runs single-threaded inside this worker — acceptable for tiny
44-
* 1-page EMF PDFs.
47+
* Loading the matching worker module installs its WorkerMessageHandler in
48+
* this isolated global scope. PDF.js then uses its in-context worker fallback
49+
* without creating another worker or changing the host app's configuration.
4550
*/
4651
const WORKER_SRC = /* js */ `
4752
let pdfjsLib = null;
4853
4954
self.onmessage = async (e) => {
50-
const { id, pdfData, width, height, pdfjsUrl } = e.data;
55+
const { id, pdfData, width, height, pdfjsUrl, pdfjsWorkerUrl } = e.data;
5156
try {
5257
if (!pdfjsLib) {
58+
await import(pdfjsWorkerUrl);
5359
pdfjsLib = await import(pdfjsUrl);
54-
pdfjsLib.GlobalWorkerOptions.workerSrc = '';
5560
}
5661
5762
const doc = await pdfjsLib.getDocument({ data: pdfData }).promise;
@@ -88,7 +93,7 @@ const _pending = new Map<
8893
{ resolve: (b: Blob | null) => void; reject: (e: Error) => void }
8994
>()
9095

91-
function getWorker(_pdfjsUrl: string): Worker | null {
96+
function getWorker(): Worker | null {
9297
if (_workerFailed) return null
9398
if (_worker) return _worker
9499

@@ -130,10 +135,10 @@ function renderInWorker(
130135
pdfData: Uint8Array,
131136
width: number,
132137
height: number,
133-
pdfjsUrl: string
138+
pdfjsUrls: { library: string; worker: string }
134139
): Promise<Blob | null> {
135140
return new Promise((resolve) => {
136-
const worker = getWorker(pdfjsUrl)
141+
const worker = getWorker()
137142
if (!worker) {
138143
resolve(null)
139144
return
@@ -147,7 +152,17 @@ function renderInWorker(
147152

148153
// Transfer the buffer to avoid copying
149154
const copy = pdfData.slice() // copy so caller retains original
150-
worker.postMessage({ id, pdfData: copy, width, height, pdfjsUrl }, [copy.buffer])
155+
worker.postMessage(
156+
{
157+
id,
158+
pdfData: copy,
159+
width,
160+
height,
161+
pdfjsUrl: pdfjsUrls.library,
162+
pdfjsWorkerUrl: pdfjsUrls.worker,
163+
},
164+
[copy.buffer]
165+
)
151166

152167
// Timeout: if worker doesn't respond in 15s, give up
153168
setTimeout(() => {
@@ -175,14 +190,14 @@ export async function renderPdfToImage(
175190
width: number,
176191
height: number
177192
): Promise<string | null> {
178-
const pdfjsUrl = getPdfjsUrl()
193+
const pdfjsUrls = getPdfjsUrls()
179194

180-
if (!pdfjsUrl || typeof OffscreenCanvas === 'undefined' || typeof Worker === 'undefined') {
195+
if (!pdfjsUrls || typeof OffscreenCanvas === 'undefined' || typeof Worker === 'undefined') {
181196
return null
182197
}
183198

184199
try {
185-
const blob = await renderInWorker(pdfData, width, height, pdfjsUrl)
200+
const blob = await renderInWorker(pdfData, width, height, pdfjsUrls)
186201
if (blob) return URL.createObjectURL(blob)
187202
} catch {
188203
// Worker failed — no fallback, return null

0 commit comments

Comments
 (0)