PDF文件現在在許多企業中常用 - 無論您是要生成銷售報告,交付合同還是發送發票,PDF都是首選的文件類型。PDF.js是由Mozilla編寫的JavaScript庫。由于它使用vanilla JavaScript實現PDF渲染,因此它具有跨瀏覽器兼容性,并且不需要安裝其他插件。在使用PDFJS之前你也可以先了解下原生的PDF<object>對象,本文僅介紹PDFJS。
https://mozilla.github.io/pdf.js/
官網提供了下載入口,有穩定版和Beta版,我們要在生產環境下使用建議使用穩定版,官網給我們提供了三種獲取PDF.js的方式
我們可以直接使用cdn服務,也可以將下載的文件引入,我們看一下示例代碼,這里我提供了兩種寫法,在項目運行之前,請確保你的同級目錄下有一個test.pdf文件
//index.html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@2.0.943/build/pdf.min.js"></script> <script src='./index.js'></script> <title>PDF</title> </head> <body> <canvas id="pdf"></canvas> </body> </html> //index.js // var loadingTask = pdfjsLib.getDocument("test.pdf"); // loadingTask.promise.then( // function(pdf) { // // 加載第一頁 // pdf.getPage(1).then(function(page) { // var scale = 1; // var viewport = page.getViewport(scale); // //應用到頁面的canvas上. // var canvas = document.getElementById("pdf"); // var context = canvas.getContext("2d"); // canvas.height = viewport.height; // canvas.width = viewport.width; // // 渲染canvas. // var renderContext = { // canvasContext: context, // viewport: viewport // }; // page.render(renderContext).then(function() { // console.log("Page rendered!"); // }); // }); // }, // function(reason) { // console.error(reason); // } // ); // index.js (async () => { const loadingTask = pdfjsLib.getDocument("test.pdf"); const pdf = await loadingTask.promise; // 加載第一頁. const page = await pdf.getPage(1); const scale = 1; const viewport = page.getViewport(scale); // 應用到頁面的canvas上. const canvas = document.getElementById("pdf"); const context = canvas.getContext("2d"); canvas.height = viewport.height; canvas.width = viewport.width; // 渲染canvas. const renderContext = { canvasContext: context, viewport: viewport }; await page.render(renderContext); })();
當我們運行項目之后,打開瀏覽器查看,它已經將pdf的內容渲染到了瀏覽器中,且顯示了第一頁,如下圖所示:
如果就這樣的話遠遠是無法滿足我們使用的,因此我們來看一下它比較高級的用法,或者說簡單的用法,高級的功能。
首先我們將我們下載的js包加壓,復制里面的web文件夾,粘貼到你的項目目錄
然后修改你的index.html代碼,首先注釋掉之前引入的js代碼,然后修改body,如下
<body> <iframe src="test.pdf" style="border: none;" width="100%" height='1000px'></iframe> </body>
隨后打開我們的瀏覽器,你會發現一個預覽的窗口
它繼承了我們常用的功能,比如旋轉、下載、打印、自適應縮放、放大、縮小等,我們只需要使用iframe引入我們的pdf文件即可,其余的全部交給pdf來完成,即可獲得一個實現一個完整的pdf預覽功能。
PDFJS的這三層分開,讓我們很好的來根據業務需求來實現我們想要的部分,其簡單的api讓我們得心應手,總而言之,PDFJS是一個絕佳的PDF預覽解決方案。
PDFJS不僅僅支持pdf的二進制文件,同樣還支持base64編碼的pdf,如果在你的項目中需要用到pdf的預覽等功能,無疑它是一種良好的解決方案,當然想要實現相同的功能有許多辦法,我們可以選擇最適合我們需求的,官方還提供了一個完整的演示Demo,如下截圖,如果你覺得本文對你有幫助,請麻煩轉發、點贊加關注吧,后續會分享更多實用有趣的技術!
?
需求:實現一個在線預覽pdf、excel、word、圖片等文件的功能。
介紹:支持pdf、xlsx、docx、jpg、png、jpeg。
以下使用Vue3代碼實現所有功能,建議以下的預覽文件標簽可以在外層包裹一層彈窗。?
iframe標簽能夠將另一個HTML頁面嵌入到當前頁面中,我們的圖片也能夠使用iframe標簽來進行展示。
<iframe :src="圖片地址"
style="z-index: 1000; height:650px; width: 100%; margin: 0 auto"
sandbox="allow-scripts allow-top-navigation allow-same-origin allow-popups"
>
「sandbox」這個屬性如果是單純預覽圖片可以不使用,該屬性對呈現在 iframe 框架中的內容啟用一些額外的限制條件。屬性值可以為空字符串(這種情況下會啟用所有限制),也可以是用空格分隔的一系列指定的字符串。
先下載npm包
npm i docx-preview --save
<div class="docxRef"></div>
<script>
import { renderAsync } from 'docx-preview';
function fn() {
// 這里的res.data是 blob文件流,如果自己的不是blob文件流
// 可以通過URL.createObjectURL(參數) 參數為File格式,轉換為blob文件流
let blob = res.data
let childRef = document.getElementsByClassName('docxRef');
renderAsync(blob, childRef[0]) //渲染
}
fn()
</script>
「blob文件流」
下載包
npm install xlsx@0.16.0
<div class="xlsxClass"></div>
const reader = new FileReader();
//通過readAsArrayBuffer將blob轉換為ArrayBuffer對
reader.readAsArrayBuffer(res.data) // 這里的res.data是blob文件流
reader.onload = (event) => {
// 讀取ArrayBuffer數據變成Uint8Array
var data = new Uint8Array(event.target.result);
// 這里的data里面的類型和后面的type類型要對應
var workbook = XLSX.read(data, { type: "array" });
var sheetNames = workbook.SheetNames; // 工作表名稱
var worksheet = workbook.Sheets[sheetNames[0]];
// var excelData = XLSX.utils.sheet_to_json(worksheet); //JSON
let html = XLSX.utils.sheet_to_html(worksheet);
document.getElementsByClassName('xlsxClass')[0].innerHTML = html
};
下載包 npm install pdfjs-dist
我使用的是npm install pdfjs-dist@2.0.943版本,以下例子使用的是vue3+vite創建的項目
以下例子通過canvas來渲染pdf
<template>
<div class="box">
<div class="tool-bar">
<div>{{ pdfParams.pageNumber }} / {{ pdfParams.total }}</div>
<button type="primary" :disabled="pdfParams.pageNumber == pdfParams.total" @click="nextPage">下一頁
</button>
<button type="primary" :disabled="pdfParams.pageNumber == 1" @click="prevPage">上一頁</button>
</div>
<canvas id="pdf-render"></canvas>
</div>
</template>
<script setup>
import { onMounted, ref, reactive } from 'vue'
const pdfParams = reactive({
pageNumber: 1, // 當前頁
total: 0, // 總頁數
});
// 不要定義為ref或reactive格式,就定義為普通的變量
let pdfDoc = null;
// 這里必須使用異步去引用pdf文件,直接去import會報錯,也不知道為什么
onMounted(async ()=> {
let pdfjs = await import('pdfjs-dist/build/pdf')
let pdfjsWorker = await import('pdfjs-dist/build/pdf.worker.entry')
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorker
// 此文件位于public/test2.pdf
let url = ref('/test2.pdf')
pdfjs.getDocument(url.value).promise.then(doc => {
pdfDoc = doc
pdfParams.total = doc.numPages
getPdfPage(1)
})
})
// 加載pdf的某一頁
const getPdfPage = (number) => {
pdfDoc.getPage(number).then(page => {
const viewport = page.getViewport()
const canvas = document.getElementById('pdf-render')
const context = canvas.getContext('2d')
canvas.width = viewport.viewBox[2]
canvas.height = viewport.viewBox[3]
viewport.width = viewport.viewBox[2]
viewport.height = viewport.viewBox[3]
canvas.style.width = Math.floor(viewport.width) + 'px'
canvas.style.height = Math.floor(viewport.height) + 'px'
let renderContext = {
canvasContext: context,
viewport: viewport,
// 這里transform的六個參數,使用的是transform中的Matrix(矩陣)
transform: [1, 0, 0, -1, 0, viewport.height]
}
// 進行渲染
page.render(renderContext)
})
}
// 下一頁功能
const prevPage = () => {
if(pdfParams.pageNumber > 1) {
pdfParams.pageNumber -= 1
} else {
pdfParams.pageNumber = 1
}
getPdfPage(pdfParams.pageNumber)
}
// 上一頁功能
const nextPage = () => {
if(pdfParams.pageNumber < pdfParams.total) {
pdfParams.pageNumber += 1
} else {
pdfParams.pageNumber = pdfParams.total
}
getPdfPage(pdfParams.pageNumber)
}
</script>
以上pdf代碼引用文章:(54條消息) 前端pdf預覽、pdfjs的使用_pdf.js_無知的小菜雞的博客-CSDN博客
pdfjs官方代碼:例子 (mozilla.github.io)
以上代碼看不懂的地方可以查閱官方代碼,大部分都是固定的寫法。
「以上注意點:」
近在實現共享 PDF 文檔的需求,存在主講人這樣一個角色,上傳 PDF 文檔后,通知其它連接中的終端,進行實時同步展示的功能。對于這樣的需求,pdf.js 成功的讓我想起了它。
PDF 文檔的預覽,總的就是要加載速度快,盡最快的速度完成渲染,呈現給用戶看,不要出現長時間的白屏或 Loading 狀態的現象,另外 PDF 文檔需要支持翻頁等操作。具體看看一步步的實現。
文檔分片下載速度
分片上傳文檔,支持秒傳,VUE 支持分片上傳的插件一搜一大把,可以采用 vue-simple-uploader 等,具體如何實現,這里不詳細論述,簡單貼一下秒傳校驗的實現。
import SparkMD5 from 'spark-md5';
/**
* 文件秒傳 MD5 校驗
* @param file 上傳的文件信息
*/
md5File(file) {
const fileReader = new FileReader(),
blobSlice = File.prototype.slice,
chunkSize = 1024 * 1000, // 分片大小
chunks = Math.floor(file.size / chunkSize), // 總的分片數量
spark = new SparkMD5.ArrayBuffer(); // 三方庫 SparkMD5
let currentChunk = 0;
// 加載分片
const loadNext = () => {
const start = currentChunk * chunkSize;
let end = file.size;
if (currentChunk < chunks - 1) end = start + chunkSize;
fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end));
}
// 暫停文件上傳
file.pause();
// 開始校驗文件MD5
loadNext();
fileReader.onload = (e) => {
spark.append(e.target.result);
// 小于總分片, 繼續加載
if (currentChunk < chunks - 1) {
currentChunk++;
loadNext();
} else {
// 分片全部加載完成, 生成 MD5
const md5 = spark.end();
// 開始服務端校驗 MD5 ( 秒傳 )
this.md5Success(md5, file);
}
};
fileReader.onerror = () => {
// 文件讀取出錯, 取消上傳
console.log(`文件${file.name}讀取出錯,請檢查該文件`);
file.cancel();
};
}
文件秒傳MD5校驗
一個 PDF 文檔,無法一次就預覽所有內容,在有限的可視區域內,只能顯示有限的內容,那我們就獲取能在有限區域內所能展示的那部分內容,以加快 Content Download 的速度,減少用戶第一次打開時的 Loading 時間。
假設一個 PDF 文檔有 1000 頁,以 5 頁為一片,將該文檔切分成 200 個分片,首次打開默認請求第一個分片,其后根據翻頁來確定是否繼續加載后續的分片信息(如需刷新后仍然展示剛剛所在頁,則需記錄當前頁,根據該值與分片的頁數來確定當前屬于第幾個分片,進而再請求相應分片即可)。
服務端如何進行分片,則交給服務端就好了,這里就不詳細說了(得注意下中文亂碼的情況)。假設文件信息格式及單個分片的請求地址如下所示:
/**
* 文件信息.
* 在文件上傳后即可拿到.
*/
const file = {
id: 1,
md5: 'e10adc3949ba59abbe56e057f20f883e',
total: 1000,
name: 'VUE 如何實現高性能的 PDF 在線預覽',
// ...
}
/**
* 請求分片.
* $http 是我針對 axios 的一些常用方法,攔截器等重新封裝后工具類庫
*/
文件信息格式及分片請求地址格式
pdf.js 接口中,getDocument 可用于獲取遠程文檔,返回 PDFDocumentLoadingTask 對象,該對象是一個下載遠程 PDF 文檔的任務,提供了一些監聽方法,可通過 promise 拿到下載完成的 PDF 對象,最終會生成并返回 PDFDocumentProxy 對象,我們接下來所有的操作都是基于該代理類進行的。
注意在 PDF 文檔中存在有中文時,會出現不顯示的情況,控制臺也會報如下的錯誤提示
Warning: The CMap "baseUrl" parameter must be specified, ensure that the "cMapUrl" and "cMapPacked" API parameters are provided.
主要是 PDF 文檔內容存在不支持的字體,暫且引入三方字體來解決該問題
const url = `https://www.makeit.vip/${md5}-${page}.pdf?id=${fid}&token=${token}`,
cMapUrl = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.4.456/cmaps/';
const
promise = PDFJS.getDocument({
url,
cMapUrl,
cMapPacked: true
}).promise;
因為我實現的是 PDF 一頁的內容,按屏幕尺寸 100% 寬度來顯示的,這樣很容易高度就超出可視范圍了,所以單頁采用滾動形式,多頁則采用按鈕觸發翻頁的形式來展示(為了更好的適配不同尺寸的屏幕,顯示的效果與主講人完全同步),并非采用一字往下無限排,滾動翻頁的形式。
/**
* 獲取分片
* @param fid 文件ID
* @param md5 文件唯一標識
* @param token 授權碼
* @param num 第N個分片
*/
public getFragmentation(
fid: number,
md5: string,
token: string,
num = 0
) {
// 請求分片, 得到 promise...
// ...
promise.then((pdf: any) => {
for (let i = 1; i <= pdf.numPages; i++) {
pdf.getPage(i).then((page) => {
const pagination = num * 5 + i;
this.renderPage(pagination, page);
});
}
});
}
/**
* 渲染分頁內容.
* @param pagination 第N頁
* @param page 分頁屬性
*/
protected renderPage(
pagination: number,
page: any
) {
// 根據縮放比例, 獲取文檔的可視屬性
const viewport = page.getViewport({scale: 1});
// 創建用于渲染的Canvas元素
const canvas = document.createElement('canvas'),
context = canvas.getContext('2d');
canvas.width = viewport.width;
canvas.height = viewport.height;
// 渲染文檔
const renderContext = {
canvasContext: context,
viewport
};
return page.render(renderContext).promise;
}
在上一頁/下一頁的不斷操作中,1000頁的內容,不斷的進行渲染,難不成要渲染1000個DOM出來?顯然不合理,非得把瀏覽器給搞崩了才肯罷休嗎?幾十個 Canvas 就讓你卡的不要不要的了。具體實現也簡單,保證只顯示 5 個的前提下,根據上一頁或下一頁的操作,增加或刪除相應的 DOM即可。最后貼一下稍微完整一些的代碼(稍微加了一些注釋)。
/**
* 獲取分片
* @param fid 文件ID
* @param md5 文件唯一標識
* @param token 授權碼
* @param num 第N個分片
* @param showPage 顯示第N個分片中的第X頁
* @param speaker 是否為主講人
* @param render 是否直接渲染
* @param clear 是否清除原有內容
*/
public getFragmentation(
fid: number,
md5: string,
token: string,
num: number = 0,
showPage = 1,
speaker?: boolean,
render?: boolean,
clear?: boolean
): Promise<any> {
const url = `${process.env.VUE_APP_PROXY_SERVER}/${md5}-${num}.pdf?id=${fid}&token=${token}`,
cMapUrl = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.4.456/cmaps/';
let promise = PDFJS.getDocument({
url,
cMapUrl,
cMapPacked: true
}).promise;
promise.then((pdf: any) => {
/** 記錄 PDFDocumentProxy 對象, 可避免重復請求已經請求過的分片 */
this.files.page = showPage;
if (!this.files.pdfs) this.files.pdfs = {} as any;
this.files.pdfs[num] = pdf;
});
/** 是否執行渲染操作 */
if (render) {
/** 清除 */
if (clear) {
const documents = this.getContainer() as HTMLDivElement;
if (documents) documents.innerHTML = '';
}
/** 渲染 - 重新賦值 promise, 保證加載完成后的操作時序 */
promise = new Promise((resolve) => {
promise.then((pdf: any) => {
/** 開始遍歷循環 */
for (let i = 1; i <= pdf.numPages; i++) {
pdf.getPage(i).then((page) => {
const pagination = num * 5 + i;
if (!this.files.paginations[pagination]) {
/** 存儲分頁信息(寬/高/ID等 - ID可用于判斷是否已經渲染及清除DOM操作) */
this.files.paginations[pagination] = {} as any;
}
this.files.paginations[pagination].id = Utils.uid();
const renderFinish = this.renderPage(pagination, page, speaker);
if (renderFinish) {
renderFinish.then(() => {
if (i === pdf.numPages) {
/**
* 1. 當前為最后一頁或倒數第2頁, 請求下一分片
* 2. 當前為第一頁或第2頁, 請求上一分片
*/
const left = showPage % 5;
if (
left === 0 ||
left === 4
) {
/** 回調 - 請求下一個分片 */
if (num + 1 <= this.files.total) {
this.getFragmentation(
fid,
md5,
token,
num + 1,
showPage,
speaker
).then(() => {
/**
* 下一個分片請求成功后的處理
* 根據剩余個數, 決定繼續渲染下一分片的1頁還是2頁
*/
this.getFragmentationSuccess(
showPage,
left ? 1 : 0
);
});
}
} else if (
left === 1 ||
left === 2
) {
/** 回調 - 請求上一個分片 */
if (num - 1 >= 0) {
this.getFragmentation(
fid,
md5,
token,
num - 1,
showPage,
speaker
).then(() => {
this.getFragmentationSuccess(
showPage,
left === 2 ? 1 : 0,
'prev'
);
});
}
}
/**
* 渲染完成后返回.
* 因為我需要5頁全部渲染完成后,初始化每一頁上面的涂鴉功能,
* 所以我在最后才返回 Promise, 以保證時序的正確性.
* 若僅僅是展示, 沒有其它功能的話, 無需返回 Promise.
*/
resolve();
}
});
}
});
}
});
});
}
return promise;
}
/**
* 渲染分頁內容.
* @param pagination 第N頁
* @param page 分頁屬性
* @param speaker 是否為主講人
* @param type 類型(上下頁區分)
*/
protected renderPage(
pagination: number,
page: any,
speaker = false,
type = 'next'
): Promise<any> | void {
const documents = this.getContainer() as HTMLDivElement;
if (documents) {
const item = this.createPage(pagination),
pageView = page.view,
scale = this.getScale(pageView, speaker, item),
viewport = page.getViewport({scale});
if (this.files.page !== pagination) item.style.display = 'none';
/** 這個就是保存一些用得到的屬性, 具體實現代碼就不貼出來了. */
this.setPaginationAttrs(
pagination,
viewport,
pageView,
scale
);
/** 創建元素 */
const canvas = document.createElement('canvas'),
context = canvas.getContext('2d');
canvas.width = viewport.width;
canvas.height = viewport.height;
item.appendChild(canvas);
/** 判斷是要插入還是追加元素 */
if (type === 'next') documents.appendChild(item);
else if (documents.firstChild) documents.insertBefore(item, documents.firstChild);
/** 渲染文檔 */
const renderContext = {
canvasContext: context,
viewport
};
return page.render(renderContext).promise;
}
}
/**
* 獲取縮放比.
* @param origin 文檔原始尺寸
* @param speaker 是否為主講人(主講人默認以寬為基準)
* @param wrapper 畫布容器(超出寬度的話, 需要手動設置高度)
* @param width 待變更元素的寬度
*/
protected getScale(
origin: any,
speaker: boolean,
wrapper?: HTMLDivElement,
width?: number
): number {
width = width ?? 0;
const documents = this.getContainer() as HTMLDivElement;
if (documents && !width) width = documents.offsetWidth;
if (speaker) {
/**
* 左右兩邊增加了一些偏移量(主講人與普通用戶大小不一樣, 這個函數的代碼也沒啥好貼)
* 主講人默認是以屏幕寬度為基準進行文檔縮放的.
*/
const offsetWidth = this.getOffsetWidth(width);
return Math.round(offsetWidth / origin[2] * 100) / 100;
} else {
/**
* 非主講人為了與主講人顯示內容一致, 默認采用高度為基準, 但有特殊情況,
* 就是高度保證一致的情況下, 寬度卻超出了屏幕的可視區域, 這時候就要將
* 文檔顯示區域所在的容器高度進一步縮短, 保證寬度是在可視區域內, 具體
* 實現就看 getHeightAndScale 這個方法了.
*/
const heightAndScale = this.getHeightAndScale(documents, origin),
scale = heightAndScale.scale;
if (wrapper && heightAndScale.height) wrapper.style.height = `${heightAndScale.height}px`;
return scale;
}
}
/**
* 獲取文檔顯示高度與縮放比例.
* @param documents
* @param origin
*/
protected getHeightAndScale(
documents?: HTMLDivElement,
origin?: any
): {
height: number;
scale: number;
} {
documents = documents ?? this.getContainer() as HTMLDivElement;
let wrapperHeight = 0, lastScale = 0;
if (documents) {
const size = this.files.speaker, // 所記錄的主講人的屏幕尺寸
width = documents.offsetWidth, // 當前用戶顯示容器的可視寬度
height = documents.offsetHeight; // 當前用戶顯示容器的可視高度
let originWidth;
/** 獲取原始文檔寬度 */
if (!origin) {
const pagination = this.getActivePagination();
originWidth = pagination.originWidth;
} else {
originWidth = origin[2];
}
/**
* 計算主講人的縮放比.
* 往下出現的 200 / 150 之類的常數, 為設定好的顯示偏移量.
*/
const speakerRatio = Math.round((size.width - 200) / originWidth * 100) / 100;
/** 非主講人默認以高度為基準來計算文檔顯示的縮放比例 */
lastScale = Math.round(speakerRatio / (size.height - 150) * (height - 100) * 100) / 100;
/** 如果以高度為基準的情況下, 判定寬度是否超出可視區域 */
const destWidth = Math.round(originWidth * lastScale * 100) / 100,
offsetWidth = width - (this.isSpeaker() ? 200 : 120),
diffWidth = destWidth - offsetWidth;
if (diffWidth > 0) {
/**
* 如果超出可視區域, 重新設定縮放比,
* 文檔內容顯示所在的DIV容器, 將進一步縮小,
* 以保證寬度在正常的可視區域內
*/
wrapperHeight = (Math.round((offsetWidth * (size.height - 150)) / (size.width - 200) * 100) / 100);
lastScale = Math.round(offsetWidth / originWidth * 100) / 100;
}
}
return {
height: wrapperHeight,
scale: lastScale
};
}
翻頁控制的代碼我就不貼出來了,與請求分片中的判定類似??偟膶崿F,沒有太大的難點,理清思路之后就很好實現了,上傳速度快慢先不說,秒傳校驗通過的情況下,基本在 16ms 內完成 Content Download,直至頁面渲染出來,整個過程大概 1s 左右,有個前提是我的實現是等 5 個分頁都渲染完成后又進行了一系列的涂鴉初始化操作后的時間,不做其它處理,只做展示,速度將會更快。
*請認真填寫需求信息,我們會在24小時內與您取得聯系。