From 54a19c2ba3edcf5104711e1bb28281024783b44e Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 19 Sep 2026 01:28:09 -0700 Subject: [PATCH] fix(pdf): render CJK text with pdf.js resources Merged after PR-specific checks passed. CI failures are unrelated baseline findings in ClientHostedBrowserPagePane.markup.test.tsx and pane-title-update-global-scan-budget.test.tsx. --- config/build-plugins/pdfjs-viewer-assets.ts | 100 ++++++++++++++++++ .../scripts/project-renderer-web-client.mjs | 14 +++ electron.vite.config.ts | 3 +- .../runtime/rpc/static-web-client-handler.ts | 12 ++- .../src/components/editor/PdfViewer.tsx | 3 +- .../editor/pdf-js-document-options.test.ts | 16 +++ .../editor/pdf-js-document-options.ts | 9 ++ vite.web.config.ts | 3 +- 8 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 config/build-plugins/pdfjs-viewer-assets.ts create mode 100644 src/renderer/src/components/editor/pdf-js-document-options.test.ts create mode 100644 src/renderer/src/components/editor/pdf-js-document-options.ts diff --git a/config/build-plugins/pdfjs-viewer-assets.ts b/config/build-plugins/pdfjs-viewer-assets.ts new file mode 100644 index 00000000000..c212194ec71 --- /dev/null +++ b/config/build-plugins/pdfjs-viewer-assets.ts @@ -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) + } + } +} diff --git a/config/scripts/project-renderer-web-client.mjs b/config/scripts/project-renderer-web-client.mjs index 77516332523..1a5c4490544 100644 --- a/config/scripts/project-renderer-web-client.mjs +++ b/config/scripts/project-renderer-web-client.mjs @@ -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 { diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 2978b0b007c..3cd58f4a25f 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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' }, diff --git a/src/main/runtime/rpc/static-web-client-handler.ts b/src/main/runtime/rpc/static-web-client-handler.ts index 4bfbc1e625e..53ecd85c73f 100644 --- a/src/main/runtime/rpc/static-web-client-handler.ts +++ b/src/main/runtime/rpc/static-web-client-handler.ts @@ -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 } diff --git a/src/renderer/src/components/editor/PdfViewer.tsx b/src/renderer/src/components/editor/PdfViewer.tsx index b4e67316703..8fdd45d2c72 100644 --- a/src/renderer/src/components/editor/PdfViewer.tsx +++ b/src/renderer/src/components/editor/PdfViewer.tsx @@ -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) => { diff --git a/src/renderer/src/components/editor/pdf-js-document-options.test.ts b/src/renderer/src/components/editor/pdf-js-document-options.test.ts new file mode 100644 index 00000000000..4abe7a73eaa --- /dev/null +++ b/src/renderer/src/components/editor/pdf-js-document-options.test.ts @@ -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/' + }) + }) +}) diff --git a/src/renderer/src/components/editor/pdf-js-document-options.ts b/src/renderer/src/components/editor/pdf-js-document-options.ts new file mode 100644 index 00000000000..06a1414474d --- /dev/null +++ b/src/renderer/src/components/editor/pdf-js-document-options.ts @@ -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 + } +} diff --git a/vite.web.config.ts b/vite.web.config.ts index 054db1de800..b6150ac52e7 100644 --- a/vite.web.config.ts +++ b/vite.web.config.ts @@ -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' },