mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 21:11:08 +08:00
perf(pdf): render with PDF.js and authenticated range loading
This commit is contained in:
1 parent
813016d160
commit
7e66582f84
31 files changed
+1142
-67
No files matched your search
+26
@@ -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 真机上验收。
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,6 +22,7 @@ const MIME_BY_EXT: Record<string, string> = {
|
||||
'.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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Generated
+263
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+26
-1
@@ -653,6 +653,30 @@ export class ReaderApi {
|
||||
|
||||
// ---- content ----
|
||||
|
||||
async pdfSource(id: string, options: RequestOptions = {}): Promise<import('../formats/types.ts').PdfSource> {
|
||||
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<Uint8Array> {
|
||||
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<string, string> } = {},
|
||||
): Promise<import('../core/platform.ts').HttpResponse> {
|
||||
const generation = this.sessionGeneration;
|
||||
const assertCurrent = (): void => this.assertSession(generation);
|
||||
const attempt = async (token: string | null) => {
|
||||
assertCurrent();
|
||||
const headers: Record<string, string> = { 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}`;
|
||||
|
||||
@@ -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<never>((_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 };
|
||||
}
|
||||
+13
-46
@@ -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<BookDoc> {
|
||||
export async function loadPdf(source: Uint8Array | PdfSource = new Uint8Array(), signal?: AbortSignal): Promise<BookDoc> {
|
||||
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';
|
||||
}
|
||||
@@ -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<Uint8Array>;
|
||||
}
|
||||
|
||||
export interface PdfRenderer {
|
||||
render(page: number, container: HTMLElement, fit: 'contain' | 'width'): Promise<void>;
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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.
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<void> {
|
||||
const button = [...screen.element.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.getAttribute('aria-label') === label || candidate.textContent?.trim().endsWith(label));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}));
|
||||
@@ -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> = {}): AndroidBridge {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 ?? '';
|
||||
|
||||
@@ -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)) });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: '<html><body></body></html>' }));
|
||||
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();
|
||||
}
|
||||
+3
-2
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user