PDF与大图阅读器
PDF 页和 2 万像素宽的原图,都不能当成「一张 drawImage」。文档是 页/瓦片金字塔 + 相机;Canvas 只光栅当前视口。可选文字必须是 DOM,否则读屏、复制、搜页全死。
1. 先定世界坐标
一页 PDF 或一张大图:世界原点在内容左上,单位用 CSS 像素 at scale=1(对 PDF 即 viewport.width/height 在 scale=1 时的尺寸;对图即 naturalWidth/Height)。多页纵向流式:
export interface PageLayout {
pageIndex: number;
y: number;
width: number;
height: number;
}
export function stackPages(
sizes: readonly { width: number; height: number }[],
gap: number,
): PageLayout[] {
let y = 0;
return sizes.map((size, pageIndex) => {
const layout = { pageIndex, y, width: size.width, height: size.height };
y += size.height + gap;
return layout;
});
}
相机仍用 相机。滚动一页 = 改 panY。不要为每一页建一个 canvas DOM。
2. PDF:光栅层和文字层拆开
用 pdf.js 只当 解码器。约定:
export interface PdfPageFacade {
readonly pageIndex: number;
intrinsicSize(): { width: number; height: number };
raster(scale: number): Promise<ImageBitmap>;
textItems(): Promise<readonly PdfTextItem[]>;
}
export interface PdfTextItem {
str: string;
x: number;
y: number;
w: number;
h: number;
}
raster(scale) 内部才是 page.render({ canvasContext, viewport })。渲染目标必须是 离屏,完成后 transferToImageBitmap,主视图只 drawImage。
关键约束:
scale跟相机走:deviceScale = camera.scale * dpr,但要 钳到档位(0.5 / 1 / 1.5 / 2 / 3),不要每帧 1.037x 重光栅。松手或缩放结束再升档。- 一页 300dpi 等效光栅极大。超出阈值(例如长边 > 4096)改成 页内瓦片:对同一页用多个 viewport clip 渲染(pdf.js
transform+ 目标 canvas 尺寸),或先整页低清再对视口补高清。 - 文字层:
textItems映射成绝对定位 DOM(pdf.js 自带 textLayer 模式)。Canvas 上fillText只允许水印/临时高亮,不当正文。 - 链接/注释同样 DOM。命中不要对字形
isPointInPath。
页面切换:只保留视口附近 ±1 页的 bitmap,离开 bitmap.close()。预渲染下一页用 IdleCallback / Worker,不要和当前帧抢。
跨源 PDF 必须 CORS,否则 pdf.js 能解析失败或 canvas taint,导出标注会炸。见 污染 CORS。
3. 大图:禁止整图进 backing store
drawImage(img, 0, 0) 一张 20000×10000 的 JPEG,解码后 RGBA ≈ 800MB,移动端直接杀进程。策略按源类型:
| 源 | 做法 |
|---|---|
| 已有瓦片金字塔(IIIF / DZI / 自切 512) | 按 z/x/y 拉块,逻辑同 瓦片 |
| 单张但浏览器能裁解码 | createImageBitmap(blob, { sx, sy, sw, sh, resizeWidth, resizeHeight }) 只解视口 |
| 既不是金字塔也不能裁 | 服务端出金字塔;前端最多先 createImageBitmap(blob, { resizeWidth }) 做预览,原图像素不要进主线程 |
export async function decodeViewportTile(
blob: Blob,
world: { x: number; y: number; w: number; h: number },
outW: number,
outH: number,
): Promise<ImageBitmap> {
return createImageBitmap(blob, world.x, world.y, world.w, world.h, {
resizeWidth: Math.max(1, Math.round(outW)),
resizeHeight: Math.max(1, Math.round(outH)),
resizeQuality: "high",
});
}
createImageBitmap 的源裁切单位是 位图像素。世界若等于自然像素,可直接传;若世界是「PDF CSS 像素」,要先乘页的 intrinsic 比。失败(旧 WebKit 不支持裁切参数)就降级:整图 decode 一次到 Worker 里的 OffscreenCanvas,再 drawImage 切块——整图仍占内存,只是不堵 UI,不能当 2 万像素图的生产方案。
4. 绘制一帧
export function paintReader(
ctx: CanvasRenderingContext2D,
camera: Camera,
dpr: number,
tiles: readonly { bitmap: ImageBitmap; x: number; y: number; w: number; h: number }[],
): void {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = "#525659";
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.setTransform(cameraToCtm(camera, dpr));
for (const tile of tiles) {
ctx.drawImage(tile.bitmap, tile.x, tile.y, tile.w, tile.h);
}
}
背景灰是阅读器惯例,避免透明棋盘透出页面。标注矢量画在瓦片之上,不要烘进瓦片。
5. 适用 / 不适用
适用:内部 PDF 预览、图纸查看、病理切片(有金字塔时)、合同阅读 + 矢量签批。
不适用:要重排的 reflow PDF(那是 HTML 排版)、要编辑文字的 PDF(改 PDF 对象树,不是改 canvas)、把整本 PDF 拼成一张长图发朋友圈(先分页导出)。
6. 失败形态
| 症状 | 原因 |
|---|---|
| 打开一页白屏后崩 | 整页 × 高 DPR 一次光栅 |
| 缩放一抖就卡 | 每帧按精确 scale 重 render |
| 选不中字 / 读屏没字 | 文字只画在 canvas 上 |
| 大图 decode 后内存不掉 | 瓦片 bitmap 没 close,或整图 HTMLImage 还活着 |
| 标注和文字对不齐 | 文字 DOM 用了一套 scale,canvas 用了另一套相机 |
权威资料
- pdf.js(光栅与 textLayer 分工)
- createImageBitmap()
- IIIF Image API
- 相机、瓦片
核对日期:2026-08-26