fix(pdf): load resources required for CJK rendering

Co-authored-by: innocarpe <2222333+innocarpe@users.noreply.github.com>
This commit is contained in:
m4air
2026-09-19 01:06:22 -07:00
co-authored by innocarpe
parent 57fdf68ab3
commit 2ef366dead
8 changed files with 152 additions and 8 deletions
+100
View File
@@ -0,0 +1,100 @@
import { createReadStream, cpSync, existsSync, mkdirSync, statSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path'
import type { Plugin } from 'vite'
export const PDFJS_VIEWER_ASSET_DIRS = ['cmaps', 'standard_fonts', 'wasm'] as const
function isAssetDirectory(
value: string | undefined
): value is (typeof PDFJS_VIEWER_ASSET_DIRS)[number] {
return value !== undefined && PDFJS_VIEWER_ASSET_DIRS.some((directory) => directory === value)
}
function pdfjsRoot(): string {
return dirname(createRequire(import.meta.url).resolve('pdfjs-dist/package.json'))
}
function assetPath(root: string, pathname: string): string | undefined {
let decoded: string
try {
decoded = decodeURIComponent(pathname)
} catch {
return undefined
}
if (decoded.includes('\0') || decoded.includes('\\')) {
return undefined
}
const parts = decoded.split('/').filter(Boolean)
const directory = parts[0]
if (parts.length < 2 || !isAssetDirectory(directory)) {
return undefined
}
if (parts.some((part) => part === '.' || part === '..' || part.includes('\\'))) {
return undefined
}
const candidate = resolve(root, ...parts)
const base = resolve(root, directory)
const withinBase = relative(base, candidate)
if (withinBase.length === 0 || withinBase.startsWith('..') || isAbsolute(withinBase)) {
return undefined
}
return candidate
}
function copyAssets(root: string, outputDir: string): void {
for (const directory of PDFJS_VIEWER_ASSET_DIRS) {
const source = join(root, directory)
if (!existsSync(source)) {
throw new Error(`[pdfjs-viewer-assets] missing ${source}`)
}
cpSync(source, join(outputDir, directory), { recursive: true })
}
}
export function createPdfjsViewerAssetsPlugin(root = pdfjsRoot()): Plugin {
return {
name: 'pdfjs-viewer-assets',
configureServer(server) {
server.middlewares.use((request, response, next) => {
let pathname: string
try {
pathname = new URL(request.url ?? '/', 'http://localhost').pathname
} catch {
next()
return
}
const filePath = assetPath(root, pathname)
if (!filePath || (request.method !== 'GET' && request.method !== 'HEAD')) {
next()
return
}
let size: number
try {
size = statSync(filePath).size
} catch {
next()
return
}
response.statusCode = 200
response.setHeader('Content-Length', size)
response.setHeader(
'Content-Type',
extname(filePath) === '.wasm' ? 'application/wasm' : 'application/octet-stream'
)
if (request.method === 'HEAD') {
response.end()
return
}
createReadStream(filePath).pipe(response)
})
},
writeBundle(options) {
if (!options.dir) {
throw new Error('[pdfjs-viewer-assets] output directory is required')
}
mkdirSync(options.dir, { recursive: true })
copyAssets(root, options.dir)
}
}
}
@@ -1,5 +1,6 @@
import {
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
@@ -18,6 +19,7 @@ const manifestPath = join(rendererOutput, '.vite', 'manifest.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
const selectedFiles = new Set(['web-index.html'])
const visitedEntries = new Set()
const PDFJS_VIEWER_ASSET_DIRS = ['cmaps', 'standard_fonts', 'wasm']
function assertEntryIsolation() {
const entryKeys = new Set(
@@ -119,6 +121,17 @@ function includeReferencedOutputs() {
}
}
function includePdfjsViewerAssets() {
for (const directory of PDFJS_VIEWER_ASSET_DIRS) {
const root = join(rendererOutput, directory)
if (existsSync(root)) {
for (const outputPath of listOutputFiles(root, directory)) {
addOutputPath(outputPath)
}
}
}
}
async function minifyWebOutput() {
await Promise.all(
[...selectedFiles]
@@ -140,6 +153,7 @@ async function minifyWebOutput() {
assertEntryIsolation()
visitManifestEntry('web-index.html')
includeReferencedOutputs()
includePdfjsViewerAssets()
rmSync(stagingOutput, { force: true, recursive: true })
try {
+2 -1
View File
@@ -4,6 +4,7 @@ import { defineConfig, type UserConfig } from 'electron-vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { createBootstrapFatalExitBanner } from './config/build-plugins/bootstrap-fatal-exit-banner'
import { createPdfjsViewerAssetsPlugin } from './config/build-plugins/pdfjs-viewer-assets'
import { createPlainNodeEntryGuardPlugin } from './config/build-plugins/plain-node-entry-guard'
import packageJson from './package.json' with { type: 'json' }
@@ -307,7 +308,7 @@ export const electronViteConfig: UserConfig = {
'@': resolve('src/renderer/src')
}
},
plugins: [react(), tailwindcss()],
plugins: [react(), tailwindcss(), createPdfjsViewerAssetsPlugin()],
worker: {
format: 'es'
},
@@ -4,7 +4,7 @@ import type { IncomingMessage, RequestListener, ServerResponse } from 'node:http
import { extname, isAbsolute, posix, relative, resolve } from 'node:path'
const STATIC_WEB_ALLOWED_PATHS = new Set(['/web-index.html'])
const STATIC_WEB_ALLOWED_PREFIXES = ['/assets/']
const STATIC_WEB_ALLOWED_PREFIXES = ['/assets/', '/cmaps/', '/standard_fonts/', '/wasm/']
const STATIC_WEB_CONTENT_TYPES = new Map([
['.css', 'text/css; charset=utf-8'],
['.html', 'text/html; charset=utf-8'],
@@ -116,12 +116,14 @@ function mapProxyPrefixedStaticPathname(pathname: string): string {
if (pathname === '/web-index.html' || pathname.endsWith('/web-index.html')) {
return '/web-index.html'
}
const assetMarker = '/assets/'
const assetIndex = pathname.indexOf(assetMarker)
if (assetIndex !== -1) {
const prefixIndex = STATIC_WEB_ALLOWED_PREFIXES.reduce(
(deepest, prefix) => Math.max(deepest, pathname.indexOf(prefix)),
-1
)
if (prefixIndex !== -1) {
// Why: reverse proxies may forward the external path prefix through to
// Orca. Only the bundled /assets subtree is served after the prefix.
return pathname.slice(assetIndex)
return pathname.slice(prefixIndex)
}
return pathname
}
@@ -17,6 +17,7 @@ import { keybindingMatchesAction } from '../../../../shared/keybindings'
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
import { translate } from '@/i18n/i18n'
import { buildPdfJsDocumentOptions } from './pdf-js-document-options'
import {
applyPdfScalePreference,
stepPdfScalePreference,
@@ -240,7 +241,7 @@ export default function PdfViewer({
// input listener above can see.
eventBus.on('find', markUserMoved)
const loadingTask = pdfjsLib.getDocument({ data: bytes })
const loadingTask = pdfjsLib.getDocument(buildPdfJsDocumentOptions(bytes, document.baseURI))
loadingTask.promise
.then((doc) => {
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { buildPdfJsDocumentOptions } from './pdf-js-document-options'
describe('pdf.js document resource options', () => {
it('resolves CMaps, standard fonts, and WASM beside the document', () => {
expect(
buildPdfJsDocumentOptions(new Uint8Array([1]), 'https://orca.test/web-index.html')
).toEqual({
data: new Uint8Array([1]),
cMapUrl: 'https://orca.test/cmaps/',
cMapPacked: true,
standardFontDataUrl: 'https://orca.test/standard_fonts/',
wasmUrl: 'https://orca.test/wasm/'
})
})
})
@@ -0,0 +1,9 @@
export function buildPdfJsDocumentOptions(data: Uint8Array, baseUrl: string) {
return {
data,
cMapUrl: new URL('cmaps/', baseUrl).href,
cMapPacked: true,
standardFontDataUrl: new URL('standard_fonts/', baseUrl).href,
wasmUrl: new URL('wasm/', baseUrl).href
}
}
+2 -1
View File
@@ -2,13 +2,14 @@ import { resolve } from 'node:path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { createPdfjsViewerAssetsPlugin } from './config/build-plugins/pdfjs-viewer-assets'
export default defineConfig({
root: resolve('src/renderer'),
// Why: pairing URLs may live under a reverse-proxy path prefix like
// /orca/web-index.html, so built assets must resolve relative to the page.
base: './',
plugins: [react(), tailwindcss()],
plugins: [react(), tailwindcss(), createPdfjsViewerAssetsPlugin()],
define: {
ORCA_FEATURE_WALL_ENABLED: 'true'
},