最近接到产品需求,用户需要在我们的站点上在线查看 PDF 文件,并且查看时,用户可以对 PDF 文件的进行旋转、缩放、跳转到指定页码等操作。
这个太简单了,随便找找就一堆轮子。目前常见的在线 PDF 查看方案:- 使用 iframe、embed、object 标签直接加载
采用此方案,只需要直接将 PDF 的在线地址设置为标签的 src 属性
这个方案麻烦一点,我们需要在项目中引入 PDF.js 这个库,然后再使用 iframe 来加载指定的 HTML 文件(下文代码中的 viewer.html ),并且将需要访问的 PDF 的在线地址作为参数传递进去。大概就像下面一样:
showPdf (selector, options) {
const { width, height, fileUrl } = options;
this.pdfFrame = document.createElement('iframe');
this.pdfFrame.width = width;
this.pdfFrame.height = height;
this.pdfFrame.src = `./assets/web/viewer.html?file=${encodeURIComponent(fileUrl)}`;
document.getElementById(selector).append(this.pdfFrame);
}
这里可能会遇到跨域的问题,不过不是本文重点,不展开讲,相信这种小事难不倒聪明的你。
于是乎,啪啪啪几行代码迅速搞定给产品演示。然后产品拿了个线上文件来尝试效果。。。 BEDC8D6B-827A-4883-8A27-52B6372517A5.png
两人对着白屏尴尬的沉默良久,产品终于忍不住了。
“这怎么这么慢?不行,用户肯定不能接受。。。”
“公司网络不好... 你这文件太大了... 你重启一下试试?“
不存在的,作为一个优秀的前端开发者,怎么可以说这种话,当然是想办法解决啦。
重新整理一下产品的需求:
基本上前两条上述方案都能满足,所以我们需要解决的关键问题在于如何让用户快速打开内容,减少等待时间。由于现有方案都是将 pdf 文件内容全部下载完成之后才开始进行渲染,如果文件比较大的时候,用户第一次打开时就可能需要等待很长时间。那么思路有了:我们可不可以不下载全部的文件内容就开始渲染?
因为用户不可能一眼看到所有的 PDF 内容,每次只能看到屏幕显示范围内的几页。所以我们可以将可视范围内的PDF 页面内容优先下载并展示,可视范围外的我们根据用户浏览的实际位置按需下载和渲染。这样就可以减少第一次打开时用户的等待时间了。(类似与数据分页、图片懒加载的思想,目的是提高首屏性能。)
那么我们可以将一个大的 PDF 文件分成多个小文件,即分片。比如某个 PDF 有 200 页,我们按照 5 页一片,将它切分成 40 片,每次只下载用户看到的那一个分片。然后在用户进行滚动翻页的时候,异步的去下载对应包含对应页的分片。
基本的思路有了,接下来就是想办法实现了。要实现分片加载我们需要做两件事情:
1、服务器对 PDF 文件进行分片
由于这个是服务器做了,所以,交给后端就好了。本文不细讲,大家有兴趣的可以去了解 itextpdf (https://api.itextpdf.com/iText5/java/5.5.11/) 库,它提供了相关 API 对 PDF 进行切片。我们需要跟后端约定好 PDF 文件分片之后每一片的数据格式。假如分片的大小为 5(即每次请求 5 页内容),那么可以定义数据格式如下:
{
"startPage": 1, // 分片的开始页码
"endPage": 5, // 分片结束页码
"totalPage": 100, // pdf 总页数
"url": "http://test.com/asset/fhdf82372837283.pdf" // 分片内容下载地址
}
2、客户端根据用户交互行为获取并渲染指定的分片
显然,获取并渲染是两个操作。为了保证用户操作(滚动)的流畅性,这两个操作我们都异步进行。至此,我们需要解决的关键问题变成两个:
由于我们无法在已有标签上做修改,所以我们考虑基于 PDF.js 库进行深度定制。那么我们先了解一下 PDF.js 可以为我们提供哪些能力。参考 官方文档 (https://mozilla.github.io/pdf.js),下面列举了我们需要用到的几个 API ,由于官方文档中内容比较粗,这里贴上了源码中的注释。另附 源码地址 (https://github.com/mozilla/pdf.js/blob/12aba0f91a5cd3e36fa81cb799540f8073990831/src/display/api.js#L431)。
/**
* This is the main entry point for loading a PDF and interacting with it.
* NOTE: If a URL is used to fetch the PDF data a standard XMLHttpRequest(XHR)
* is used, which means it must follow the same origin rules that any XHR does
* e.g. No cross domain requests without CORS.
*
* @param {string|TypedArray|DocumentInitParameters|PDFDataRangeTransport} src
* Can be a url to where a PDF is located, a typed array (Uint8Array)
* already populated with data or parameter object.
* @returns {PDFDocumentLoadingTask}
*/
function getDocument(src) {
// 省略实现
}
简单的说就是,getDocument 接口可以获取 src 指定的远程 PDF 文件,并返回一个 PDFDocumentLoadingTask 对象。后续所有对 PDF 内容的操作都可以通过改对象实现。
2 . PDFDocumentLoadingTask
/**
* The loading task controls the operations required to load a PDF document
* (such as network requests) and provides a way to listen for completion,
* after which individual pages can be rendered.
*/
// eslint-disable-next-line no-shadow
class PDFDocumentLoadingTask {
// 省略 n 行实现
/**
* Promise for document loading task completion.
* @type {Promise}
*/
get promise() {
return this._capability.promise;
}
}
PDFDocumentLoadingTask 是一个下载远程 PDF 文件的任务。它提供了一些监听方法,可以监听 PDF 文件的下载状态。通过 promise 可以获取到下载完成的 PDF 对象,它会生成并最终返回一个 PDFDocumentProxy 对象。
3 . PDFDocumentProxy
/**
* Proxy to a PDFDocument in the worker thread. Also, contains commonly used
* properties that can be read synchronously.
*/
class PDFDocumentProxy {
// 省略 n 行实现
/**
* @type {number} Total number of pages the PDF contains.
*/
get numPages() {
return this._pdfInfo.numPages;
}
/**
* @param {number} pageNumber - The page number to get. The first page is 1.
* @returns {Promise} A promise that is resolved with a {@link PDFPageProxy}
* object.
*/
getPage(pageNumber) {
return this._transport.getPage(pageNumber);
}
}
PDFDocumentProxy 是 PDF 文档代理类,我们可以通过它的 numPages 获取到文档的页面数量,通过 getPage 方法获取到指定页码的页面 PDFPageProxy 实例。
4 . PDFPageProxy
/**
* Proxy to a PDFPage in the worker thread.
* @alias PDFPageProxy
*/
class PDFPageProxy {
// 省略 n 行实现
/**
* @param {GetViewportParameters} params - Viewport parameters.
* @returns {PageViewport} Contains 'width' and 'height' properties
* along with transforms required for rendering.
*/
getViewport({
scale,
rotation = this.rotate,
offsetX = 0,
offsetY = 0,
dontFlip = false,
} = {}) {
return new PageViewport({
viewBox: this.view,
scale,
rotation,
offsetX,
offsetY,
dontFlip,
});
}
/**
* Begins the process of rendering a page to the desired context.
* @param {RenderParameters} params Page render parameters.
* @returns {RenderTask} An object that contains the promise, which
* is resolved when the page finishes rendering.
*/
render({
canvasContext,
viewport,
intent = "display",
enableWebGL = false,
renderInteractiveForms = false,
transform = null,
imageLayer = null,
canvasFactory = null,
background = null,
}) {
// 省略方法实现
}
}
PDFPageProxy 我们主要用到它的两个方法。通过 getViewport 可以根据指定的缩放比例(scale)、旋转角度(rotation)获取当前 PDF 页面的实际大小。通过 render 方法可以将 PDF 的内容渲染到指定的 canvas 上下文中。
首先我们使用 PDF.js 提供的接口获取第一个分片的 url,然后再下载该分片的 PDF 文件。
/*
代码中使用 loadStatus 来记录特定页的内容是否一件下载
*/
const pageLoadStatus = {
WAIT: 0, // 等待下下载
LOADED: 1, // 已经下载
}
// 拿到第一个分片
const { startPage, totalPage, url } = await fetchPdfFragment(1);
if (!pages) {
const pages = initPages(totalPage);
}
const loadingTask = PDFJS.getDocument(url);
loadingTask.promise.then((pdfDoc) => {
// 将已经下载的分片保存到 pages 数组中
for (let i = 0; i < pdfDoc.numPages; i += 1) {
const pageIndex = startPage + i;
const page = pages[pageIndex - 1];
if (page.loadStatus !== pageLoadStatus.LOADED) {
pdfDoc.getPage(i + 1).then((pdfPage) => {
page.pdfPage = pdfPage;
page.loadStatus = pageLoadStatus.LOADED;
// 通知可以进行渲染了
startRenderPages();
});
}
}
});
// 从服务器获取分片
asycn function fetchPdfFragment(pageIndex) {
/*
省略具体实现
该方法从服务器获取包含指定页码(pageIndex)的 pdf 分片内容,
返回的格式参考上文约定:
{
"startPage": 1, // 分片的开始页码
"endPage": 5, // 分片结束页码
"totalPage": 100, // pdf 总页数
"url": "http://test.com/asset/fhdf82372837283.pdf" // 分片内容下载地址
}
*/
}
// 创建一个 pages 数组来保存已经下载的 pdf
function initPages (totalPage) {
const pages = [];
for (let i = 0; i < totalPage; i += 1) {
pages.push({
pageNo: i + 1,
loadStatus: pageLoadStatus.WAIT,
pdfPage: null,
dom: null
});
}
}
PDF 分片内容下载完成之后,我们就可以将其渲染到页面上。渲染之前,我们需要知道 PDF 页面的大小。调用 PDF.js 提供的方法,我们能够根据当前 PDF 的缩放比例、选择角度来获取页面的实际大小。
// 获取单页高度
const viewport = pdfPage.getViewport({
scale: 1, // 缩放的比例
rotation: 0, // 旋转的角度
});
// 记录pdf页面高度
const pageSize = {
width: viewport.width,
height: viewport.height,
}
然后我们需要创建一个内容渲染的区域,需要计算出内容的总高度(总高度 = 单页高度 * 总页数)。
// 为了不让内容太拥挤,我们可以加一些页面间距 PAGE_INTVERVAL
const PAGE_INTVERVAL = 10;
// 创建内容绘制区,并设置大小
const contentView = document.createElement('div');
contentView.style.width = `${this.pageSize.width}px`;
contentView.style.height = `${(totalPage * (pageSize.height + PAGE_INTVERVAL)) + PAGE_INTVERVAL}px`;
pdfContainer.appendChild(contentView);
之后我们就可以根据 pdf 的页码来将其内容渲染到指定区域。
// 我们可以通过 scale 和 rotaion 的值来控制 pdf 文档缩放、旋转
let scale = 1;
let rotation = 0;
function renderPageContent (page) {
const { pdfPage, pageNo, dom } = page;
// dom 元素已存在,无须重新渲染,直接返回
if (dom) {
return;
}
const viewport = pdfPage.getViewport({
scale: scale,
rotation: rotation,
});
// 创建新的canvas
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.height = pageSize.height;
canvas.width = pageSize.width;
// 创建渲染的dom
const pageDom = document.createElement('div');
pageDom.style.position = 'absolute';
pageDom.style.top = `${((pageNo - 1) * (pageSize.height + PAGE_INTVERVAL)) + PAGE_INTVERVAL}px`;
pageDom.style.width = `${pageSize.width}px`;
pageDom.style.height = `${pageSize.height}px`;
pageDom.appendChild(canvas);
// 渲染内容
pdfPage.render({
canvasContext: context,
viewport,
});
page.dom = pageDom;
contentView.appendChild(pageDom);
}
上面我们已经将第一个分片进行了展示,但是当用户进行滚动时,我们需要更新内容的显示。首先根据滚动的位置,计算出当前需要展示的页面,然后下载包含该页面的分片。
// 监听容器的滚动事件,触发 scrollPdf 方法
// 这里加了防抖保证不会一次产生过多请求
scrollPdf = _.debounce(() => {
const scrollTop = pdfContainer.scrollTop;
const height = pdfContainer.height;
// 根据内容可视区域中心点计算页码, 没有滚动时,指向第一页
const pageIndex = scrollTop > 0 ?
Math.ceil((scrollTop + (height / 2)) / (pageSize.height + PAGE_INTVERVAL)) :
1;
loadBefore(pageIndex);
loadAfter(pageIndex);
}, 200)
// 假定每个分片的大小是 5 页
const SLICE_COUNT = 5;
// 获取当前页之前页面的分片
function loadBefore (pageIndex) {
const start = (Math.floor(pageIndex / SLICE_COUNT) * SLICE_COUNT) - (SLICE_COUNT - 1);
if (start > 0) {
const prevPage = pages[start - 1] || {};
prevPage.loadStatus === pageLoadStatus.WAIT && loadPdfData(start);
}
}
// 获取当前页之后页面的分片
function loadAfter (pageIndex) {
const start = (Math.floor(pageIndex / SLICE_COUNT) * SLICE_COUNT) + 1;
if (start <= pages.length) {
const nextPage = pages[start - 1] || {};
nextPage.loadStatus === pageLoadStatus.WAIT && loadPdfData(start);
}
}
PDF 文件可能会很大,比如一个 1000 页的 PDF 文件。随着用户的滚动浏览,它会一直渲染,如果最终同时将 1000 个页面的 dom 全部放到页面上。那么内存占用将会非常多,导致页面卡顿。因此,为了减少内存占用,我们可以将当前可视范围之外的页面元素清除。
// 首先我们获取到需要渲染的范围
// 根据当前的可视范围内的页码,我们前后只保留 10 页
function getRenderScope (pageIndex) {
const pagesToRender = [];
let i = pageIndex - 1;
let j = pageIndex + 1;
pagesToRender.push(pages[pageIndex - 1]);
while (pagesToRender.length < 10 && pagesToRender.length < pages.length) {
if (i > 0) {
pagesToRender.push(pages[i - 1]);
i -= 1;
}
if (pagesToRender.length >= 10) {
break;
}
if (j <= pages.length) {
pagesToRender.push(this.pages[j - 1]);
j += 1;
}
}
return pagesToRender;
}
// 渲染需要展示的页面,不需展示的页码将其清除
function renderPages (pageIndex) {
const pagesToRender = getRenderScope(pageIndex);
for (const i of pages) {
if (pagesToRender.includes(i)) {
i.loadStatus === pageLoadStatus.LOADED ?
renderPageContent(i) :
renderPageLoading(i);
} else {
clearPage(i);
}
}
}
// 清除页面 dom
function clearPage (page) {
if (page.dom) {
contentView.removeChild(page.dom);
page.dom = undefined;
}
}
// 页面正在下载时渲染loading视图
function renderPageLoading (page) {
const { pageNo, dom } = page;
if (dom) {
return;
}
const pageDom = document.createElement('div');
pageDom.style.width = `${pageSize.width}px`;
pageDom.style.height = `${pageSize.height}px`;
pageDom.style.position = 'absolute';
pageDom.style.top = `${
((pageNo - 1) * (pageSize.height + PAGE_INTVERVAL)) + PAGE_INTVERVAL
}px`;
/*
此处在dom 上添加 loading 组件,省略实现
*/
page.dom = pageDom;
contentView.appendChild(pageDom);
}
至此,我们就实现了 PDF 文件的分片展示。保证了第一次用户就可以很快看到文件内容,同时在用户在滚动浏览时不会感觉到有卡顿,产品经理也露出了满足的微笑。
我们在程序设计中,遇到请求数据较大、任务执行时间过长等场景时很容易想到通过数据切分、任务分片等方式来提升程序在系统中的执行&响应效果。本文介绍的问题便是将大的 PDF 文件拆分,然后根据用户的交互行为按需加载,从而达到提升用户在线阅读体验的目的。
当然上述方案还存在很多优化空间,比如我们可以通过 IntersectionObserver API 结合容器 margin 的调整来实现 PDF 内容的滚动及页面元素的复用。具体的实现大家有兴趣可以自己尝试。
实际使用场景中,我们也遇到了一些坑。上述方案在进行页面渲染时,会预先初始化整个容器( contentView)的大小。并且我们是根据第一次获取的 PDF 页面的大小进行计算容器高度的(页面高度 * 总页数)。这里有一个前提,就是我们假定所有的 PDF 页面大小是一样的,但在实际场景中,很可能出现同一个 PDF 文档中,页面大小不一样的情况。这时就会出现加载页面位置不准确或者内容展示被遮挡的情况。
针对上述问题,目前我们思考了两种方案:
本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/L5pGAuqM3Pd9VmUmSXV39A
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。