Skip to content

Commit f89a15e

Browse files
authored
fix(frontend): bundle PDF.js wasm decoders (#4382)
1 parent 75e7ee4 commit f89a15e

6 files changed

Lines changed: 101 additions & 2 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export declare function copyPdfJsWasm(sourceDir: string, outputDir: string): void
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { cpSync, existsSync, mkdirSync } from 'node:fs'
2+
import { resolve } from 'node:path'
3+
4+
/** Copy the installed pdfjs-dist decoder resources into the frontend build. */
5+
export function copyPdfJsWasm(sourceDir, outputDir) {
6+
const jbig2Wasm = resolve(sourceDir, 'jbig2.wasm')
7+
if (!existsSync(jbig2Wasm)) {
8+
throw new Error(`PDF.js wasm resources are missing the required decoder: ${jbig2Wasm}`)
9+
}
10+
11+
mkdirSync(outputDir, { recursive: true })
12+
cpSync(sourceDir, outputDir, { recursive: true })
13+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import assert from 'node:assert/strict'
2+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3+
import { join } from 'node:path'
4+
import { tmpdir } from 'node:os'
5+
import test from 'node:test'
6+
import { copyPdfJsWasm } from './copy-pdfjs-wasm.mjs'
7+
8+
const tempRoot = process.env.ISSUEHUNTER_TMP ?? tmpdir()
9+
10+
test('copies all installed PDF.js wasm resources to the build asset directory', (t) => {
11+
const root = mkdtempSync(join(tempRoot, 'pdfjs-wasm-copy-'))
12+
t.after(() => rmSync(root, { recursive: true, force: true }))
13+
const source = join(root, 'package-wasm')
14+
const output = join(root, 'dist', 'pdfjs', 'wasm')
15+
mkdirSync(source)
16+
writeFileSync(join(source, 'jbig2.wasm'), 'jbig2 decoder')
17+
writeFileSync(join(source, 'openjpeg.wasm'), 'openjpeg decoder')
18+
19+
copyPdfJsWasm(source, output)
20+
21+
assert.equal(readFileSync(join(output, 'jbig2.wasm'), 'utf8'), 'jbig2 decoder')
22+
assert.equal(readFileSync(join(output, 'openjpeg.wasm'), 'utf8'), 'openjpeg decoder')
23+
})
24+
25+
test('fails clearly when the installed PDF.js package has no JBIG2 decoder', (t) => {
26+
const root = mkdtempSync(join(tempRoot, 'pdfjs-wasm-missing-'))
27+
t.after(() => rmSync(root, { recursive: true, force: true }))
28+
const source = join(root, 'package-wasm')
29+
const output = join(root, 'dist', 'pdfjs', 'wasm')
30+
mkdirSync(source)
31+
writeFileSync(join(source, 'openjpeg.wasm'), 'openjpeg decoder')
32+
33+
assert.throws(() => copyPdfJsWasm(source, output), /required decoder: .*jbig2\.wasm/)
34+
assert.equal(existsSync(output), false, 'do not leave a seemingly complete output directory')
35+
})

‎frontend/src/components/source-preview/PdfSourceViewer.vue‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,10 @@ async function openDocument(data: ArrayBuffer) {
8989
pdfjs = lib
9090
if (disposed) return
9191
// pdf.js takes ownership of the buffer; keep the caller's copy intact.
92-
const task = lib.getDocument({ data: new Uint8Array(data.slice(0)) })
92+
const task = lib.getDocument({
93+
data: new Uint8Array(data.slice(0)),
94+
wasmUrl: `${import.meta.env.BASE_URL}pdfjs/wasm/`,
95+
})
9396
loadingTask = task
9497
const doc = await task.promise
9598
if (disposed) {
@@ -161,7 +164,12 @@ async function paintPage(
161164
entry.task = task
162165
try {
163166
await task.promise
164-
} catch {
167+
} catch (err) {
168+
if (rendered.get(pageNumber) === entry) {
169+
console.error('PDF page render failed:', err)
170+
error.value = t('preview.loadFailed')
171+
emit('error', error.value)
172+
}
165173
return // cancelled by a newer render or unrender
166174
}
167175
if (rendered.get(pageNumber) !== entry) return
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import assert from 'node:assert/strict'
2+
import { readFileSync } from 'node:fs'
3+
import test from 'node:test'
4+
5+
const viewer = readFileSync(new URL('./PdfSourceViewer.vue', import.meta.url), 'utf8')
6+
const viteConfig = readFileSync(new URL('../../../vite.config.ts', import.meta.url), 'utf8')
7+
const renderCode = viewer.match(/async function paintPage\([\s\S]*?\n}\n\nfunction touchRendered/)?.[0] ?? ''
8+
9+
test('PDF preview resolves the decoder from the configured frontend base path', () => {
10+
assert.match(viewer, /wasmUrl\s*:\s*`\$\{import\.meta\.env\.BASE_URL\}pdfjs\/wasm\//)
11+
})
12+
13+
test('production builds copy the installed pdfjs-dist wasm assets', () => {
14+
assert.match(viteConfig, /copyPdfJsWasm/)
15+
assert.match(viteConfig, /pdfJsWasmAssets/)
16+
assert.match(viteConfig, /require\.resolve\('pdfjs-dist\/legacy\/build\/pdf\.mjs'\)/)
17+
assert.match(viteConfig, /wasmOutput = resolve\(config\.root, config\.build\.outDir, 'pdfjs', 'wasm'\)/)
18+
})
19+
20+
test('page-render failures are reported instead of leaving only a blank canvas', () => {
21+
assert.match(renderCode, /catch \(err\)[\s\S]*error\.value = t\('preview\.loadFailed'\)/)
22+
assert.match(renderCode, /emit\('error', error\.value\)/)
23+
})

‎frontend/vite.config.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { fileURLToPath, URL } from 'node:url'
22
import { resolve, dirname } from 'node:path'
33
import { existsSync } from 'node:fs'
44
import { execSync } from 'node:child_process'
5+
import { copyPdfJsWasm } from './scripts/copy-pdfjs-wasm.mjs'
56
import { createRequire } from 'node:module'
67
import { defineConfig, type Plugin } from 'vite'
78
import vue from '@vitejs/plugin-vue'
@@ -67,6 +68,23 @@ function resolveVueOfficePptxEntry(): string {
6768
}
6869
}
6970

71+
function pdfJsWasmAssets(): Plugin {
72+
let wasmSource = ''
73+
let wasmOutput = ''
74+
return {
75+
name: 'pdfjs-wasm-assets',
76+
apply: 'build',
77+
configResolved(config) {
78+
const pdfjsBuild = require.resolve('pdfjs-dist/legacy/build/pdf.mjs')
79+
wasmSource = resolve(dirname(pdfjsBuild), '../../wasm')
80+
wasmOutput = resolve(config.root, config.build.outDir, 'pdfjs', 'wasm')
81+
},
82+
closeBundle() {
83+
copyPdfJsWasm(wasmSource, wasmOutput)
84+
},
85+
}
86+
}
87+
7088
export default defineConfig({
7189
define: {
7290
__FRONTEND_VERSION__: JSON.stringify(FRONTEND_VERSION),
@@ -120,6 +138,7 @@ export default defineConfig({
120138
vue(),
121139
vueJsx(),
122140
embedHtmlDevFallback(),
141+
pdfJsWasmAssets(),
123142
],
124143
resolve: {
125144
alias: {

0 commit comments

Comments
 (0)