pdf-h5-react 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +430 -2
  3. package/dist/components/AnnotationLayer.d.ts +26 -0
  4. package/dist/components/LinkLayer.d.ts +17 -0
  5. package/dist/components/OutlinePanel.d.ts +8 -0
  6. package/dist/components/PageCanvas.d.ts +41 -0
  7. package/dist/components/PageThumbnail.d.ts +23 -0
  8. package/dist/components/PdfH5Reader.d.ts +101 -0
  9. package/dist/components/SearchPanel.d.ts +18 -0
  10. package/dist/components/StatusOverlay.d.ts +15 -0
  11. package/dist/components/TextLayer.d.ts +26 -0
  12. package/dist/components/ThumbnailPanel.d.ts +18 -0
  13. package/dist/components/Toolbar.d.ts +36 -0
  14. package/dist/core/annotations.d.ts +91 -0
  15. package/dist/core/cache.d.ts +96 -0
  16. package/dist/core/errors.d.ts +12 -0
  17. package/dist/core/eventEmitter.d.ts +43 -0
  18. package/dist/core/gestures.d.ts +71 -0
  19. package/dist/core/layout.d.ts +109 -0
  20. package/dist/core/layoutAdapter.d.ts +36 -0
  21. package/dist/core/lru.d.ts +57 -0
  22. package/dist/core/metrics.d.ts +22 -0
  23. package/dist/core/pdfjs.d.ts +51 -0
  24. package/dist/core/readerEvents.d.ts +116 -0
  25. package/dist/core/renderQueue.d.ts +45 -0
  26. package/dist/core/throttle.d.ts +40 -0
  27. package/dist/core/zoomAnimation.d.ts +33 -0
  28. package/dist/hooks/useElementSize.d.ts +18 -0
  29. package/dist/hooks/usePageMetrics.d.ts +27 -0
  30. package/dist/hooks/usePdfDocument.d.ts +19 -0
  31. package/dist/hooks/usePinchGesture.d.ts +41 -0
  32. package/dist/hooks/useVirtualPages.d.ts +48 -0
  33. package/dist/index.d.ts +46 -0
  34. package/dist/index.js +3226 -0
  35. package/dist/lib/highlight.d.ts +9 -0
  36. package/dist/lib/outline.d.ts +35 -0
  37. package/dist/lib/search.d.ts +38 -0
  38. package/dist/lib/text.d.ts +40 -0
  39. package/dist/lib/thumbnail.d.ts +18 -0
  40. package/dist/pdf-h5-react.css +1 -0
  41. package/dist/types.d.ts +145 -0
  42. package/package.json +84 -4
  43. package/scripts/copy-pdfjs-assets.mjs +100 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 superjunjie
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,431 @@
1
- # Temporary Holding Version
1
+ # pdf-h5-react
2
+
3
+ 基于 [pdf.js](https://mozilla.github.io/pdf.js/) 的**移动端优先** React PDF 阅读器组件。
4
+ 面向 H5 场景(微信内置浏览器、iOS Safari、Android Chrome),开箱即用,同时把性能和内存控制做在架构里。
5
+
6
+ [![npm](https://img.shields.io/npm/v/pdf-h5-react.svg)](https://www.npmjs.com/package/pdf-h5-react)
7
+ [![npm downloads](https://img.shields.io/npm/dm/pdf-h5-react.svg)](https://www.npmjs.com/package/pdf-h5-react)
8
+ [![Release](https://github.com/superjunjie/pdf-h5-react/actions/workflows/release.yml/badge.svg)](https://github.com/superjunjie/pdf-h5-react/actions/workflows/release.yml)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
10
+
11
+
12
+ ```
13
+ 连续滚动 / 翻页吸附 · 捏合缩放 · 双击缩放 · 文本选中 · 全文搜索
14
+ 缩略图 · 多级目录 · 内链跳转 · 密码文档 · 深浅色主题 · 旋转页 · 混尺寸页面
15
+ 高亮批注 · 文字批注 · 手写笔迹 · 事件系统 · 缩放动画
16
+ ```
17
+
18
+ - 组件库本体 **81KB**(gzip 23KB),样式 12KB;`pdfjs-dist` 作为 peerDependency 由使用方安装。
19
+ - 300 页文档实测只挂载 **2–9 个页面 DOM**,位图缓存严格受字节上限约束。
20
+ - 端到端验证脚本用无头 Chrome 跑真实浏览器,**21 项断言全部通过**(见 [验证](#验证))。
21
+
22
+ ## 效果
23
+
24
+ 以下截图均由 `npm run verify` 在无头 Chrome 中真实渲染后截取(移动端 390×844 @2x):
25
+
26
+ | 连续滚动(中文) | 混尺寸页面(横向 A4 + 窄页) |
27
+ | --- | --- |
28
+ | ![连续滚动](docs/screenshots/mobile-scroll.png) | ![混尺寸](docs/screenshots/mobile-mixed-size.png) |
29
+
30
+ | 多级目录 | 全文搜索 |
31
+ | --- | --- |
32
+ | ![目录](docs/screenshots/mobile-outline.png) | ![搜索](docs/screenshots/mobile-search.png) |
33
+
34
+ | 页面缩略图 | 翻页模式(scroll-snap) |
35
+ | --- | --- |
36
+ | ![缩略图](docs/screenshots/mobile-thumbnails.png) | ![翻页](docs/screenshots/mobile-page-mode.png) |
37
+
38
+ | 300 页大文档 | 桌面端 |
39
+ | --- | --- |
40
+ | ![大文档](docs/screenshots/mobile-large.png) | ![桌面](docs/screenshots/desktop.png) |
41
+
42
+ 批注(高亮 + 跨行高亮 + 文字批注 + 手写),缩放 200% 后仍精确贴合原文:
43
+
44
+ | 批注总览 | 放大 200% |
45
+ | --- | --- |
46
+ | ![批注](docs/screenshots/annotations.png) | ![批注缩放](docs/screenshots/annotations-zoom.png) |
47
+
48
+
49
+ ---
50
+
51
+ ## 安装
52
+
53
+ ```bash
54
+ npm i pdf-h5-react pdfjs-dist
55
+ ```
56
+
57
+ `react` / `react-dom` 是 peer 依赖,宿主项目已有则不会重复安装。
58
+ 包发布在 npm 公共源:[npmjs.com/package/pdf-h5-react](https://www.npmjs.com/package/pdf-h5-react)
59
+ ### 生产环境:自托管 pdf.js 资源
60
+
61
+ 库默认从 jsDelivr 加载与依赖版本严格一致的 worker / cmaps / 标准字体 / wasm,**零配置即可运行**。
62
+ 内网、离线或对 CDN 有顾虑时,把资源拷到自己的静态目录:
63
+
64
+ ```bash
65
+ # 在依赖了本包的项目里执行(脚本会从 node_modules 解析 pdfjs-dist 的实际版本)
66
+ node node_modules/pdf-h5-react/scripts/copy-pdfjs-assets.mjs public/pdfjs
67
+ ```
68
+
69
+ ```ts
70
+ import { configurePdfjs } from 'pdf-h5-react';
71
+
72
+ configurePdfjs({
73
+ workerSrc: '/pdfjs/build/pdf.worker.min.mjs',
74
+ cMapUrl: '/pdfjs/cmaps/',
75
+ standardFontDataUrl: '/pdfjs/standard_fonts/',
76
+ wasmUrl: '/pdfjs/wasm/',
77
+ iccUrl: '/pdfjs/iccs/',
78
+ });
79
+ ```
80
+
81
+ > `pdfjs` 的 worker 版本必须与 `pdfjs-dist` 一致,否则会抛 `API version does not match Worker version`。
82
+ > 仓库里的 `scripts/copy-pdfjs-assets.mjs` 会按 `node_modules` 里的实际版本拷贝,并在目录下写 `VERSION` 文件。
83
+
84
+ ---
85
+
86
+ ## 快速开始
87
+
88
+ ```tsx
89
+ import { PdfH5Reader } from 'pdf-h5-react';
90
+ import 'pdf-h5-react/style.css';
91
+
92
+ export default function App() {
93
+ return (
94
+ <div style={{ height: '100dvh' }}>
95
+ <PdfH5Reader src="/files/report.pdf" />
96
+ </div>
97
+ );
98
+ }
99
+ ```
100
+
101
+ 容器必须有确定高度(`height` 不能是 auto),阅读器会填满父容器。
102
+
103
+
104
+ ---
105
+
106
+ ## 核心设计
107
+
108
+ 移动端 PDF 阅读的三个真实瓶颈是:**DOM 数量**、**位图内存**、**高频事件**。对应的三层结构与三个机制:
109
+
110
+ ```
111
+ usePdfDocument 文档层 加载 / 密码 / 进度(进度事件节流 120ms)
112
+ ↓
113
+ usePageMetrics 度量层 首屏并发解析 N 页真实尺寸,其余页渐进解析(rAF 批量校正)
114
+ ↓
115
+ useVirtualPages 视图层 只挂载可视区内的页面 DOM
116
+ ↓
117
+ RenderQueue 渲染层 可见/预取双并发 + 抢占 + 引用计数取消
118
+ ```
119
+
120
+ ### 1. 懒加载与性能优化
121
+
122
+ | 手段 | 实现位置 | 效果(实测) |
123
+ | --- | --- | --- |
124
+ | 页面 DOM 虚拟化 | `hooks/useVirtualPages.ts` | 300 页文档只挂载 2–9 页 |
125
+ | 渐进度量 | `hooks/usePageMetrics.ts` | 首屏只解析 6 页尺寸,其余用均值占位,滚动时按需解析 |
126
+ | 渲染档位量化 | `core/layout.ts` `quantizeScale` | 缩放只升不降、命中有限档位,捏合时不重渲染 |
127
+ | canvas 限幅 | `core/layout.ts` `clampCanvasSize` | 规避 iOS 约 1670 万像素上限导致的空白画布 |
128
+ | 渲染队列双并发 | `core/renderQueue.ts` | 可见页优先(2 并发),预取页低优先(1 并发) |
129
+ | 抢占式丢弃 | `RenderQueue.dropPendingPrefetch` | 快速滚动时丢弃无意义的预取任务 |
130
+ | 缩略图按区间渲染 | `components/ThumbnailPanel.tsx` | 300 页缩略图只渲染可见行区间 |
131
+
132
+ ### 2. LRU 缓存
133
+
134
+ 四类缓存独立计量,全部按**字节/条目双上限**淘汰(`core/lru.ts`、`core/cache.ts`):
135
+
136
+ | 缓存 | 默认上限 | key | 说明 |
137
+ | --- | --- | --- | --- |
138
+ | 位图 | 96MB | `page@scaleBucket` | 离屏 canvas,缩放来回命中;淘汰时把 canvas 尺寸置 0 尽快归还 GPU 内存 |
139
+ | 文本层 | 24 页 / 24MB | `page` | 已构建的 TextLayer DOM,缩放走 `update()` 重排而非重建 |
140
+ | 页面代理 | 64 页 | `page` | `PDFPageProxy`,并发请求合并为同一个 Promise |
141
+ | 缩略图 | 120 张 | `page` | JPEG dataURL,单张 3–8KB |
142
+
143
+ 两个容易踩的坑,实现里都处理了:
144
+
145
+ - **唯一超大对象不立刻淘汰**:否则「渲染完就被淘汰 → 下一帧重渲染」会形成抖动。
146
+ - **显示 canvas 必须每页独占**:缓存里是共享的离屏位图,而 DOM 节点只有一个父节点;直接把缓存 canvas 挂进页面,后挂载的页会把前面页的 canvas 抢走,表现为空白。因此渲染结果用 `drawImage` 拷到本页 canvas。
147
+
148
+ ### 3. 高频操作节流
149
+
150
+ `core/throttle.ts` 提供三个原语,全部可通过 `getCacheStats().throttle` 观测:
151
+
152
+ | 原语 | 用在 | 说明 |
153
+ | --- | --- | --- |
154
+ | `rafThrottle` | 滚动 → 当前页、缩略图区间 | 每帧最多一次,实测 61 次事件 → 1 次处理 |
155
+ | `throttle` | 加载进度、日志上报 | 固定时间窗,首尾可配 |
156
+ | `debounce` | 搜索输入(260ms)、重排定位(120ms) | 静默期结束才执行 |
157
+ | `rafBatch` | 多页尺寸批量校正 | 一帧内合并多个 setState,避免连续重排 |
158
+
159
+ 捏合缩放走的是**另一条路径**:手势进行中只设置一个 CSS `transform` 做预演(只触发合成,不触发布局),松手后才一次性提交真实 `zoom` + `scroll`。
160
+ 因为内容(页面、间距、内边距)对 `zoom` 严格线性,预演与提交在数学上等价,松手不会跳变 —— 这一点由 `src/core/gestures.test.ts` 断言。
161
+
162
+ ---
163
+
164
+ ## API
165
+
166
+ ### `<PdfH5Reader />`
167
+
168
+ | Prop | 类型 | 默认 | 说明 |
169
+ | --- | --- | --- | --- |
170
+ | `src` | `PdfSource` | — | `string \| URL \| ArrayBuffer \| Uint8Array \| { url, data, password, httpHeaders, withCredentials }` |
171
+ | `mode` | `'scroll' \| 'page'` | `'scroll'` | 连续滚动 / 横向翻页(`scroll-snap`,由合成线程处理,比 JS 修正更跟手) |
172
+ | `fitMode` | `'width' \| 'page' \| 'none'` | `'width'` | 适配方式 |
173
+ | `initialPage` / `initialZoom` | `number` | `1` / `1` | 初始页与初始倍率 |
174
+ | `minZoom` / `maxZoom` / `doubleTapZoom` | `number` | `0.5` / `5` / `2.5` | 缩放边界与双击目标 |
175
+ | `animationDuration` | `number` | `300` | 双击 / 按钮缩放的动画时长(ms),`0` 表示立即跳变 |
176
+ | `zoomEnable` / `scrollEnable` | `boolean` | `true` | 是否允许缩放 / 滚动(禁用手势,按钮与 API 仍可用) |
177
+ | `rotation` | `number` | `0` | 额外旋转(叠加在 PDF 自带 `/Rotate` 之上) |
178
+ | `theme` | `'light' \| 'dark' \| 'auto'` | `'auto'` | `auto` 跟随系统并实时响应 |
179
+ | `gap` / `padding` | `number` | `12` / `12` | 页间距 / 内容内边距(均随 zoom 缩放) |
180
+ | `textLayer` | `boolean` | `true` | 关闭可省内存,代价是不可选中/复制 |
181
+ | `links` | `boolean` | `true` | 内链跳转与外链 |
182
+ | `toolbar` / `showControls` | `boolean` | `true` | 内置工具栏 / 页码缩放浮层 |
183
+ | `backTop` / `loadingBar` | `boolean` | `true` | 回到顶部悬浮按钮 / 顶部加载进度条 |
184
+ | `bitmapCacheBytes` | `number` | `96MB` | 位图缓存上限 |
185
+ | `textLayerCacheEntries` | `number` | `24` | 文本层缓存条目上限 |
186
+ | `maxCanvasPixels` / `maxCanvasDimension` | `number` | iOS 安全值 | canvas 限幅 |
187
+ | `annotation` | `boolean` | `false` | 启用批注功能(工具栏出现批注按钮) |
188
+ | `annotations` | `Annotation[]` | — | 受控批注列表;不传则内部维护 |
189
+ | `annotationStyle` | `Partial<AnnotationStyle>` | — | 批注配色、字号、线宽 |
190
+ | `onAnnotationsChange` | `(list) => void` | — | 批注变化回调 |
191
+ | `onDocumentLoad` / `onPageChange` / `onProgress` / `onError` / `onZoomChange` | callback | — | 事件回调(`onProgress` 已节流) |
192
+ | `onReady` / `onRender` / `onScroll` / `onZoomEnd` / `onComplete` | callback | — | 生命周期与交互事件(见 [事件系统](#事件系统)) |
193
+ | `onInternalLink` / `onExternalLink` | callback | — | 拦截链接行为 |
194
+ | `renderError` | `(error, retry) => ReactNode` | — | 自定义错误态 |
195
+
196
+ ### 命令式 API(通过 `ref`)
197
+
198
+ ```tsx
199
+ const ref = useRef<PdfH5ReaderHandle>(null);
200
+
201
+ ref.current?.goToPage(10, { smooth: true });
202
+ ref.current?.nextPage();
203
+ ref.current?.setZoom(2, { x: 200, y: 400 }); // 以视口坐标点为锚点缩放
204
+ ref.current?.zoomIn();
205
+ ref.current?.resetZoom();
206
+ await ref.current?.search('关键词'); // 返回 SearchHit[]
207
+ ref.current?.openPanel('outline'); // 'outline' | 'thumbnails' | 'search' | null
208
+ ref.current?.getCacheStats(); // 缓存与渲染运行时统计
209
+ ref.current?.getStatus(); // 运行状态快照
210
+ ref.current?.clearCaches();
211
+ ```
212
+
213
+ ### 事件系统
214
+
215
+ 两种写法等价,按团队习惯选一种即可(内部走同一条派发路径,行为不会漂移):
216
+
217
+ ```tsx
218
+ // 方式 A:React props
219
+ <PdfH5Reader
220
+ src={url}
221
+ onReady={({ total }) => setTotal(total)}
222
+ onZoomEnd={(e) => track('zoom', e.zoom)}
223
+ />
224
+
225
+ // 方式 B:命令式 on/off(适合在 effect 里按条件订阅)
226
+ const off = ref.current.on('pageChange', ({ page, total }) => setPage(page));
227
+ off(); // 取消这一条
228
+ ref.current.off('pageChange'); // 清空该事件
229
+ ref.current.once('complete', (e) => console.log(e)); // 只监听一次
230
+ ref.current.trigger('backTop', { from: 3 }); // 手动派发
231
+ ```
232
+
233
+ | 事件 | 载荷 | 说明 |
234
+ | --- | --- | --- |
235
+ | `init` | `{ src }` | 开始初始化 |
236
+ | `ready` | `{ doc, total }` | 文档就绪,可拿总页数 |
237
+ | `render` | `{ pageNumber, pageTime, totalTime }` | 单页渲染完成(含耗时,便于做性能埋点) |
238
+ | `success` | `{ message, time }` | 加载成功 |
239
+ | `error` | `{ error, message, time }` | 加载失败 |
240
+ | `complete` | `{ status: 'success' \| 'error', message, time }` | 加载流程结束(成功失败都触发) |
241
+ | `scroll` | `{ scrollTop, scrollLeft, currentPage, progress }` | 滚动(已 rAF 节流) |
242
+ | `pageChange` | `{ page, total }` | 当前页变化 |
243
+ | `zoom` / `zoomStart` | `{ zoom, source }` | 缩放中 / 开始 |
244
+ | `zoomEnd` | `{ zoom, startZoom, scaleChange, duration, source }` | 缩放结束(动画也计入) |
245
+ | `zoomEnabled` / `scrollEnabled` | `{ enabled }` | 开关变化 |
246
+ | `backTop` | `{ from }` | 点击回到顶部 |
247
+ | `destroy` | `{}` | 组件卸载 |
248
+
249
+ `getStatus()` 返回运行快照,适合做埋点或调试面板:
250
+
251
+ ```ts
252
+ {
253
+ ready: true, loading: false, total: 22, page: 3, zoom: 1,
254
+ measured: 22, // 已解析尺寸的页数
255
+ cacheBytes: 10_485_760, // 位图缓存占用
256
+ mountedPages: 5, // 实际挂载的页面 DOM 数(虚拟化效果)
257
+ error: null, zoomEnabled: true, scrollEnabled: true,
258
+ }
259
+ ```
260
+
261
+ ### 批注
262
+
263
+ 支持**高亮 / 文字批注 / 手写笔迹**三种。数据层与渲染层分离,不修改 PDF 字节。
264
+
265
+ ```tsx
266
+ <PdfH5Reader src={url} annotation onAnnotationsChange={setAnnotations} />
267
+ ```
268
+
269
+ 开启 `annotation` 后工具栏出现批注按钮,点开可选三种工具;**单次绘制后自动退出批注模式**,避免想滑动时又画出一条。
270
+
271
+ 受控用法(数据自己管):
272
+
273
+ ```tsx
274
+ const [annotations, setAnnotations] = useState<Annotation[]>([]);
275
+
276
+ <PdfH5Reader
277
+ src={url}
278
+ annotation
279
+ annotations={annotations}
280
+ onAnnotationsChange={setAnnotations}
281
+ />
282
+ ```
283
+
284
+ 命令式 API:
285
+
286
+ ```ts
287
+ ref.current.getAnnotations();
288
+ ref.current.addAnnotation({
289
+ id: 'a1', kind: 'highlight', pageNumber: 1, createdAt: Date.now(),
290
+ rects: [{ x: 0.1, y: 0.2, width: 0.4, height: 0.03 }],
291
+ color: '#ffd400', opacity: 0.4,
292
+ });
293
+ ref.current.removeAnnotation('a1');
294
+ ref.current.clearAnnotations();
295
+ ref.current.setAnnotationMode('ink'); // 'none' | 'highlight' | 'freetext' | 'ink'
296
+ ```
297
+
298
+ **坐标用归一化值(0-1,相对页面宽高),不是像素。** 这样缩放、旋转、切换 fitMode 后批注仍然贴合原文 —— 这是批注功能最容易做错的地方。三种批注的数据结构:
299
+
300
+ ```ts
301
+ // 高亮:支持多段矩形(跨行高亮)
302
+ { kind: 'highlight', rects: NormRect[], color, opacity, text? }
303
+
304
+ // 文字批注:锚点 + 文字;fontSize 是相对页面宽度的比例
305
+ { kind: 'freetext', anchor: NormPoint, text, fontSize, color }
306
+
307
+ // 手写:归一化点序列;thickness 相对页面宽度
308
+ { kind: 'ink', points: NormPoint[], thickness, color }
309
+ ```
310
+
311
+ 样式可通过 `annotationStyle` 覆盖,或用 CSS 变量 `--pdfh5-anno-hl` / `--pdfh5-anno-ink` 改色。
312
+
313
+ > 批注默认**不写入 PDF 文件**。改 PDF 需要 pdf-lib 之类依赖,且会破坏原文件的签名与增量更新语义;
314
+ > 需要落盘时用 `getAnnotations()` 导出,自行决定存储方式。
315
+
316
+ ### 可单独复用的模块
317
+
318
+ 不想用内置 UI 时,可以只拿底层能力:
319
+
320
+ ```ts
321
+ import {
322
+ usePdfDocument, usePageMetrics, useVirtualPages, usePinchGesture,
323
+ RenderQueue, BitmapCache, TextLayerCache, LruCache,
324
+ computeLayout, quantizeScale, clampCanvasSize,
325
+ pinchTransform, pinchCommit, rafThrottle, debounce, rafBatch,
326
+ searchDocument, flattenOutline, createThumbnailCanvas,
327
+ } from 'pdf-h5-react';
328
+ ```
329
+
330
+ `src/core/{lru,throttle,layout,gestures}.ts` 都是纯函数/纯类,不依赖 React 与 DOM(`clampCanvasSize` 除外),已覆盖单元测试。
331
+
332
+ ---
333
+
334
+ ## 验证
335
+
336
+ ```bash
337
+ npm run typecheck # tsc --noEmit
338
+ npm test # 42 个单元测试(LRU / 节流 / 版面数学 / 手势数学 / 渲染队列 / 文本搜索)
339
+ npm run samples # 生成示例 PDF(含多级书签、混尺寸页、旋转页、加密文档)
340
+ npm run assets # 拷贝 pdf.js 运行时资源到 public/pdfjs
341
+ npm run build:demo && npm run preview
342
+ npm run verify # 无头 Chrome 端到端验证 + 截图
343
+ ```
344
+
345
+ `npm run verify` 在真实 Chrome 里跑的 21 项断言(最近一次结果):
346
+
347
+ ```
348
+ 文档加载成功 · 首屏渲染出图 · 虚拟化只挂载少量页面 · 画布实际有内容
349
+ 所有可见页均已绘制(无空白页)
350
+ 300 页文档只挂载极少数页面 · 滚动后挂载数受控 · 快速滚动后仍无空白可见页
351
+ LRU 回滚命中 > 0 · 位图缓存始终不超过字节上限 · LRU 有实际淘汰行为
352
+ 渲染任务有取消而非堆积 · 同一帧滚动风暴合并为一次处理(61 事件 → 1 处理)
353
+ 全文档搜索命中 · 放大后内容高度按比例增长 · 翻页模式横向吸附生效
354
+ 缩略图面板可用 · 目录面板可用(17 条多级)· 目录标题可读 · 点击目录可跳转
355
+ 无未捕获的前端错误
356
+ ```
357
+
358
+ 截图在 `screenshots/`(`final-*`),报告在 `screenshots/verify-report.json`。
359
+
360
+ ### 示例文档
361
+
362
+ | 文件 | 页数 | 说明 |
363
+ | --- | --- | --- |
364
+ | `public/samples/demo-zh.pdf` | 22 | 中文(SimHei 子集嵌入,121KB);17 条多级书签;第 3 页横向 A4、第 5 页窄页、第 7 页 `/Rotate 90`;内链跳第 10 页、外链到 pdf.js 官网;第 13 页矢量插图 |
365
+ | `public/samples/large.pdf` | 300 | 性能压测(163KB) |
366
+ | `public/samples/encrypted.pdf` | 1 | 加密文档,**打开密码 `test`**(来自 pdf.js 官方测试集 `issue15893_reduced.pdf`) |
367
+
368
+ `scripts/make-samples.mjs` 自带断言:页数、大纲条目及 `/Dest` 可解析性、文本层可提取、混尺寸与旋转页、文本不越界、**文本层无 `U+0000`**(字体缺字会静默写入该字符,复制出来是乱码)。
369
+
370
+ ---
371
+
372
+ ## 已知边界
373
+
374
+ - **批注不写入 PDF 文件**。数据存在独立层(`getAnnotations()` 可导出),不修改原文件字节——改 PDF 需要 pdf-lib 等依赖,且会破坏签名与增量更新语义。
375
+ - **链接层只铺透明命中区**,没有渲染完整的 annotation layer(PDF 自带的表单与批注外观不会显示)。需要时可用 `pdfjs-dist` 的 `AnnotationLayer` 自行扩展。
376
+ - **横向页混排时会出现横向滚动**。为保证全篇缩放一致(视觉整齐),不压缩超宽页;阅读器会自动把横向滚动位置居中,避免看起来「右侧被裁」。
377
+ - **canvas 单页上限**。iOS Safari 约 1670 万像素,超限会得到空白画布;`clampCanvasSize` 会自动降分辨率,因此极端放大时是「位图不够清晰」而不是「白屏」。
378
+ - **`fitMode: 'page'` 在连续滚动下按宽度优先**,避免窄页被缩得过小;翻页模式下才严格整页可见。
379
+ - **加密文档**只走 pdf.js 的密码回调,不支持证书/权限密码的细粒度控制。
380
+ - **SSR 场景的 pdf.js 提示**。`pdfjs-dist` 在 Node 环境会在 import 时打印
381
+ `Please use the legacy build in Node.js environments`。这是 pdf.js 自身的行为,
382
+ 对浏览器产物无影响;在 Next.js 等服务端渲染框架里用 `dynamic(() => import(...), { ssr: false })`
383
+ 或 `'use client'` 即可避免。
384
+
385
+ ## 开发
386
+
387
+ ```bash
388
+ npm i
389
+ npm run assets # 首次需要:准备 pdf.js 运行时资源
390
+ npm run samples # 首次需要:生成示例 PDF
391
+ npm run dev # http://127.0.0.1:5273
392
+ ```
393
+
394
+ 技术栈:Vite 7 + React 19 + TypeScript 5.9 + pdfjs-dist 6(peer)。
395
+ 库产物为 ESM + 独立 CSS + 完整 `.d.ts`,`react` / `react-dom` / `pdfjs-dist` 均为 external。
396
+
397
+ ## 发布
398
+
399
+ 发布由 [GitHub Actions](.github/workflows/release.yml) 自动完成,推 tag 即触发。
400
+
401
+ **前置条件**:仓库 `Settings → Secrets and variables → Actions` 里配置 `NPM_TOKEN`
402
+ (npmjs.com 的 Automation token,权限只需 *Read and write*)。
403
+ GitHub 自带的 `GITHUB_TOKEN` 只能发 GitHub Packages,发不了 npmjs,因此必须配这个 Secret。
404
+
405
+ ```bash
406
+ # 1. 改版本号
407
+ npm version 0.4.0 --no-git-tag-version
408
+ git commit -am "chore: release v0.4.0"
409
+
410
+ # 2. 推送并打 tag(脚本会校验 tag 与 package.json 一致)
411
+ node scripts/publish-gh.mjs --tag 0.4.0
412
+ ```
413
+
414
+ 推送 `v*` tag 后 workflow 会依次执行:类型检查 → 单元测试 → 构建库 → 校验 `dist` 产物 → `npm publish`。
415
+ 版本校验不通过(tag 与 `package.json` 不一致)会直接失败,避免发错版本。
416
+
417
+ 也可以在 [Actions 页面](https://github.com/superjunjie/pdf-h5-react/actions/workflows/release.yml)
418
+ 手动触发 `Run workflow`,并用输入框覆盖版本号。
419
+
420
+ ### 手动发布(本地)
421
+
422
+ 不想等 CI 时可以本地直发,需要先 `npm login`:
423
+
424
+ ```bash
425
+ npm run build && npm publish # prepublishOnly 会自动跑 typecheck + test + build
426
+ ```
427
+
428
+ ## License
429
+
430
+ MIT
2
431
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
@@ -0,0 +1,26 @@
1
+ import { DEFAULT_ANNOTATION_STYLE, Annotation, AnnotationMode, AnnotationStyle } from '../core/annotations';
2
+ export interface AnnotationLayerProps {
3
+ pageNumber: number;
4
+ width: number;
5
+ height: number;
6
+ mode: AnnotationMode;
7
+ style: AnnotationStyle;
8
+ annotations: readonly Annotation[];
9
+ /** 交互总开关:翻页动画 / 面板打开时应关闭,避免抢手势 */
10
+ enabled: boolean;
11
+ onAdd(annotation: Annotation): void;
12
+ onSelect?(annotation: Annotation): void;
13
+ onActiveChange?(active: boolean): void;
14
+ }
15
+ /**
16
+ * 注释层。
17
+ *
18
+ * 三层结构(自下而上):
19
+ * 1. 既有注释的渲染层(pointer-events: none,不影响阅读与选词)
20
+ * 2. 交互层(仅在 mode !== 'none' 时启用 pointer-events: auto)
21
+ * 3. 自由文本输入浮层(用 textarea,聚焦时阻止阅读器手势)
22
+ *
23
+ * 为什么用归一化坐标:见 core/annotations.ts 的说明。缩放 / 旋转后注释仍贴合原文。
24
+ */
25
+ export declare function AnnotationLayer({ pageNumber, width, height, mode, style, annotations, enabled, onAdd, onSelect, onActiveChange, }: AnnotationLayerProps): import("react").JSX.Element;
26
+ export { DEFAULT_ANNOTATION_STYLE };
@@ -0,0 +1,17 @@
1
+ import { PDFPageProxy } from 'pdfjs-dist';
2
+ export interface LinkLayerProps {
3
+ page: PDFPageProxy | null;
4
+ pageNumber: number;
5
+ width: number;
6
+ height: number;
7
+ displayScale: number;
8
+ rotation: number;
9
+ enabled: boolean;
10
+ onInternalLink?: (dest: string | unknown[] | null) => void;
11
+ onExternalLink?: (url: string) => void;
12
+ }
13
+ /**
14
+ * 链接层。只保留可点击区域,不渲染 PDF 的完整 annotation layer
15
+ * (表单、批注编辑器在 H5 里体积大且意义有限)。
16
+ */
17
+ export declare function LinkLayer({ page, pageNumber, width, height, displayScale, rotation, enabled, onInternalLink, onExternalLink, }: LinkLayerProps): import("react").JSX.Element | null;
@@ -0,0 +1,8 @@
1
+ import { PDFDocumentProxy } from 'pdfjs-dist';
2
+ export interface OutlinePanelProps {
3
+ doc: PDFDocumentProxy;
4
+ onNavigate(pageNumber: number): void;
5
+ onClose(): void;
6
+ }
7
+ /** 目录(书签)面板:扁平化 + 缩进,适合 H5 抽屉 */
8
+ export declare function OutlinePanel({ doc, onNavigate, onClose }: OutlinePanelProps): import("react").JSX.Element;
@@ -0,0 +1,41 @@
1
+ import { PDFPageProxy } from 'pdfjs-dist';
2
+ import { BitmapCache } from '../core/cache';
3
+ import { RenderQueue } from '../core/renderQueue';
4
+ export interface PageCanvasProps {
5
+ page: PDFPageProxy | null;
6
+ pageNumber: number;
7
+ /** 页面在 CSS 中的显示尺寸 */
8
+ width: number;
9
+ height: number;
10
+ /** 显示尺寸对应的 PDF scale(width / pdfWidth) */
11
+ displayScale: number;
12
+ devicePixelRatio: number;
13
+ rotation: number;
14
+ bitmapCache: BitmapCache;
15
+ renderQueue: RenderQueue;
16
+ /** 可见页高优先级,预取页低优先级 */
17
+ priority: 'visible' | 'prefetch';
18
+ /** false 时只显示缓存,不主动渲染(用于远区) */
19
+ shouldRender: boolean;
20
+ maxCanvasPixels?: number;
21
+ maxCanvasDimension?: number;
22
+ onRendered?: (pageNumber: number) => void;
23
+ }
24
+ /**
25
+ * 单页画布。
26
+ *
27
+ * 性能与正确性要点:
28
+ *
29
+ * 1. **显示 canvas 必须每页独占**:缓存里的是可共享的离屏位图,但 DOM 节点只有一个父节点,
30
+ * 若直接把缓存 canvas 挂进页面,后挂载的页会把前面页的 canvas 抢走(表现为空白)。
31
+ * 所以渲染结果用 drawImage 拷贝到本组件自己的 canvas。
32
+ *
33
+ * 2. **创建与绘制必须在同一个 effect**:拆成两个 effect 时,绘制可能先于创建执行,
34
+ * 拿到的 canvas 是 null,首屏会一直空白。
35
+ *
36
+ * 3. **量化渲染档位**:捏合过程中不重渲染,只有档位跃迁才触发,缩放来回能持续命中 LRU。
37
+ *
38
+ * 4. **取消后必须重试**:渲染任务可能因为「尺寸变化 / 离开视口 / 队列抢占」被取消。
39
+ * 若此时页面仍在屏上,不重试就会留下永久空白 —— 这是最容易漏掉的一类 bug。
40
+ */
41
+ export declare function PageCanvas({ page, pageNumber, width, height, displayScale, devicePixelRatio, rotation, bitmapCache, renderQueue, priority, shouldRender, maxCanvasPixels, maxCanvasDimension, onRendered, }: PageCanvasProps): import("react").JSX.Element;
@@ -0,0 +1,23 @@
1
+ import { PDFDocumentProxy } from 'pdfjs-dist';
2
+ import { ThumbnailCache } from '../core/cache';
3
+ import { RenderQueue } from '../core/renderQueue';
4
+ export interface PageThumbnailProps {
5
+ doc: PDFDocumentProxy;
6
+ pageNumber: number;
7
+ width: number;
8
+ cache: ThumbnailCache;
9
+ queue: RenderQueue;
10
+ /** 进入视口才渲染 */
11
+ active: boolean;
12
+ rotation: number;
13
+ onSelect(pageNumber: number): void;
14
+ current: boolean;
15
+ }
16
+ /**
17
+ * 单张缩略图。
18
+ *
19
+ * 缩略图数量可能上百,两个限制:
20
+ * 1. 只有 active(进入视口附近)才提交渲染;
21
+ * 2. 结果按页缓存成 dataURL(体积小),再次打开面板直接复用。
22
+ */
23
+ export declare function PageThumbnail({ doc, pageNumber, width, cache, queue, active, rotation, onSelect, current, }: PageThumbnailProps): import("react").JSX.Element;
@@ -0,0 +1,101 @@
1
+ import { PDFDocumentProxy } from 'pdfjs-dist';
2
+ import { ZoomEventPayload } from '../core/readerEvents';
3
+ import { Annotation, AnnotationStyle } from '../core/annotations';
4
+ import { PdfH5ReaderHandle, PdfSource, ThemeMode, ViewerMode, FitMode } from '../types';
5
+ export interface PdfH5ReaderProps {
6
+ /** PDF 来源:URL / 二进制 / 参数对象 */
7
+ src: PdfSource | null | undefined;
8
+ /** 连续滚动 / 单页翻页 */
9
+ mode?: ViewerMode;
10
+ /** 适配方式,默认 width */
11
+ fitMode?: FitMode;
12
+ initialPage?: number;
13
+ /** 初始缩放倍率(相对适配尺寸),默认 1 */
14
+ initialZoom?: number;
15
+ minZoom?: number;
16
+ maxZoom?: number;
17
+ /** 双击 / 双指点击的放大目标倍率,默认 2.5 */
18
+ doubleTapZoom?: number;
19
+ /** 缩放动画时长(ms),默认 300;设为 0 立即跳变 */
20
+ animationDuration?: number;
21
+ /** 是否允许缩放手势,默认 true */
22
+ zoomEnable?: boolean;
23
+ /** 是否允许滚动,默认 true */
24
+ scrollEnable?: boolean;
25
+ rotation?: number;
26
+ theme?: ThemeMode;
27
+ gap?: number;
28
+ padding?: number;
29
+ /** 文本层(可选中 / 可复制),默认开启 */
30
+ textLayer?: boolean;
31
+ /** 链接层,默认开启 */
32
+ links?: boolean;
33
+ /** 是否显示内置工具栏,默认 true */
34
+ toolbar?: boolean;
35
+ /** 是否显示页码 / 缩放浮层,默认 true */
36
+ showControls?: boolean;
37
+ /** 是否显示「回到顶部」悬浮按钮,默认 true */
38
+ backTop?: boolean;
39
+ /** 是否显示顶部加载进度条,默认 true */
40
+ loadingBar?: boolean;
41
+ /** 是否启用注释功能(工具栏出现批注按钮),默认 false */
42
+ annotation?: boolean;
43
+ /** 受控注释列表;不传则由组件内部维护 */
44
+ annotations?: Annotation[];
45
+ /** 注释样式覆盖 */
46
+ annotationStyle?: Partial<AnnotationStyle>;
47
+ /** 注释变化回调(新增 / 删除 / 清空) */
48
+ onAnnotationsChange?: (annotations: Annotation[]) => void;
49
+ /** 位图缓存上限(字节),默认 96MB */
50
+ bitmapCacheBytes?: number;
51
+ /** 文本层缓存条目上限,默认 24 */
52
+ textLayerCacheEntries?: number;
53
+ maxCanvasPixels?: number;
54
+ maxCanvasDimension?: number;
55
+ className?: string;
56
+ style?: React.CSSProperties;
57
+ onDocumentLoad?: (doc: PDFDocumentProxy) => void;
58
+ onPageChange?: (page: number, total: number) => void;
59
+ onProgress?: (loaded: number, total: number) => void;
60
+ onError?: (error: Error) => void;
61
+ onZoomChange?: (zoom: number) => void;
62
+ onInternalLink?: (dest: string | unknown[] | null) => void;
63
+ onExternalLink?: (url: string) => void;
64
+ /** 自定义空状态 / 加载失败渲染 */
65
+ renderError?: (error: Error, retry: () => void) => React.ReactNode;
66
+ /** 文档就绪,可拿到总页数 */
67
+ onReady?: (payload: {
68
+ doc: PDFDocumentProxy;
69
+ total: number;
70
+ }) => void;
71
+ /** 某一页渲染完成 */
72
+ onRender?: (payload: {
73
+ pageNumber: number;
74
+ pageTime: number;
75
+ totalTime: number;
76
+ }) => void;
77
+ /** 滚动(已节流) */
78
+ onScroll?: (payload: {
79
+ scrollTop: number;
80
+ scrollLeft: number;
81
+ currentPage: number;
82
+ progress: number;
83
+ }) => void;
84
+ /** 缩放结束 */
85
+ onZoomEnd?: (payload: ZoomEventPayload) => void;
86
+ /** 加载流程结束(成功或失败都会触发) */
87
+ onComplete?: (payload: {
88
+ status: 'success' | 'error';
89
+ message: string;
90
+ time: number;
91
+ }) => void;
92
+ }
93
+ /**
94
+ * 移动端优先的 PDF 阅读器。
95
+ *
96
+ * 三层性能结构:
97
+ * 文档层 usePdfDocument —— 加载 / 密码 / 进度(进度事件节流)
98
+ * 度量层 usePageMetrics —— 首屏 N 页真实尺寸 + 其余页渐进解析(rAF 批量校正)
99
+ * 视图层 useVirtualPages —— 只挂载可视区内的页面 DOM,位图走 LRU,渲染走双并发队列
100
+ */
101
+ export declare const PdfH5Reader: import('react').ForwardRefExoticComponent<PdfH5ReaderProps & import('react').RefAttributes<PdfH5ReaderHandle>>;