【LM-PDF】一个大模型时代的 PDF 极速预览方案是如何实现的?
最终效果示例(测试文档:290 页)
开源地址: github.com/chennlang/l… (如果觉得还不错,记得留下你的 star,这对我有很大帮助!)
背景
随着 AGI 的日益发展,多模态的大模型也逐渐成为常态,出现在大众视野中,不过对于要求较高的场景,识别效果还是缺点意思,主要还是因为文档解析是一个复杂的流程(layout 分析 + 表格、文字识别 + 切片 + 原文对比、段落划分等),所以传统的 RAG 流程还是主流的方式。
大模型要去 “看” 到世界,首先得理解图片、文档。在 RAG 的流程中,大模型需要去学习本地的文档,从而生成更加专业的回答。而这些存量的文档大多数是 pdf 格式的,或者是以图片存在的。所以我们第一步要解决的问题就是如何把文档中的信息完整、准确的提取出来。
PDF 渲染器其实是文档渲染的一个通用的文档展示方案,如果假设我们是在一个照相机后面看世界,所有图片、文档都能被看做是一张张图片,最终汇总起来就是一本 pdf 文档,所以理论上所有东西都能用 PDF 渲染器显示。
在 AGI 的前端项目中,无论是训练模型语料、还是模型回答原文查看、还是切片来源,都需要把原文档联系起来。所以都需要同时展示原文和回答的功能。
现有 PDF 预览方案
| 特性 | pdf.js | react-pdf | react-pdf-viewer |
|---|---|---|---|
| 类型 | JavaScript 库 | React 组件库 | React 组件库 |
| 依赖 | 无 | pdf.js | pdf.js |
| UI | 无(需手动实现) | 基础(需手动实现工具栏等) | 完整(提供工具栏、缩略图等) |
| 功能 | 渲染、缩放、搜索等 | 渲染、页面懒加载等 | 渲染、搜索、缩放、插件支持等 |
| 定制性 | 高(底层 API) | 中(组件化) | 中高(插件和主题定制) |
| 性能 | 取决于 PDF 复杂度和实现 | 取决于 PDF 复杂度和实现 | 取决于 PDF 复杂度和实现 |
| 学习成本 | 高(需处理 UI 和交互) | 中(React 组件) | 中高(API 和插件系统) |
| 适用场景 | 高度自定义、非 React 环境 | React 项目,基础预览 | React 项目,功能齐全的查看器 |
目前主流开源的 pdf 渲染器都是基于 pdf.js 封装实现,其中比较有代表性的就是 react-pdf和 react-pdf-viewer,选型建议:
- 如果你的项目基于 React,且需要一个开箱即用的 PDF 查看器,推荐选择 react-pdf-viewer。
- 如果你仅需在 React 中渲染 PDF 页面并希望自行设计 UI,建议使用 react-pdf。
- 如果你不在 React 环境中,或需要底层控制,建议直接使用 pdf.js。
现存问题
一直以来,我都是使用比较成熟的开源库 react-pdf 渲染 pdf 文档。不过,随着使用的深入,各种问题也随之浮现。例如开源的产品没法满足高度定制化、字体兼容问题导致显示错误.... 而最大的问题,是性能!
- 场景1: 500 页的 pdf 文档如果不做分页,市面上几乎没有一款 pdf 渲染器能做到流畅的滚动加载。
- 场景2:加载时间长,100M的文档,需要下载完才能预览,网络差的用户需要等 20分钟后才能看到。
综上问题,本来原文档预览是一个方面使用者快速去对比分片、对比回答结果的快捷方式,却因为以上问题,使用起来特别难受。
react-pdf 兼容性问题可参考:全面解析 React-PDF 的浏览器兼容性及其解决策略背景 最近使用 react-pdf 进行 pdf 文件预览。上线 - 掘金
本文适用范围说明
本文探讨的技术方案基于以下核心需求:
- PDF 预览模式:采用无限滚动(Infinite Scroll)方式浏览文件内容,而非传统分页器(Pager)模式(逐页或固定页数翻页)。
- 性能要求:需实现 PDF 文件的秒级加载,确保流畅体验。
重点说明: 无限滚动模式更符合现代用户习惯,适用于大多数实际场景。本文内容不涉及分页器模式的实现逻辑。
想法萌生
就在我百思不得其解的时候,我看到了一款闭源的 canvas 实现的 pdf 渲染器。全文只有一个 canvas 元素!当然,简单体验了下,页数很多时依然会很卡,甚至不能用。
受此启发,所以我想,既然 pdf 文件在 OCR 识别之前的第一步,就一定是把每一页切成一张图片,那么基于这个场景下,我们完全可以使用图片来渲染呀,完全不用加载文件。
假设视图内只有一页,那么 canvas 中只会渲染 1 张图片,那速度岂不是秒开?
当然,pdf 文件流是二进制的,也能通过分段获取其中一部分文档。可是如何知道每一页的开始和结束符,这是一个问题。
lm-pdf
为了方便下文讲解,我将此方案先命名为 lm-pdf, 主要是为了体现其出色的加载速度。
技术选型:react-konva
有了以上思路,实现起来就是时间的问题了。我选用了 canvas 作为渲染底座,搜索一圈之后发现 konvajs在这个场景下非常适合。结合 react-konva, 在画布上渲染元素就非常简单了。
示例:在画布上渲染一张图片
import { Stage, Layer, Image } from 'react-konva';
class App extends Component {
render() {
return (
<Stage width={window.innerWidth} height={window.innerHeight}>
<Layer>
<Image x={0} y={0} image={...}></ Image>
</Layer>
</Stage>
);
}
}
当然,使用 canvas 渲染还不够,既然要做到性能最好,我们还需要加上虚拟滚动。
核心功能:canvas 虚拟滚动
很多人会说,都使用 canvas 了还使用什么虚拟滚动?可是你要知道,如果大量的元素常驻在画布上,加上滚动时,所有位置都要偏移,也就是说所有元素的位置都会被重新计算一遍。canvas 是按帧渲染的,这样 GPU 渲染肯定错错有余,不过内存和 CPU 性能却吃不消了!所以,要做就做到最好的! 而虚拟滚动恰好就能解决这个问题,因为视窗内同时显示的元素最多不超过 5 个,那么最多就这 5 个元素的计算量,会非常低。
虚拟滚动是什么
虚拟滚动(Virtual Scrolling)是一种优化长列表渲染性能的技术。其基本原理是只渲染可视区域内的元素,而非整个列表,从而减少DOM节点的数量和提高页面性能。
虚拟滚动本身的原理说起来很简单,无非就是通过容器高度和滚动距离动态渲染子元素。不过,要实现一个基于 canvas 的虚拟滚动器,实现过程中,却有很多小细节值得分享。
传统实现方案
Canvas 的虚拟滚动方案和常规实现方案有所不同,也有相同之处。所以我们需要先了解下传统的滚动条方案是怎么实现的。方便理解文章后面的内容。
完整 Demo 如下:
import React, { useState, useEffect, useRef } from 'react';
// 虚拟滚动列表组件
const VirtualScrollList = ({ items, itemHeight }) => {
// 状态:可见项的起始和结束索引
const [startIndex, setStartIndex] = useState(0);
const [endIndex, setEndIndex] = useState(10);
// 引用:用于访问滚动容器
const containerRef = useRef(null);
const handleScroll = () => {
// 获取当前滚动位置
const scrollTop = containerRef.current.scrollTop;
// 计算新的起始索引
const newStartIndex = Math.floor(scrollTop / itemHeight);
// 计算新的结束索引
const newEndIndex = newStartIndex + Math.ceil(containerRef.current.clientHeight / itemHeight);
// 更新可见项的索引
setStartIndex(newStartIndex);
setEndIndex(newEndIndex);
};
// 滚动事件监听
useEffect(() => {
const container = containerRef.current;
container.addEventListener('scroll', handleScroll);
return () => {
container.removeEventListener('scroll', handleScroll);
};
}, []);
// 可见项
const visibleItems = items.slice(startIndex, endIndex);
// 计算占位符高度
const placeholderHeight = items.length * itemHeight;
return (
<div style={{ height: '300px', overflowY: 'auto' }} ref={containerRef}>
<div style={{ height: `${placeholderHeight}px`, position: 'relative' }}>
<div style={{ position: 'absolute', top: `${startIndex * itemHeight}px`, left: 0 }}>
{visibleItems.map((item, index) => (
// 渲染可见项
<div key={index} style={{ height: `${itemHeight}px` }}>
{item}
</div>
))}
</div>
</div>
</div>
);
};
// 使用示例
const items = Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`);
const App = () => (
<div>
<h1>虚拟滚动列表</h1>
<VirtualScrollList items={items} itemHeight={30} />
</div>
);
export default App;
定义一个父容器,在容器中放一个占位符,占位符高度是所以 item 的总和,从而达到撑开父容器,出现滚动条。然后其子元素 item 使用 absolute 的方式悬浮在占位符 元素上。然后通过滚动的距离计算出要显示的子元素 visibleItems,渲染在页面上即可。
Canvas 虚拟滚动实现方案
下面将使用伪代码展示核心原理。
一、canvas 内并没有滚动条的概念,所以我们需要自己实现一个滚动条。
virtual-scroll-bar 组件
// Item
interface Item {
id: string | number;
height: number;
[k: string]: any;
}
interface Props {
items: Item [];
onVisibleItemChange: (items: Item[]) => void;
onScroll?: (scroll: { left: number; top: number }) => void;
}
const VirtualScrollBar = ({ items }: ) => {
// 滚动触发
function handleScroll (scroll) {
updateVisibleItems(scroll)
onScroll(scroll)
}
// 计算可视元素
function updateVisibleItems (scroll) {
const visibleItems = []
// .....省略计算过程
onVisibleItemChange(visibleItems)
}
return <div ref={divRef} className={`v-scroll-bar ${direction}`}>
<div style={{
height: items.reduce((sum, item) => (sum += item.height), 0),
}}>
</div>
</div>
}
同样的方式,我们在 div 中加入一个占位符,高度是所以 item 的总和。然后通过监听 divRef 的滚动,计算出视图内出现的元素。
核心逻辑(伪代码)
// 当前显示元素
const [displayItems, setDisplayItems] = useState<PageItem[]>([]);
const onScroll: VirtualScrollBarProps["onScroll"] = ({ left, top }) => {
// 整体 y 方向偏移, 这里使用 setData 而不是 setData => old,
// 因为滚动频繁,利用 setData 更新机制可以做到节流,提升性能
setDisplayItems((old) =>
old.map((m) => ({
...m,
y: m.top - top,
}))
)};
// 对比新旧值,更新 Y 的坐标
// 滚动的过程中,y 会偏移,新的 items 进来,如果有公共的 items ,要和旧的保持一致。
function diffAndUpdateY () {}
function onVisibleItemChange(originItems: VirtualScrollBarProps["items"]) {
const items = originItems as PageItem[];
// 这里要做一件事,新的 items 会把 y 的坐标全部重新排过,这会有问题,表现为突然弹跳位置。
// 如果新的 items 中和旧的 items 中有共同的 item, 那么以旧的 item 的 y 为准,保持不变
// 那么新出现的,排在旧的上面或下面
startTransition(() => {
startTransition(() => {
setDisplayItems((old) => diffAndUpdateY(old, items));
});
});
}
<VirtualScrollBar
items={pages}
onVisibleItemChange={onVisibleItemChange}
onScroll={onScroll}
></VirtualScrollBar>
核心功能:如何实现页面平滑切换
不过你会发现,页面是一卡一卡的,像是幻灯片,子元素没有随着滚动而移动的。只是会到达一定滚动距离后,就会全部替换成新的元素。因为我们还没有做偏移,元素要随着滚动在容器内上下移动,直到移动到容器外,才替换成新的元素。实现偏移:所以我们把整体元素 y 值随着滚动偏移, y = y + offsetY, 这样元素就会滚动效果 。
要想实现流畅滚动(平滑切换),还要实现新旧元素 Diff ,原理如下:
如上图,假设我们当前视图显示了 [元素1、元素2],子元素高度都是 200px,滚动了 30px 后,显示了 [元素1、元素2、元素 3]。
元素 1、元素 2 在是它们的交集。所以元素 1、元素 2 的位置要保持以前的位置不变(用户界面能看到的已有的元素,不能因为切换了新的元素而改变位置),而元素 3 应该在元素 2 后面。
| 元素 | 初始坐标(滚动前) | 滚动后坐标(向下滚动 30px) |
|---|---|---|
| 元素1 | [0, 0] |
[0, -30] |
| 元素2 | [0, 200] |
[0, 170] |
| 元素3 | — | [0, 370] |
做完 diff 后,从用户的角度就会发现是连续的滚动,而实际是不停的在切换新元素。
性能优化:异步加载,减少线程阻塞
因为滚动的过程中是连续的,例如从第1页滚到 100页,那么中间的 2-99 都会被渲染一遍,其实我只是想看第 100 页,这会严重拖慢页面的渲染性能。
// page.ts
useEffect(() => {
if (!blocks.length) return;
// 延迟渲染定时器
const timer = setTimeout(() => {
// 开始渲染
setDisplayBlocks(blocks);
}, 500);
return () => {
clearTimeout(timer);
};
}, [blocks]);
上面我用了一个定时器,完美解决了这个问题,只有等组件出现在可视区域,渲染后且 500 毫秒内没有消失,才会真正渲染。这样就能避免无效的渲染任务。
500 毫秒最终使用过程中被我改成了 200,不然会出现明显的等待渲染过程,影响体验。
性能对比
页面加载速度测试,我采用目前开源中用的最多的 react-pdf 和 lm-pdf 作对比:
- 测试指标: 首页渲染时间
- 测试网速:13.9 Mbps
| PDF 测试文件页码 | react-pdf | lm-pdf |
|---|---|---|
| 3页 | 3.5s | 1s |
| 50页 | 7s | 1.5s |
| 344页 | 109s | 2.5s |
| 1000页 | 240s | 2.5s |
react-pdf 的加载速度取决于文档的大小,下载的网速影响。而 lm-pdf 的优势在于无论多少页,都趋近于 2.5s,打开的速度取决于单页的图片大小。
lm-pdf 优缺点
优点:
- 极快的首次加载速度(和文件大小、页数无关)
- 丝滑的滚动体验
- 极低的内存占用
- 极少的页面 DOM
缺点:
- 目前不支持复制 PDF 文本(研究中)
- PDF 必须先被切成图片
持续优化的点:其实还可以在远端无损压缩图片,进一步提高渲染速度。
总结
如果你看重的是加速速度和性能,lm-pdf 绝对能满足你的需求,不过此方案还存在一些局限,例如强依赖后端生成 pdf 单页图片、不支持复制 PDF 文本等,不过目前已开源,后续还会持续完善,也希望感兴趣的同学一起 PR 共建。