阅读视图

发现新文章,点击刷新页面。

【LM-PDF】一个大模型时代的 PDF 极速预览方案是如何实现的?

最终效果示例(测试文档:290 页)

Kapture 2025-11-18 at 23.45.35.gif

开源地址: 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 文件预览。上线 - 掘金

本文适用范围说明

本文探讨的技术方案基于以下核心需求:

  1. PDF 预览模式:采用无限滚动(Infinite Scroll)方式浏览文件内容,而非传统分页器(Pager)模式(逐页或固定页数翻页)。
  2. 性能要求:需实现 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, 这样元素就会滚动效果 。

image.png

要想实现流畅滚动(平滑切换),还要实现新旧元素 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-pdflm-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 共建。

❌