diff --git a/docs/pdf.md b/docs/pdf.md new file mode 100644 index 0000000..c47b3be --- /dev/null +++ b/docs/pdf.md @@ -0,0 +1,26 @@ +# PDF 在线与离线阅读 + +PDF 使用 PDF.js 在 Web Worker 中解析,当前页由 Canvas 和可选择的文本层显示。 +每页对应阅读器的一个固定布局 section,翻页、目录、页码和阅读位置沿用现有机制。 + +## 在线打开 + +1. 优先检查当前服务器、账户下已下载的完整文件缓存。 +2. 没有完整缓存时,仅通过 `HEAD /api/v1/books/:id/content` 获取长度与 Range 能力。 +3. PDF.js 通过自定义 `PDFDataRangeTransport` 按需读取字节区间,块大小为 64 KiB;禁用流式全量下载和自动预取。 +4. 每个 Range 请求走现有客户端 Transport,携带 Authorization,并沿用令牌续期、401 重试、账户切换检查与取消机制。令牌不会写入 PDF URL。 +5. 服务端直接从请求的文件偏移读取,不再读取并丢弃区间前面的字节。 + +64 KiB 是分块粒度,不是首屏流量上限;实际读取量由 PDF 的索引、字体、图片和页面内容布局决定,部分文档仍可能需要读取大量数据。 +代理必须保留 `HEAD`、`Range`、`Content-Length`、`Accept-Ranges` 和 `Content-Range`;不支持 Range 时显示错误,不隐式回退到完整下载。 + +## 离线与资源管理 + +在线阅读不会自动缓存完整 PDF。需要离线时使用现有的整书下载功能;之后从账户隔离的完整文件缓存打开。 +PDF.js worker、CMap、标准字体和 WASM 解码资源随构建打包,由现有 PWA 机制缓存,不依赖外部 CDN。 +切换页面会取消旧的渲染,关闭阅读器会取消请求、释放画布并销毁文档 worker。尺寸变化自动重绘;支持完整页面和适合宽度两种模式。 + +## 验证 + +在 `web` 目录执行 `npm run ui:review:pdf`。脚本使用真实 Chromium 和带大段未引用数据的 8 MiB PDF,禁止不带 Range 的 GET,检查首屏流量、空闲时不预取、鉴权、文本层、页面跳转、尺寸变化及资源释放。 +流量统计只计算 PDF 数据,不含阅读器脚本、worker、字体等静态资源。该构造样本用于验证按需读取机制,不代表真实扫描书的性能基准;尚需在实际书籍和 Android 真机上验收。 diff --git a/server/src/http/app.ts b/server/src/http/app.ts index f93c9f0..182a434 100644 --- a/server/src/http/app.ts +++ b/server/src/http/app.ts @@ -60,8 +60,9 @@ export function buildApp(ctx: AppContext,mediaRuntime?:MediaService): FastifyIns return; } reply.header('access-control-allow-origin', resolveCorsOrigin(ctx.config, request)); - reply.header('access-control-allow-headers', 'authorization, content-type'); - reply.header('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'); + reply.header('access-control-allow-headers', 'authorization, content-type, range'); + reply.header('access-control-allow-methods', 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS'); + reply.header('access-control-expose-headers', 'accept-ranges, content-length, content-range'); // `vary: origin` is required whenever the allowed origin is reflected rather // than fixed, otherwise a shared cache will serve one origin's response to // another. diff --git a/server/src/http/assets.ts b/server/src/http/assets.ts index cc4d125..9ec32ee 100644 --- a/server/src/http/assets.ts +++ b/server/src/http/assets.ts @@ -142,6 +142,10 @@ function sendStream( if (!range) { reply.header('accept-ranges', payload.seekable ? 'bytes' : 'none'); if (total !== undefined) reply.header('content-length', String(total)); + if (request.method === 'HEAD') { + stream.destroy(); + return reply.send(stream); + } return sendWithRelease(request, reply, stream); } @@ -154,6 +158,10 @@ function sendStream( reply.header('accept-ranges', 'bytes'); reply.header('content-range', `bytes ${range.start}-${range.end}/${total}`); reply.header('content-length', String(length)); + if (payload.openRange) { + stream.destroy(); + return sendWithRelease(request, reply, payload.openRange(range.start, range.end)); + } // A *suffix* range is the one that cannot be produced by skipping. The stream // is forward-only, and `start` is `total - suffix`, so surviving it means diff --git a/server/src/http/routes/web.ts b/server/src/http/routes/web.ts index 4f8e27c..f1dac6e 100644 --- a/server/src/http/routes/web.ts +++ b/server/src/http/routes/web.ts @@ -22,6 +22,7 @@ const MIME_BY_EXT: Record = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', + '.wasm': 'application/wasm', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', diff --git a/server/src/indexer/formats/pdf.ts b/server/src/indexer/formats/pdf.ts index 5d7f540..3fea7d8 100644 --- a/server/src/indexer/formats/pdf.ts +++ b/server/src/indexer/formats/pdf.ts @@ -74,6 +74,7 @@ export const pdfHandler = registerFileHandler({ const info = await stat(ctx.absPath); return { stream: createReadStream(ctx.absPath), + openRange: (start, end) => createReadStream(ctx.absPath, { start, end }), contentType: 'application/pdf', filename: ctx.relPath.slice(ctx.relPath.lastIndexOf('/') + 1), size: info.size, diff --git a/server/src/indexer/formats/registry.ts b/server/src/indexer/formats/registry.ts index b789acd..30d2b97 100644 --- a/server/src/indexer/formats/registry.ts +++ b/server/src/indexer/formats/registry.ts @@ -176,6 +176,7 @@ export interface AssetPayload { size?: number; /** Supports HTTP Range requests. Only set this when the bytes are seekable. */ seekable?: boolean; + openRange?: (start: number, end: number) => Readable; /** Trusted validator for the payload, e.g. the book's content hash. */ etag?: string; /** Last-modified time in milliseconds, when the source has one. */ diff --git a/server/src/publications/files.ts b/server/src/publications/files.ts index 8785345..e96ad38 100644 --- a/server/src/publications/files.ts +++ b/server/src/publications/files.ts @@ -56,6 +56,7 @@ export class FilePublications { const info = await stat(context.absPath); return { stream: createReadStream(context.absPath), contentType: contentTypeFor(context.relPath), + openRange: (start, end) => createReadStream(context.absPath, { start, end }), filename: context.relPath.split('/').pop() ?? 'book', size: info.size, seekable: true, etag: bookId, lastModified: info.mtimeMs, }; diff --git a/server/test/api.test.ts b/server/test/api.test.ts index efa0d0c..5602f7f 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -6,6 +6,7 @@ */ import { test, before, after } from 'node:test'; import assert from 'node:assert/strict'; + import { mkdtemp, mkdir, writeFile, rm, readdir } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -1086,3 +1087,15 @@ test('the folder filter is not fooled by LIKE wildcards in a name', async () => const titles = ((res.json() as { items: Array<{ title: string }> }).items).map((item) => item.title); assert.deepEqual(titles, ['百分号']); }); + +test('PDF range preflight permits HEAD and Range and exposes byte metadata to Android', async () => { + const response = await app.inject({ + method: 'OPTIONS', url: '/api/v1/books/pdf/content', + headers: { origin: 'https://appassets.androidplatform.net', 'access-control-request-method': 'HEAD', 'access-control-request-headers': 'authorization,range' }, + }); + assert.equal(response.statusCode, 204); + assert.equal(response.headers['access-control-allow-origin'], 'https://appassets.androidplatform.net'); + assert.match(String(response.headers['access-control-allow-methods']), /HEAD/); + assert.match(String(response.headers['access-control-allow-headers']), /range/); + assert.match(String(response.headers['access-control-expose-headers']), /content-range/); +}); diff --git a/server/test/pdf-range.test.ts b/server/test/pdf-range.test.ts new file mode 100644 index 0000000..a5acd34 --- /dev/null +++ b/server/test/pdf-range.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { createReadStream } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { Readable } from 'node:stream'; +import { test } from 'node:test'; +import Fastify from 'fastify'; +import { sendAssetPayload } from '../src/http/assets.ts'; + +test('PDF metadata and ranges do not read the file prefix or buffer the complete file', async () => { + const root = await mkdtemp(join(tmpdir(), 'reader-pdf-range-')); + const path = join(root, 'book.pdf'); + const bytes = Buffer.alloc(8 * 1024 * 1024, 'x'); + bytes.write('PDF tail', bytes.length - 8); + await writeFile(path, bytes); + const app = Fastify(); + const ranges: Array<[number, number]> = []; + let fullReads = 0; + app.get('/pdf', (request, reply) => sendAssetPayload(request, reply, { + stream: new Readable({ read() { fullReads += 1; this.push(null); } }), + openRange: (start, end) => { ranges.push([start, end]); return createReadStream(path, { start, end }); }, + contentType: 'application/pdf', size: bytes.length, seekable: true, + })); + try { + const head = await app.inject({ method: 'HEAD', url: '/pdf' }); + assert.equal(head.statusCode, 200); + assert.equal(head.headers['content-length'], String(bytes.length)); + assert.equal(head.headers['accept-ranges'], 'bytes'); + assert.equal(head.body, ''); + assert.equal(fullReads, 0); + for (const range of [`bytes=${bytes.length - 8}-${bytes.length - 1}`, 'bytes=-8']) { + const response = await app.inject({ url: '/pdf', headers: { range } }); + assert.equal(response.statusCode, 206); + assert.equal(response.body, 'PDF tail'); + assert.equal(response.headers['content-range'], `bytes ${bytes.length - 8}-${bytes.length - 1}/${bytes.length}`); + assert.equal(response.headers['content-length'], '8'); + } + assert.deepEqual(ranges, Array.from({ length: 2 }, () => [bytes.length - 8, bytes.length - 1])); + const invalid = await app.inject({ url: '/pdf', headers: { range: `bytes=${bytes.length}-` } }); + assert.equal(invalid.statusCode, 416); + assert.equal(invalid.headers['content-range'], `bytes */${bytes.length}`); + assert.equal(fullReads, 0); + assert.equal(ranges.length, 2); + } finally { + await app.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/server/test/web.test.ts b/server/test/web.test.ts index 7d6d20d..aff7992 100644 --- a/server/test/web.test.ts +++ b/server/test/web.test.ts @@ -24,6 +24,7 @@ before(async () => { await mkdir(join(root, 'assets')); await writeFile(join(root, 'assets', 'index-abc123.js'), 'console.log(1)'); await writeFile(join(root, 'assets', 'client.js'), 'console.log(2)'); + await writeFile(join(root, 'assets', 'decoder.wasm'), new Uint8Array([0, 97, 115, 109])); await writeFile(join(root, 'sw.js'), 'self.addEventListener("install", () => {})'); await writeFile(join(root, 'manifest.webmanifest'), '{"name":"reader"}'); // A file one level above the build, to prove traversal is refused. @@ -43,6 +44,12 @@ after(async () => { }); describe('static client hosting', () => { + test('PDF.js WASM decoders are served with the streaming compilation MIME type', async () => { + const response = await app.inject({ url: '/assets/decoder.wasm' }); + assert.equal(response.statusCode, 200); + assert.equal(response.headers['content-type'], 'application/wasm'); + assert.equal(response.rawPayload.byteLength, 4); + }); test('the root serves the client', async () => { const res = await app.inject({ method: 'GET', url: '/' }); assert.equal(res.statusCode, 200); diff --git a/web/package-lock.json b/web/package-lock.json index b1e1333..abd9bc2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16,6 +16,7 @@ "jszip": "^3.10.1", "lucide-react": "^0.468.0", "notyf": "^3.10.0", + "pdfjs-dist": "6.5.438", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.9.4", @@ -1024,6 +1025,256 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.1.0.tgz", + "integrity": "sha512-k+ph/D8H9GS8ZZ48okvHrKeJynkqAA1kMUZRuzRWNlQUDHXngq7oRaWMZBdmSfd9ZhVy9bwNKei/7wMboI/uMg==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.1.0", + "@napi-rs/canvas-darwin-arm64": "1.1.0", + "@napi-rs/canvas-darwin-x64": "1.1.0", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.1.0", + "@napi-rs/canvas-linux-arm64-gnu": "1.1.0", + "@napi-rs/canvas-linux-arm64-musl": "1.1.0", + "@napi-rs/canvas-linux-riscv64-gnu": "1.1.0", + "@napi-rs/canvas-linux-x64-gnu": "1.1.0", + "@napi-rs/canvas-linux-x64-musl": "1.1.0", + "@napi-rs/canvas-win32-arm64-msvc": "1.1.0", + "@napi-rs/canvas-win32-x64-msvc": "1.1.0" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.1.0.tgz", + "integrity": "sha512-mKrSmRyhWK4q699Q1xthbZIZi7JBvLtsRNnOZAeFaLPOFT1yo8y82aRC2nNZbXTGJXU7fVDONgGQPr0vo3kerw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-iqwST4g0oH+x8VCpBIYh+MYokv/mVvkM+v9oJGB0d9A3P2qBh42ogJlVyWq9SpPCrAjS7tplMb1D3OIfxl+utw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.1.0.tgz", + "integrity": "sha512-jkeA8eVTxDum03LTvc32YJ28F6KB1U0gYwnWMfOAUQZvWWEh/kvONby4iWGJx3+Tw45UqMGMXjtw2GKNVZDhVg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.1.0.tgz", + "integrity": "sha512-Y4YkKG1ijoqkgUdTEEBNG4k+2BK5bR5jGL6S56YxCXYPwGfUd5nED8/74xyc7z+bpixPasCjSYoP3xYPOIGNxw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.1.0.tgz", + "integrity": "sha512-dE/XthqYkiWcBVtKFlZbUXwAYfDuQGqDWixznipJNmevLGpprNnTVQZyftm1RZJudkIOn2EkzYZDMMETM08Row==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.1.0.tgz", + "integrity": "sha512-DqZ/CMcHlhU56pPjv9AP0h3U7MFQ5LzC1HzMyRMotL+PFsJmZJA/ziApPYVyyz78DaVLhI8t3So3ztqtijbYpg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.1.0.tgz", + "integrity": "sha512-kJ7m5n2VffxWXM5QGa5lfq8ZN4LdOJEoz3aAJAmIqYvKiZw1gRdXqpucnRXsF6D9nLYiiIlevaAtAclR7c5ilg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.1.0.tgz", + "integrity": "sha512-uQ9JuJlRhQdHyttcMWOiwLkfIqkqaB0YFUVe835LUe12THt2b51HXkTItq0fxWT1jdhUPX2ylWZ1walkJyD6GQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.1.0.tgz", + "integrity": "sha512-has4CPPAkkCac/LZ13VAw5p+MJLdaKVoyrhumN/A7uJd6960xYkJNDZgUebyxiha82AFzXx6RV+QJg5qGG079g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.1.0.tgz", + "integrity": "sha512-MJnktLKNijmXqdjTughHDZ9TDLZ9uUm2hvFXqaOqn0+w7lphXFnft6bpSgQAc9U1exc20UGIBlzGu9+kiCj9Ag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.1.0.tgz", + "integrity": "sha512-08575bFk7iiadxap4h+JxgGut9xDCHAGum6wh0eInN53zEMImkmEHi5MushX2FOmNO7qRNX7BPiDC5ZYBwHeEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -2666,6 +2917,18 @@ "node": ">= 14.16" } }, + "node_modules/pdfjs-dist": { + "version": "6.5.438", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.5.438.tgz", + "integrity": "sha512-vg8Pw1x+AFKy4AMDvhuWl5cOYV8d698UoEANoFFhc79hqXDnZQqnOXoxw3HG907WHOg1JYOvLhP756boddFxpA==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^1.0.13" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/web/package.json b/web/package.json index 4fede4c..49316b6 100644 --- a/web/package.json +++ b/web/package.json @@ -14,6 +14,7 @@ "ui:review": "npm run build && node tools/ui-review/react-app.mjs", "ui:review:only": "node tools/ui-review/react-app.mjs", "ui:review:reader": "node tools/ui-review/run.mjs", + "ui:review:pdf": "npm run build && node tools/ui-review/pdf-layout.mjs", "ui:sources": "npm run build && node --import ../server/node_modules/tsx/dist/loader.mjs tools/ui-review/host-source-flow.mjs" }, "dependencies": { @@ -24,6 +25,7 @@ "jszip": "^3.10.1", "lucide-react": "^0.468.0", "notyf": "^3.10.0", + "pdfjs-dist": "6.5.438", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.9.4", diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 5308f0f..d84997b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -653,6 +653,30 @@ export class ReaderApi { // ---- content ---- + async pdfSource(id: string, options: RequestOptions = {}): Promise { + const generation = this.sessionGeneration; + const path = `/api/v1/books/${encodeURIComponent(id)}/content`; + const metadata = await this.request(path, 'HEAD', undefined, options); + const length = Number(metadata.headers['content-length']); + if (!Number.isSafeInteger(length) || length <= 0 || metadata.headers['accept-ranges'] !== 'bytes') { + throw new Error('PDF 在线阅读需要服务器支持字节范围请求'); + } + return { + length, + readRange: async (begin, end, signal) => { + this.assertSession(generation); + const response = await this.request(path, 'GET', undefined, { + binary: true, signal, headers: { range: `bytes=${begin}-${end - 1}` }, + }); + this.assertSession(generation); + if (response.status !== 206 || response.headers['content-range'] !== `bytes ${begin}-${end - 1}/${length}` || response.bytes?.byteLength !== end - begin) { + throw new Error('服务器返回了无效的 PDF 字节范围'); + } + return response.bytes; + }, + }; + } + /** Authenticated book body. Not cacheable by the service worker: it needs a token. */ async bookBytes(id: string, options: RequestOptions = {}): Promise { const response = await this.request( @@ -834,13 +858,14 @@ export class ReaderApi { path: string, method: string, body: unknown, - options: RequestOptions & { binary?: boolean; stream?: boolean } = {}, + options: RequestOptions & { binary?: boolean; stream?: boolean; headers?: Record } = {}, ): Promise { const generation = this.sessionGeneration; const assertCurrent = (): void => this.assertSession(generation); const attempt = async (token: string | null) => { assertCurrent(); const headers: Record = { accept: options.stream ? 'text/event-stream' : options.binary ? '*/*' : 'application/json' }; + Object.assign(headers, options.headers); const multipart = typeof FormData !== 'undefined' && body instanceof FormData; if (body !== undefined && !multipart) headers['content-type'] = 'application/json'; if (token) headers.authorization = `Bearer ${token}`; diff --git a/web/src/formats/pdf-renderer.ts b/web/src/formats/pdf-renderer.ts new file mode 100644 index 0000000..d1b6890 --- /dev/null +++ b/web/src/formats/pdf-renderer.ts @@ -0,0 +1,146 @@ +import { getDocument, GlobalWorkerOptions, PDFDataRangeTransport, TextLayer } from 'pdfjs-dist'; +import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist'; +import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; +import textLayerStyles from 'pdfjs-dist/web/pdf_viewer.css?inline'; +import type { PdfRenderer, PdfSource } from './types.ts'; + +GlobalWorkerOptions.workerSrc = workerUrl; + +export async function openPdf(source: Uint8Array | PdfSource, signal?: AbortSignal): Promise<{ document: PDFDocumentProxy; renderer: PdfRenderer }> { + signal?.throwIfAborted(); + const controller = new AbortController(); + let failRange: (error: unknown) => void = () => undefined; + const rangeFailure = new Promise((_resolve, reject) => { failRange = reject; }); + class RangeTransport extends PDFDataRangeTransport { + override requestDataRange(begin: number, end: number): void { + if (source instanceof Uint8Array) return; + void source.readRange(begin, end, controller.signal).then(bytes => { + if (!controller.signal.aborted) this.onDataRange(begin, bytes); + }).catch(error => { + if (controller.signal.aborted) return; + failRange(error); + abort(); + }); + } + override abort(): void { controller.abort(); } + } + const assets = new URL(`${import.meta.env.BASE_URL}pdfjs/`, document.baseURI).href; + const task = getDocument({ + ...(source instanceof Uint8Array ? { data: source.slice() } : { range: new RangeTransport(source.length, new Uint8Array(), true) }), + rangeChunkSize: 64 * 1024, + disableStream: true, + disableAutoFetch: true, + cMapUrl: `${assets}cmaps/`, cMapPacked: true, + standardFontDataUrl: `${assets}standard_fonts/`, + wasmUrl: `${assets}wasm/`, enableXfa: false, + }); + const abort = () => { + if (controller.signal.aborted) return; + failRange(signal?.reason ?? new DOMException('PDF loading cancelled', 'AbortError')); + controller.abort(); + void task.destroy().catch(() => undefined); + }; + signal?.addEventListener('abort', abort, { once: true }); + let pdf: PDFDocumentProxy; + try { + pdf = await Promise.race([task.promise, rangeFailure]); + signal?.throwIfAborted(); + } catch (error) { + signal?.removeEventListener('abort', abort); + abort(); + throw error; + } + let generation = 0; + let drawing = 0; + let frame: number | undefined; + let observer: ResizeObserver | undefined; + let renderTask: RenderTask | undefined; + let textLayer: TextLayer | undefined; + let currentPage: PDFPageProxy | undefined; + let canvas: HTMLCanvasElement | undefined; + const cancel = () => { + generation += 1; + drawing += 1; + observer?.disconnect(); + if (frame !== undefined) cancelAnimationFrame(frame); + const previousTask = renderTask; + const previousPage = currentPage; + previousTask?.cancel(); + if (previousTask && previousPage) void previousTask.promise.catch(() => undefined).then(() => previousPage.cleanup()); + textLayer?.cancel(); + if (canvas) { canvas.width = 0; canvas.height = 0; } + currentPage?.cleanup(); + currentPage = undefined; + }; + const renderer: PdfRenderer = { + async render(pageNumber, container, fit) { + cancel(); + const active = generation; + const page = await pdf.getPage(pageNumber); + if (active !== generation || controller.signal.aborted) { page.cleanup(); return; } + currentPage = page; + container.classList.add('pdf-page'); + const style = document.createElement('style'); + style.textContent = textLayerStyles; + const surface = document.createElement('div'); + surface.className = 'pdf-surface'; + container.replaceChildren(style, surface); + const draw = async () => { + const drawId = ++drawing; + const previous = renderTask; + previous?.cancel(); + textLayer?.cancel(); + await previous?.promise.catch(() => undefined); + if (active !== generation || drawId !== drawing) return; + const base = page.getViewport({ scale: 1 }); + const width = Math.max(1, container.clientWidth); + const height = Math.max(1, container.clientHeight); + const scale = fit === 'width' ? width / base.width : Math.min(width / base.width, height / base.height); + const viewport = page.getViewport({ scale }); + const outputScale = Math.min(window.devicePixelRatio || 1, 2, Math.sqrt(16_000_000 / (viewport.width * viewport.height))); + if (canvas) { canvas.width = 0; canvas.height = 0; } + canvas = document.createElement('canvas'); + canvas.setAttribute('aria-label', `第 ${pageNumber} 页`); + canvas.width = Math.ceil(viewport.width * outputScale); + canvas.height = Math.ceil(viewport.height * outputScale); + canvas.style.width = `${viewport.width}px`; + canvas.style.height = `${viewport.height}px`; + surface.style.width = `${viewport.width}px`; + surface.style.height = `${viewport.height}px`; + surface.style.setProperty('--total-scale-factor', String(scale)); + const text = document.createElement('div'); + text.className = 'textLayer'; + surface.replaceChildren(canvas, text); + renderTask = page.render({ canvas, viewport, transform: [outputScale, 0, 0, outputScale, 0, 0] }); + await renderTask.promise; + if (active !== generation || drawId !== drawing) return; + textLayer = new TextLayer({ textContentSource: page.streamTextContent(), container: text, viewport }); + await textLayer.render(); + }; + await draw().catch(error => { + if (active === generation && error?.name !== 'RenderingCancelledException') throw error; + }); + if (active !== generation) return; + let lastSize = `${container.clientWidth}:${container.clientHeight}`; + observer = new ResizeObserver(() => { + const nextSize = `${container.clientWidth}:${container.clientHeight}`; + if (lastSize === nextSize) return; + lastSize = nextSize; + if (frame !== undefined) cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + void draw().catch(error => { + if (active === generation && error?.name !== 'RenderingCancelledException') container.textContent = 'PDF 页面渲染失败,请重新打开'; + }); + }); + }); + observer.observe(container); + }, + cancel, + dispose() { + cancel(); + signal?.removeEventListener('abort', abort); + abort(); + }, + }; + return { document: pdf, renderer }; +} diff --git a/web/src/formats/pdf.ts b/web/src/formats/pdf.ts index 1ea2f20..61dcfa0 100644 --- a/web/src/formats/pdf.ts +++ b/web/src/formats/pdf.ts @@ -1,54 +1,21 @@ -import type { BookDoc, RenderMode } from './types.ts'; +import type { BookDoc, PdfSource, RenderMode } from './types.ts'; -/** - * PDF loader. - * - * Scope is honest: PDF is "保底可读" (product design §10). The browser's own - * viewer is used, in an iframe with the blob URL, because reimplementing text - * selection, search and zoom on a canvas renderer is weeks of work for a format - * the product explicitly does not differentiate on. - * - * What this loader does provide is the *same navigation contract* as every other - * format: a manifest with a content URL and a page count, so the shelf, progress - * reporting and the reading UI do not need a PDF special case. - * - * The one thing worth caring about here is the page count. Reading a PDF without - * a page indicator is disorienting, and the client cannot count pages without - * parsing the file, so the count is read from the server's metadata when the - * book was scanned (`book.pageCount`). When it is missing, the UI degrades to - * percentage-only progress rather than showing a fake page number. - */ -export async function loadPdf(bytes: Uint8Array = new Uint8Array()): Promise { +export async function loadPdf(source: Uint8Array | PdfSource = new Uint8Array(), signal?: AbortSignal): Promise { + const { openPdf } = await import('./pdf-renderer.ts'); + const { document, renderer } = await openPdf(source, signal); + const sections = Array.from({ length: document.numPages }, (_, index) => ({ + id: index === 0 ? 'pdf' : `pdf:${index + 1}`, + label: `第 ${index + 1} 页`, + depth: 0, + })); return { - format: 'pdf', - layout: 'fixed', - render: pdfRenderMode(), - direction: 'ltr', - // A PDF is one document, not a list of sections: the browser viewer owns - // navigation inside it. One section keeps the progress model uniform. - sections: [ - { - id: 'pdf', - label: 'PDF', - html: '', - document: { mediaType: 'application/pdf', bytes }, - depth: 0, - }, - ], - toc: [{ id: 'pdf', label: 'PDF', depth: 0 }], - styles: [], - resources: new Map(), - orderedByBook: true, + format: 'pdf', layout: 'fixed', render: pdfRenderMode(), direction: 'ltr', + sections, + toc: sections.map(({ id, label, depth }) => ({ id, label, depth })), + styles: [], resources: new Map(), orderedByBook: true, pdf: renderer, }; } -/** - * Render mode for a PDF. Always `document`. - * - * A PDF is one document, and both the browser and Android already ship a viewer - * for it that is better than anything this project would write. The host is told - * so rather than being handed bytes to guess from. - */ export function pdfRenderMode(): RenderMode { return 'document'; } diff --git a/web/src/formats/types.ts b/web/src/formats/types.ts index 72d74aa..0beb286 100644 --- a/web/src/formats/types.ts +++ b/web/src/formats/types.ts @@ -113,7 +113,19 @@ export type Layout = 'reflowable' | 'fixed'; */ export type RenderMode = 'reflowable' | 'image' | 'document'; +export interface PdfSource { + length: number; + readRange(begin: number, end: number, signal: AbortSignal): Promise; +} + +export interface PdfRenderer { + render(page: number, container: HTMLElement, fit: 'contain' | 'width'): Promise; + cancel(): void; + dispose(): void; +} + export interface BookDoc { + pdf?: PdfRenderer; format: BookFormat; /** Reflowable pages can be re-paginated; fixed ones are one screen per section. */ layout: Layout; diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index 28959e1..4ad9769 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -448,6 +448,9 @@ book-content { .book-host[data-layout='reflowable'][data-paginated='true'] { touch-action: none; } +.book-host[data-pdf='true'] { + touch-action: pan-y pinch-zoom; +} /* ------------------------------------------------------------------ * * Chrome: header, shelf, panels diff --git a/web/src/ui/reader-screen.tsx b/web/src/ui/reader-screen.tsx index aad14bd..1fa4f40 100644 --- a/web/src/ui/reader-screen.tsx +++ b/web/src/ui/reader-screen.tsx @@ -101,6 +101,7 @@ interface LoadedBook { * that is a function of it. The stage is handed in as children. */ export class ReaderScreen { + private pdfLoading: AbortController | null = null; readonly element: HTMLDivElement; private readonly stage: HTMLDivElement; private readonly ui: Root; @@ -356,6 +357,10 @@ export class ReaderScreen { /** Opens a book: fetch bytes, parse, restore position, start reporting. */ async open(book: Book): Promise { + this.pdfLoading?.abort(); + this.doc?.pdf?.dispose(); + const pdfLoading = new AbortController(); + this.pdfLoading = pdfLoading; this.closeAlternatives(); this.chapterRefresh?.abort(); this.chapterRefresh = null; const token = ++this.loadingToken; this.toolsOpen = false; this.annotation = null; this.toolsReturn = ''; this.toolsSelection = null; this.stopPreview(); @@ -415,11 +420,19 @@ export class ReaderScreen { return; } - const bytes = await this.fetchBookBytes(book, this.manifest); - if (token !== this.loadingToken) return; - - const loaded = await this.loadDoc(book, this.manifest, bytes); - if (token !== this.loadingToken) return; + let loaded: LoadedBook; + if (book.format === 'pdf') { + const cached = await this.publicationCache.resource(book.id, OFFLINE_FILE); + const source = cached?.byteLength ? cached : await this.options.api.pdfSource(book.id, { signal: pdfLoading.signal }); + if (token !== this.loadingToken) return; + const { loadPdf } = await import('../formats/pdf.ts'); + loaded = { doc: await loadPdf(source, pdfLoading.signal), notes: [] }; + } else { + const bytes = await this.fetchBookBytes(book, this.manifest); + if (token !== this.loadingToken) return; + loaded = await this.loadDoc(book, this.manifest, bytes); + } + if (token !== this.loadingToken) { loaded.doc.pdf?.dispose(); return; } this.doc = loaded.doc; opened = true; @@ -557,6 +570,8 @@ export class ReaderScreen { } dispose(): void { + this.pdfLoading?.abort(); + this.doc?.pdf?.dispose(); this.themeOff?.(); this.closeAlternatives(); this.chapterRefresh?.abort(); this.stopPreview(); @@ -1857,7 +1872,7 @@ export class ReaderScreen { this.patch({}); // Fixed-layout fit and direction changes are structural, so the current page // has to be re-rendered rather than merely re-styled. - if (this.doc?.layout === 'fixed' && ('fit' in patch || 'comicDirection' in patch)) { + if (this.doc?.layout === 'fixed' && !this.doc.pdf && ('fit' in patch || 'comicDirection' in patch)) { const index = this.view?.currentSectionIndex() ?? 0; await this.view?.open(index, 0); } diff --git a/web/src/ui/reader-view.ts b/web/src/ui/reader-view.ts index f99f31b..fced2f0 100644 --- a/web/src/ui/reader-view.ts +++ b/web/src/ui/reader-view.ts @@ -238,6 +238,7 @@ export class ReaderView { this.settings = { ...DEFAULT_SETTINGS, direction: options.doc.direction }; this.host = createBookHost(); this.host.setAttribute('data-layout', options.doc.layout); + if (options.doc.pdf) this.host.setAttribute('data-pdf', 'true'); this.host.className = 'book-host'; this.resolver = new ResourceResolver(options.doc.resources); this.container.append(this.host); @@ -469,6 +470,12 @@ export class ReaderView { // A page turn is the only place an animation is wanted, and only the two // settings that change where a page *is* can start one. if (next && previous.mode !== this.settings.mode) this.animatePage('none'); + if (next && previous.fit !== this.settings.fit && this.doc.pdf) { + const section = this.doc.sections[this.sectionIndex]; + if (section) void this.renderFixed(section).catch(() => { + this.host.flow.textContent = 'PDF 页面渲染失败,请重新打开'; + }); + } } /** @@ -815,6 +822,7 @@ export class ReaderView { } dispose(): void { + this.doc.pdf?.cancel(); this.detachSelection?.(); this.detachSelection = null; this.resizeObserver?.disconnect(); this.resizeObserver = null; @@ -1001,6 +1009,16 @@ export class ReaderView { } private async renderFixed(section: Section): Promise { + if (this.doc.pdf) { + this.options.pageHost?.hide(); + const wrapper = document.createElement('div'); + wrapper.className = 'fixed-page'; + wrapper.dataset['fit'] = this.settings.fit; + this.host.setContent('', []); + this.host.flow.replaceChildren(wrapper); + await this.doc.pdf.render(this.sectionIndex + 1, wrapper, this.settings.fit); + return; + } // A native host is offered the page first. It declines for PDF (the platform // viewer is a separate screen, not an embeddable view) and for anything it // cannot decode, and then the DOM path below runs unchanged. diff --git a/web/src/ui/shadow.ts b/web/src/ui/shadow.ts index da1c455..602c70a 100644 --- a/web/src/ui/shadow.ts +++ b/web/src/ui/shadow.ts @@ -305,6 +305,16 @@ const FLOW_STYLESHEET = ` :host([data-paginated='true']) .book-flow > * { break-inside: auto; } +:host([data-layout='fixed']) .book-flow { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + max-inline-size: none; + margin: 0; + padding: 0; + columns: auto; +} /* * The page-turn animations. * @@ -455,6 +465,28 @@ const FLOW_STYLESHEET = ` width: 100%; height: 100%; } +.fixed-page.pdf-page { + overflow: auto; + min-width: 0; + min-height: 0; + touch-action: pan-y pinch-zoom; + color-scheme: light; +} +:host([data-pdf='true']), +:host([data-pdf='true']) .book-flow { + touch-action: pan-y pinch-zoom; +} +.fixed-page.pdf-page[data-fit='width'] { + align-items: start; +} +.pdf-surface { + position: relative; + background: white; + flex: none; +} +.pdf-surface canvas { + display: block; +} @media (prefers-reduced-motion: reduce) { :host([data-animating]) .book-flow { animation: none; } .reader-speech-highlight { transition: none; } diff --git a/web/test/chapter-reader.test.ts b/web/test/chapter-reader.test.ts index 1e59fd9..60ca6c3 100644 --- a/web/test/chapter-reader.test.ts +++ b/web/test/chapter-reader.test.ts @@ -7,12 +7,15 @@ import { ReaderApi } from '../src/api/client.ts'; import { ApiError } from '../src/api/errors.ts'; import type { Book, BookContent, Manifest, Session } from '../src/api/types.ts'; import { OfflineStore } from '../src/store/offline.ts'; -import { PublicationCache, publicationScope } from '../src/store/publications.ts'; +import { OFFLINE_FILE, PublicationCache, publicationScope } from '../src/store/publications.ts'; import { DEFAULT_APP_SETTINGS } from '../src/store/settings.ts'; import { ReaderScreen } from '../src/ui/reader-screen.tsx'; import { App } from '../src/app.ts'; import type { SyncEngine } from '../src/core/sync.ts'; import { FakeTransport, MemoryBlobs, MemoryKv, makePlatform } from './helpers/env.ts'; +import { openPdf } from './helpers/pdf-renderer.ts'; + +vi.mock('../src/formats/pdf-renderer.ts', () => import('./helpers/pdf-renderer.ts')); const book: Book = { id: 'book-1', title: '远程小说', author: '作者', publisher: '', language: 'zh', isbn: '', @@ -101,6 +104,47 @@ function body(screen: ReaderScreen): ShadowRoot | null | undefined { return screen.element.querySelector('book-content')?.shadowRoot; } +describe('PDF reading', () => { + function servePdf(transport: FakeTransport) { + transport.respondWith(request => { + if (request.url.endsWith('/manifest')) return { + status: 200, headers: {}, json: { + book: { ...book, format: 'pdf' }, files: [], contentUrl: '', coverUrl: null, + content: { kind: 'document', total: 1, items: [], groups: [] }, + }, + }; + if (request.method === 'HEAD') return { status: 200, headers: { 'content-length': '1000000', 'accept-ranges': 'bytes' } }; + return { status: 200, headers: {}, json: { progress: null } }; + }); + } + + it('uses a range source rather than downloading or caching the complete file for online reading', async () => { + const env = await setup(); + servePdf(env.transport); + await act(async () => env.screen.open({ ...book, format: 'pdf' })); + expect(env.transport.requests.filter(request => request.url.endsWith('/content')).map(request => request.method)).toEqual(['HEAD']); + expect(openPdf).toHaveBeenLastCalledWith(expect.objectContaining({ length: 1000000, readRange: expect.any(Function) }), expect.any(AbortSignal)); + expect(body(env.screen)?.querySelector('canvas')).not.toBeNull(); + const cache = new PublicationCache(env.platform.kv, env.platform.blobs, publicationScope('http://one.test', 'u1')); + expect(await cache.resource(book.id, OFFLINE_FILE)).toBeNull(); + const document = await openPdf.mock.results.at(-1)!.value; + screens.splice(screens.indexOf(env.screen), 1); + await act(async () => env.screen.dispose()); + expect(document.renderer.dispose).toHaveBeenCalled(); + }); + + it('opens an explicitly downloaded PDF from cache without requesting its network content', async () => { + const env = await setup(); + servePdf(env.transport); + const bytes = new Uint8Array([1, 2, 3]); + const cache = new PublicationCache(env.platform.kv, env.platform.blobs, publicationScope('http://one.test', 'u1')); + await cache.putResource(book.id, OFFLINE_FILE, bytes); + await act(async () => env.screen.open({ ...book, format: 'pdf' })); + expect(openPdf).toHaveBeenLastCalledWith(bytes, expect.any(AbortSignal)); + expect(env.transport.requests.filter(request => request.url.endsWith('/content'))).toHaveLength(0); + }); +}); + async function click(screen: ReaderScreen, label: string): Promise { const button = [...screen.element.querySelectorAll('button')].find((candidate) => candidate.getAttribute('aria-label') === label || candidate.textContent?.trim().endsWith(label)); diff --git a/web/test/formats.test.ts b/web/test/formats.test.ts index 7207081..d1a6e07 100644 --- a/web/test/formats.test.ts +++ b/web/test/formats.test.ts @@ -1,10 +1,12 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { loadBook, detectFormat, extensionOf } from '../src/formats/index.ts'; import { loadTxt } from '../src/formats/txt.ts'; import { rewriteDocument, rewriteCss } from '../src/formats/epub.ts'; import { resolveHref } from '../src/formats/zip.ts'; import { makeCbz, makeEpub, PNG_1X1, utf8 } from './helpers/fixtures.ts'; +vi.mock('../src/formats/pdf-renderer.ts', () => import('./helpers/pdf-renderer.ts')); + const ctx = (bytes: Uint8Array, fileName: string) => ({ bytes, fileName, bookId: 'book-1' }); describe('format detection', () => { @@ -345,12 +347,12 @@ describe('CBZ loading', () => { }); describe('PDF loading', () => { - it('reports a single fixed-layout section so navigation stays uniform', async () => { + it('reports one fixed-layout section per PDF page', async () => { const { doc } = await loadBook(ctx(utf8('%PDF-1.7 fake'), 'doc.pdf')); expect(doc.format).toBe('pdf'); expect(doc.layout).toBe('fixed'); - expect(doc.sections).toHaveLength(1); - expect((doc.sections[0] as { document?: { bytes: Uint8Array } }).document?.bytes).toEqual(utf8('%PDF-1.7 fake')); + expect(doc.sections.map(section => section.id)).toEqual(['pdf', 'pdf:2', 'pdf:3']); + expect(doc.pdf).toBeDefined(); }); }); diff --git a/web/test/helpers/pdf-renderer.ts b/web/test/helpers/pdf-renderer.ts new file mode 100644 index 0000000..7c6f604 --- /dev/null +++ b/web/test/helpers/pdf-renderer.ts @@ -0,0 +1,14 @@ +import { vi } from 'vitest'; + +export const openPdf = vi.fn(async () => ({ + document: { numPages: 3 }, + renderer: { + render: vi.fn(async (page: number, container: HTMLElement) => { + const canvas = document.createElement('canvas'); + canvas.setAttribute('aria-label', `第 ${page} 页`); + container.append(canvas); + }), + cancel: vi.fn(), + dispose: vi.fn(), + }, +})); diff --git a/web/test/native-page.test.ts b/web/test/native-page.test.ts index bcb3d9f..44d595c 100644 --- a/web/test/native-page.test.ts +++ b/web/test/native-page.test.ts @@ -6,6 +6,8 @@ import { androidPageHost, serialisePage } from '../src/core/android-platform.ts' import type { AndroidBridge } from '../src/android-bridge.ts'; import type { BookDoc } from '../src/formats/types.ts'; +vi.mock('../src/formats/pdf-renderer.ts', () => import('./helpers/pdf-renderer.ts')); + /** * The native page path. * @@ -206,15 +208,17 @@ describe('serialisePage', () => { }); describe('androidPageHost', () => { - it('gives the browser a PDF blob instead of an empty fixed page', async () => { + it('renders PDF.js pages in the WebView without delegating to the native viewer', async () => { const { loadPdf } = await import('../src/formats/pdf.ts'); const pdf = await loadPdf(new TextEncoder().encode('%PDF-1.7 fixture')); - const reader = view(pdf); + const show = vi.fn(async () => true); + const reader = view(pdf, createNativePageHost(show, () => undefined)); try { await reader.open(0); - const frame = flowOf(reader).querySelector('iframe'); - expect(frame).not.toBeNull(); - expect(frame?.src).toMatch(/^blob:/); + expect(flowOf(reader).querySelector('canvas')).not.toBeNull(); + expect(flowOf(reader).querySelector('iframe')).toBeNull(); + expect(pdf.pdf?.render).toHaveBeenCalledWith(1, expect.any(HTMLElement), 'contain'); + expect(show).not.toHaveBeenCalled(); } finally { reader.dispose(); } }); function bridge(overrides: Partial = {}): AndroidBridge { diff --git a/web/test/pdf-api.test.ts b/web/test/pdf-api.test.ts new file mode 100644 index 0000000..4e06b5b --- /dev/null +++ b/web/test/pdf-api.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { ReaderApi } from '../src/api/client.ts'; +import { FakeTransport, makePlatform } from './helpers/env.ts'; + +async function setup() { + const transport = new FakeTransport(); + const api = new ReaderApi(makePlatform(transport), { + load: async () => ({ + user: { id: 'reader', username: 'reader', displayName: '', role: 'member', createdAt: 0 }, + accessToken: 'access', refreshToken: 'refresh', accessTokenExpiresAt: 9e15, refreshTokenExpiresAt: 9e15, + }), + save: async () => undefined, clear: async () => undefined, + }); + await api.restore(); + transport.respondWith(request => request.method === 'HEAD' + ? { status: 200, headers: { 'content-length': '1000000', 'accept-ranges': 'bytes' } } + : { status: 206, headers: { 'content-range': 'bytes 100-103/1000000' }, bytes: new Uint8Array([1, 2, 3, 4]) }); + return { api, transport }; +} + +describe('PDF range source', () => { + it('opens with HEAD only, then requests authenticated byte ranges through the transport', async () => { + const { api, transport } = await setup(); + const source = await api.pdfSource('book/id'); + expect(source.length).toBe(1000000); + expect(transport.requests).toHaveLength(1); + expect(transport.requests[0]).toMatchObject({ method: 'HEAD', url: '/api/v1/books/book%2Fid/content' }); + const controller = new AbortController(); + expect(await source.readRange(100, 104, controller.signal)).toEqual(new Uint8Array([1, 2, 3, 4])); + expect(transport.requests[1]).toMatchObject({ + method: 'GET', headers: { range: 'bytes=100-103', authorization: 'Bearer access' }, + }); + }); + + it('renews credentials on later range requests rather than storing a token in the document', async () => { + const { api, transport } = await setup(); + const source = await api.pdfSource('book'); + api.currentSession()!.accessTokenExpiresAt = Date.now() + 1000; + transport.respondWith(request => request.url.endsWith('/refresh') + ? { status: 200, headers: {}, json: { ...api.currentSession(), accessToken: 'renewed', accessTokenExpiresAt: 9e15 } } + : { status: 206, headers: { 'content-range': 'bytes 100-103/1000000' }, bytes: new Uint8Array(4) }); + await source.readRange(100, 104, new AbortController().signal); + expect(transport.requests.at(-1)?.headers.authorization).toBe('Bearer renewed'); + }); + + it.each([200, 206])('rejects incomplete or non-range responses (status=%s)', async status => { + const { api, transport } = await setup(); + const source = await api.pdfSource('book'); + transport.respondWith(() => ({ status, headers: { 'content-range': 'bytes 100-103/1000000' }, bytes: new Uint8Array(3) })); + await expect(source.readRange(100, 104, new AbortController().signal)).rejects.toThrow('无效的 PDF 字节范围'); + }); + + it('rejects servers without Range support without downloading a full file', async () => { + const { api, transport } = await setup(); + transport.respondWith(() => ({ status: 200, headers: { 'content-length': '1000000' } })); + await expect(api.pdfSource('book')).rejects.toThrow('字节范围请求'); + expect(transport.requests).toHaveLength(1); + }); + + it('invalidates an existing source when the server changes', async () => { + const { api, transport } = await setup(); + const source = await api.pdfSource('book'); + api.setBaseUrl('https://another.test'); + await expect(source.readRange(100, 104, new AbortController().signal)).rejects.toMatchObject({ kind: 'aborted' }); + expect(transport.requests).toHaveLength(1); + }); +}); diff --git a/web/test/pdf-renderer.test.ts b/web/test/pdf-renderer.test.ts new file mode 100644 index 0000000..50d7896 --- /dev/null +++ b/web/test/pdf-renderer.test.ts @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PdfSource } from '../src/formats/types.ts'; + +const engine = vi.hoisted(() => ({ getDocument: vi.fn(), getPage: vi.fn(), destroy: vi.fn(), render: vi.fn(), textRender: vi.fn() })); +vi.mock('pdfjs-dist', () => ({ + GlobalWorkerOptions: {}, + getDocument: engine.getDocument, + PDFDataRangeTransport: class { + onDataRange = vi.fn(); + constructor(readonly length: number) {} + }, + TextLayer: class { + render = engine.textRender; + cancel = vi.fn(); + }, +})); + +import { openPdf } from '../src/formats/pdf-renderer.ts'; + +beforeEach(() => { + vi.clearAllMocks(); + globalThis.ResizeObserver = class { observe() {} disconnect() {} unobserve() {} }; + engine.destroy.mockResolvedValue(undefined); + engine.textRender.mockResolvedValue(undefined); + engine.render.mockImplementation(() => ({ promise: Promise.resolve(), cancel: vi.fn() })); + engine.getPage.mockImplementation(async () => ({ + getViewport: ({ scale }: { scale: number }) => ({ width: 600 * scale, height: 800 * scale }), + render: engine.render, streamTextContent: () => ({}), cleanup: vi.fn(), + })); + engine.getDocument.mockReturnValue({ promise: Promise.resolve({ numPages: 3, getPage: engine.getPage }), destroy: engine.destroy }); +}); + +describe('PDF.js rendering', () => { + it('uses range transport without streaming or automatic full-file fetching', async () => { + const source: PdfSource = { length: 1000000, readRange: vi.fn(async () => new Uint8Array(65536)) }; + const { renderer } = await openPdf(source); + const options = engine.getDocument.mock.calls[0]![0]; + expect(options).toMatchObject({ disableAutoFetch: true, disableStream: true, rangeChunkSize: 65536 }); + expect(options.data).toBeUndefined(); + options.range.requestDataRange(65536, 131072); + await vi.waitFor(() => expect(options.range.onDataRange).toHaveBeenCalledWith(65536, expect.any(Uint8Array))); + expect(source.readRange).toHaveBeenCalledWith(65536, 131072, expect.any(AbortSignal)); + const signal = vi.mocked(source.readRange).mock.calls[0]![2]; + renderer.dispose(); + expect(signal.aborted).toBe(true); + expect(engine.destroy).toHaveBeenCalled(); + }); + + it('copies offline bytes before PDF.js can transfer their buffer', async () => { + const bytes = new Uint8Array([1, 2, 3]); + const { renderer } = await openPdf(bytes); + const options = engine.getDocument.mock.calls[0]![0]; + expect(options.data).toEqual(bytes); + expect(options.data).not.toBe(bytes); + expect(options.range).toBeUndefined(); + renderer.dispose(); + }); + + it('renders only the requested page, preserves text selection and releases the canvas on cancellation', async () => { + const { renderer } = await openPdf(new Uint8Array([1])); + const container = document.createElement('div'); + Object.defineProperties(container, { clientWidth: { value: 300 }, clientHeight: { value: 300 } }); + await renderer.render(2, container, 'contain'); + expect(engine.getPage).toHaveBeenCalledExactlyOnceWith(2); + const canvas = container.querySelector('canvas')!; + expect(canvas.style.width).toBe('225px'); + expect(canvas.style.height).toBe('300px'); + expect(container.querySelector('.textLayer')).not.toBeNull(); + expect(engine.textRender).toHaveBeenCalledOnce(); + renderer.cancel(); + expect(canvas.width).toBe(0); + expect(canvas.height).toBe(0); + renderer.dispose(); + }); + + it('fits the width and lets tall pages scroll rather than clipping them', async () => { + const { renderer } = await openPdf(new Uint8Array([1])); + const container = document.createElement('div'); + Object.defineProperties(container, { clientWidth: { value: 300 }, clientHeight: { value: 300 } }); + await renderer.render(1, container, 'width'); + expect(container.querySelector('canvas')?.style.height).toBe('400px'); + renderer.dispose(); + }); + + it('does not overwrite the current page when an old getPage completes late', async () => { + const { renderer } = await openPdf(new Uint8Array([1])); + let finish!: (page: unknown) => void; + engine.getPage.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const oldContainer = document.createElement('div'); + const pending = renderer.render(1, oldContainer, 'contain'); + const current = document.createElement('div'); + await renderer.render(2, current, 'contain'); + finish({ cleanup: vi.fn() }); + await pending; + expect(oldContainer.querySelector('canvas')).toBeNull(); + expect(current.querySelector('canvas')?.getAttribute('aria-label')).toBe('第 2 页'); + renderer.dispose(); + }); + + it('surfaces range errors and destroys the loading worker', async () => { + engine.getDocument.mockReturnValue({ promise: new Promise(() => undefined), destroy: engine.destroy }); + const source: PdfSource = { length: 1000000, readRange: async () => { throw new Error('range failed'); } }; + const opening = openPdf(source); + engine.getDocument.mock.calls[0]![0].range.requestDataRange(0, 65536); + await expect(opening).rejects.toThrow('range failed'); + expect(engine.destroy).toHaveBeenCalled(); + }); + + it('cancels a pending open immediately and destroys its worker only once', async () => { + engine.getDocument.mockReturnValue({ promise: new Promise(() => undefined), destroy: engine.destroy }); + const controller = new AbortController(); + const opening = openPdf(new Uint8Array([1]), controller.signal); + controller.abort(); + await expect(opening).rejects.toMatchObject({ name: 'AbortError' }); + expect(engine.destroy).toHaveBeenCalledOnce(); + }); +}); diff --git a/web/test/shadow.test.ts b/web/test/shadow.test.ts index 4fa4dcc..e7d61a7 100644 --- a/web/test/shadow.test.ts +++ b/web/test/shadow.test.ts @@ -282,6 +282,17 @@ describe('extractInlineStyles', () => { * rather than through a class selector that cannot match. */ describe('BookShadowHost', () => { + it('sizes fixed-layout content to the host instead of the text column or iframe defaults', () => { + const host = createBookHost(); + const sheet = host.shadow.querySelector('style')?.textContent ?? ''; + const fixedSelector = ":host([data-layout='fixed']) .book-flow"; + const fixed = /:host\(\[data-layout='fixed'\]\) \.book-flow \{[^}]*\}/.exec(sheet)?.[0] ?? ''; + for (const declaration of ['width: 100%', 'height: 100%', 'min-width: 0', 'min-height: 0', 'max-inline-size: none', 'margin: 0', 'padding: 0', 'columns: auto']) { + expect(fixed).toContain(declaration); + } + expect(sheet.indexOf(fixedSelector)).toBeGreaterThan(sheet.indexOf(":host([data-paginated='true']) .book-flow > *")); + }); + it('injects the reading column rules into the shadow root', () => { const host = createBookHost(); const sheet = host.shadow.querySelector('style')?.textContent ?? ''; diff --git a/web/tools/pdfjs-assets.ts b/web/tools/pdfjs-assets.ts new file mode 100644 index 0000000..607fd32 --- /dev/null +++ b/web/tools/pdfjs-assets.ts @@ -0,0 +1,31 @@ +import { createReadStream } from 'node:fs'; +import { readdir, readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { createRequire } from 'node:module'; +import type { Plugin } from 'vite'; + +const root = dirname(createRequire(import.meta.url).resolve('pdfjs-dist/package.json')); +const folders = ['cmaps', 'standard_fonts', 'wasm']; + +export function pdfjsAssets(): Plugin { + return { + name: 'reader-pdfjs-assets', + configureServer(server) { + server.middlewares.use('/pdfjs', (request, response, next) => { + const match = /^\/(cmaps|standard_fonts|wasm)\/([\w.-]+)$/.exec(request.url?.split('?')[0] ?? ''); + if (!match) return next(); + response.setHeader('content-type', match[2]?.endsWith('.wasm') ? 'application/wasm' : 'application/octet-stream'); + const stream = createReadStream(join(root, match[1]!, match[2]!)); + stream.on('error', () => { response.statusCode = 404; response.end(); }); + stream.pipe(response); + }); + }, + async generateBundle() { + for (const folder of folders) { + for (const file of await readdir(join(root, folder))) { + this.emitFile({ type: 'asset', fileName: `pdfjs/${folder}/${file}`, source: await readFile(join(root, folder, file)) }); + } + } + }, + }; +} diff --git a/web/tools/ui-review/pdf-fixture.mjs b/web/tools/ui-review/pdf-fixture.mjs new file mode 100644 index 0000000..487a6dc --- /dev/null +++ b/web/tools/ui-review/pdf-fixture.mjs @@ -0,0 +1,25 @@ +export function createPdfFixture(padding = 8 * 1024 * 1024) { + const objects = ['', '', '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>']; + const pages = []; + for (let page = 1; page <= 3; page += 1) { + if (page === 2 && padding) objects.push(`<< /Length ${padding} >>\nstream\n${'x'.repeat(padding)}\nendstream`); + const pageId = objects.length + 1; + pages.push(`${pageId} 0 R`); + objects.push(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents ${pageId + 1} 0 R >>`); + const content = `BT /F1 24 Tf 50 700 Td (PDF page ${page}) Tj ET`; + objects.push(`<< /Length ${content.length} >>\nstream\n${content}\nendstream`); + } + objects[0] = '<< /Type /Catalog /Pages 2 0 R >>'; + objects[1] = `<< /Type /Pages /Kids [${pages.join(' ')}] /Count 3 >>`; + let pdf = '%PDF-1.4\n'; + const offsets = objects.map((object, index) => { + const offset = pdf.length; + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + return offset; + }); + const xref = pdf.length; + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + pdf += offsets.map(offset => `${String(offset).padStart(10, '0')} 00000 n \n`).join(''); + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`; + return Buffer.from(pdf); +} diff --git a/web/tools/ui-review/pdf-layout.mjs b/web/tools/ui-review/pdf-layout.mjs new file mode 100644 index 0000000..a2c7e81 --- /dev/null +++ b/web/tools/ui-review/pdf-layout.mjs @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import { mkdir, readdir } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; +import { createServer, preview } from 'vite'; +import { createPdfFixture } from './pdf-fixture.mjs'; + +const bytes = createPdfFixture(); +const requests = []; +const server = await createServer({ + root: fileURLToPath(new URL('../..', import.meta.url)), + server: { host: '127.0.0.1', port: 0, open: false }, + plugins: [{ + name: 'pdf-review-fixture', + configureServer(vite) { + vite.middlewares.use('/api/v1/books/pdf-review/content', (request, response) => { + requests.push({ method: request.method, range: request.headers.range, authorization: request.headers.authorization }); + if (request.headers.authorization !== 'Bearer pdf-review') { response.statusCode = 401; return response.end(); } + response.setHeader('content-type', 'application/pdf'); + response.setHeader('accept-ranges', 'bytes'); + if (request.method === 'HEAD') { response.setHeader('content-length', bytes.length); return response.end(); } + const match = /^bytes=(\d+)-(\d+)$/.exec(request.headers.range ?? ''); + if (!match) { response.statusCode = 400; return response.end('Full downloads are forbidden in this review'); } + const begin = Number(match[1]); + const end = Math.min(Number(match[2]), bytes.length - 1); + response.statusCode = 206; + response.setHeader('content-range', `bytes ${begin}-${end}/${bytes.length}`); + response.setHeader('content-length', end - begin + 1); + response.end(bytes.subarray(begin, end + 1)); + }); + }, + }], +}); +let browser; +try { + await server.listen(); + browser = await chromium.launch({ headless: true, ...(process.env.CHROME_PATH ? { executablePath: process.env.CHROME_PATH } : {}) }); + const page = await browser.newPage({ viewport: { width: 1244, height: 1944 } }); + const errors = []; + page.on('pageerror', error => errors.push(error.stack)); + await page.goto(server.resolvedUrls.local[0]); + await page.evaluate(async () => { + const { ReaderView } = await import('/src/ui/reader-view.ts'); + const { ReaderApi } = await import('/src/api/client.ts'); + const { FetchTransport } = await import('/src/core/fetch-transport.ts'); + const { loadPdf } = await import('/src/formats/pdf.ts'); + await import('/src/styles/reader.css'); + document.body.replaceChildren(); + const stage = document.createElement('div'); + stage.style.cssText = 'position:fixed;inset:64px 20px 100px'; + document.body.append(stage); + const api = new ReaderApi({ transport: new FetchTransport(() => '') }, { + load: async () => ({ accessToken: 'pdf-review', accessTokenExpiresAt: 9e15 }), + save: async () => undefined, clear: async () => undefined, + }); + await api.restore(); + const doc = await loadPdf(await api.pdfSource('pdf-review')); + window.pdfDoc = doc; + window.pdfReader = new ReaderView({ container: stage, doc }); + await window.pdfReader.open(0); + }); + await page.waitForFunction(() => document.querySelector('book-content').shadowRoot.querySelector('.textLayer')?.textContent.includes('PDF page 1')); + const firstRequests = requests.length; + const transferred = requests.filter(request => request.range).reduce((total, request) => { + const [, begin, end] = /^bytes=(\d+)-(\d+)$/.exec(request.range); + return total + Number(end) - Number(begin) + 1; + }, 0); + assert.ok(transferred < bytes.length / 4, `first page must not download the entire PDF: ${transferred}/${bytes.length}`); + await page.waitForTimeout(300); + assert.equal(requests.length, firstRequests, 'idle reading must not prefetch the entire PDF'); + for (const viewport of [{ width: 1244, height: 1944 }, { width: 390, height: 844 }, { width: 1366, height: 768 }]) { + await page.setViewportSize(viewport); + for (const mode of ['scroll', 'paged']) { + for (const fit of ['contain', 'width']) { + await page.evaluate(({ mode, fit }) => window.pdfReader.applySettings({ mode, fit, pageMargin: 4 }), { mode, fit }); + await page.waitForFunction(() => { + const wrapper = window.pdfReader.elementHost.flow.querySelector('.pdf-page'); + const canvas = wrapper?.querySelector('canvas'); + if (!canvas?.width || !wrapper.querySelector('.textLayer')?.textContent) return false; + const bounds = canvas.getBoundingClientRect(); + return bounds.width <= wrapper.clientWidth + 1 && (wrapper.dataset.fit === 'width' || bounds.height <= wrapper.clientHeight + 1); + }); + const geometry = await page.evaluate(() => { + const host = window.pdfReader.elementHost; + const rect = element => { + const bounds = element.getBoundingClientRect(); + return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; + }; + return { host: rect(host), flow: rect(host.flow), wrapper: rect(host.flow.querySelector('.pdf-page')), columns: getComputedStyle(host.flow).columnCount }; + }); + for (const box of [geometry.flow, geometry.wrapper]) { + for (const dimension of ['x', 'y', 'width', 'height']) assert.ok(Math.abs(box[dimension] - geometry.host[dimension]) < 1, JSON.stringify({ viewport, mode, fit, geometry })); + } + assert.equal(geometry.columns, 'auto'); + } + } + } + await page.evaluate(async () => { window.pdfReader.applySettings({ fit: 'contain' }); await window.pdfReader.open(2); }); + await page.waitForFunction(() => window.pdfReader.elementHost.flow.querySelector('.textLayer')?.textContent.includes('PDF page 3')); + assert.equal(await page.evaluate(() => window.pdfReader.position().sectionId), 'pdf:3'); + await page.evaluate(async () => { await window.pdfReader.openLocator('r1:0.0000:pdf:2'); }); + assert.equal(await page.evaluate(() => window.pdfReader.position().sectionId), 'pdf:2'); + await mkdir(fileURLToPath(new URL('../../../artifacts/ui-review/pdf/', import.meta.url)), { recursive: true }); + await page.screenshot({ path: fileURLToPath(new URL('../../../artifacts/ui-review/pdf/page.png', import.meta.url)) }); + await page.evaluate(() => { window.pdfReader.dispose(); window.pdfDoc.pdf.dispose(); }); + const beforeOffline = requests.length; + await page.evaluate(async offlineBytes => { + const { ReaderView } = await import('/src/ui/reader-view.ts'); + const { loadPdf } = await import('/src/formats/pdf.ts'); + window.pdfDoc = await loadPdf(new Uint8Array(offlineBytes)); + window.pdfReader = new ReaderView({ container: document.body.firstElementChild, doc: window.pdfDoc }); + await window.pdfReader.open(0); + }, Array.from(createPdfFixture(0))); + assert.equal(requests.length, beforeOffline, 'offline PDF bytes must not request network content'); + await page.evaluate(() => { window.pdfReader.dispose(); window.pdfDoc.pdf.dispose(); }); + assert.deepEqual(errors, []); + await page.evaluate(async () => { + const { loadPdf } = await import('/src/formats/pdf.ts'); + const controller = new AbortController(); + let requested; + const started = new Promise(resolve => { requested = resolve; }); + const opening = loadPdf({ + length: 8 * 1024 * 1024, + readRange: (_begin, _end, signal) => new Promise((_resolve, reject) => { + requested(); + signal.addEventListener('abort', () => reject(new Error('cancelled')), { once: true }); + }), + }, controller.signal).then(() => false, () => true); + await started; + controller.abort(); + if (!await opening) throw new Error('Cancelling PDF loading must reject the pending open'); + }); + for (let attempt = 0; attempt < 50 && page.workers().length; attempt += 1) await page.waitForTimeout(100); + assert.equal(page.workers().length, 0, 'closing or cancelling a PDF must release its workers'); + assert.ok(errors.every(error => error.includes('Worker was terminated') && error.includes('ensureNotTerminated')), `Unexpected errors during cancellation: ${JSON.stringify(errors)}`); + console.log(`PASS: first page transferred ${transferred}/${bytes.length} bytes; authenticated Range loading, 12 layouts, text, page jumps, offline bytes, cancellation and worker cleanup`); + const production = await preview({ root: server.config.root, preview: { host: '127.0.0.1', port: 0, open: false } }); + const productionPage = await browser.newPage(); + const productionErrors = []; + productionPage.on('pageerror', error => productionErrors.push(error.message)); + try { + await productionPage.route('**/__pdf-review.html', route => route.fulfill({ contentType: 'text/html', body: '' })); + await productionPage.goto(`${production.resolvedUrls.local[0]}__pdf-review.html`); + const bundle = (await readdir(fileURLToPath(new URL('../../dist/assets/', import.meta.url)))).find(file => /^pdf-renderer-.*\.js$/.test(file)); + assert.ok(bundle, 'the PDF.js engine must be split out of the main application bundle'); + await productionPage.evaluate(async ({ bundle, offlineBytes }) => { + const { openPdf } = await import(`/assets/${bundle}`); + const { renderer } = await openPdf(new Uint8Array(offlineBytes)); + const container = document.createElement('div'); + container.style.cssText = 'position:fixed;inset:0'; + document.body.append(container); + await renderer.render(3, container, 'contain'); + if (!container.querySelector('canvas')?.width || !container.querySelector('.textLayer')?.textContent.includes('PDF page 3')) throw new Error('The production PDF.js bundle must render canvas and text'); + renderer.dispose(); + }, { bundle, offlineBytes: Array.from(createPdfFixture(0)) }); + for (let attempt = 0; attempt < 50 && productionPage.workers().length; attempt += 1) await productionPage.waitForTimeout(100); + assert.equal(productionPage.workers().length, 0); + assert.deepEqual(productionErrors, []); + console.log('PASS: production PDF.js bundle, fingerprinted worker and bundled font resources render without a CDN'); + } finally { + await productionPage.close(); + await new Promise(resolve => production.httpServer.close(resolve)); + } +} finally { + await browser?.close(); + await server.close(); +} diff --git a/web/vite.config.ts b/web/vite.config.ts index ac1c631..e477276 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; import { pwa } from './tools/pwa/build.ts'; +import { pdfjsAssets } from './tools/pdfjs-assets.ts'; // The build output is consumed three ways: // - served directly by the server for the browser/H5 build @@ -14,7 +15,7 @@ import { pwa } from './tools/pwa/build.ts'; // split into content-hashed chunks: every host fetches them like any sibling file, // and the server caches the hashed names forever. export default defineConfig({ - plugins: [react(), pwa()], + plugins: [react(), pdfjsAssets(), pwa()], base: './', build: { outDir: 'dist', @@ -27,7 +28,7 @@ export default defineConfig({ entryFileNames: 'assets/client.js', chunkFileNames: 'assets/[name]-[hash].js', // With cssCodeSplit off Vite names the one stylesheet style.css; keep index.css. - assetFileNames: asset => asset.names?.[0] === 'style.css' ? 'assets/index.css' : 'assets/[name][extname]', + assetFileNames: asset => asset.names?.[0] === 'style.css' ? 'assets/index.css' : asset.names?.[0]?.startsWith('pdf.worker') ? 'assets/[name]-[hash].js' : 'assets/[name][extname]', }, }, modulePreload: { polyfill: false },