Bootstrap

Uniapp app在线预览pdf文件流

最近项目需要开发个新需求,在app里实现pdf在线预览,并且公司为了防止直接访问pdf链接,所以后端考虑用pdf流的格式通过接口返回给前端,一开始以为是个很简单的,毕竟uniapp里成熟的预览pdf插件还是不在少数,以及兼容也很好,感觉1小时就结束了,结果硬是搞了快两天,中间遇到的坑以及试过的方法差点崩溃 -.-,顺便写个博客记录下开发这个需求碰到的一些问题以及最后的解决方案!!

开发思路

1.首先要确定后台返回的pdf流是什么格式;

2.返回的数据是单个还是多个是否需要拼接来生成完整的pdf流;

3.把后端返回的原始的字节数据转换成可以操作的下标数组;

4.把转换过的数据再转换成base64字符串;

5.把base64字符串生成本地路径打开,至此大功告成;

踩到的坑

这里简单描述下自己开发过程中踩到的坑,以及尝试的各种方法,最后才总结出自己的这一套app在线预览pdf的开发思路,如果大佬有更好的解决思路,欢迎在评论区讨论!

1.new Blob( array, options )+ pdf.js 。Blob是前端的一个专门用于支持文件操作的二进制对象 ,将二进制数据存储为一个个体的集合,通常是视频,音乐,或者多媒体文件,目的是为了更好的操作二进制数据对象,使用blob构造函数返回一个新的 Blob 对象。blob 的内容由参数数组中给出的值的串联组成,然后再将blob转成路径URL.createObjectURL(blob),再在项目根目录下新建hybrid文件夹,将下载的pdf.js压缩包解压后,复制到hybrid下的html文件夹中,路径拼接好,再通过web-view打开。这种事最简单的实现方式,但是app里无法使用blob函数,因此这个方法无法使用。具体代码如下:

pdf.js官网下载

<template>
	<view>
		<web-view :src="allUrl"></web-view>
	</view>
</template>

<script>
	export default {
		data() {
			return {
				viewerUrl: '/hybrid/html/web/viewer.html?file=',
				allUrl: '',
			}
		},
		onShow() {
			this.getPdf()
		},
		methods: {
			getPdf() {
				let params = {
					...
				}
				uni.request({
					url: `url`,
					method: 'POST',
					data: params,
					responseType: 'arraybuffer', 
					success: (response) => {
						if (!response) {
							uni.showToast({
								title: "预览失败",
								duration: 2000
							});
						}
						let pdfData = response.data; //pdfData是后端返回的文件流
						let blob = new Blob([pdfData], {
							type: 'application/pdf'
						})
						pdfData = URL.createObjectURL(blob) //创建预览路径
						this.allUrl = this.viewerUrl+encodeURIComponent(pdfData)
					},
					fail: err => {
						console.log(err)
					}
				});
			}
		}
	}
</script>
<style>
</style>

2.使用uni.openDocument方法,会去打开手机内可以打开pdf的应用程序来实现预览pdf,不过这个方法跟需求不一样,因此此方法也无法使用。具体代码如下:

uniapp.openDocument使用

// 假设你已经有了PDF流的数据,这里用pdfData代替
const pdfData = '...'; // PDF流数据,通常是一个base64编码的字符串
 
// 将PDF流数据转换为二进制数组
const binaryArray = [];
for (let i = 0; i < pdfData.length; i++) {
  binaryArray.push(pdfData.charCodeAt(i));
}
const uint8array = new Uint8Array(binaryArray);
 
// 创建一个Blob对象
const blob = new Blob([uint8array], { type: 'application/pdf' });
 
// 创建一个URL指向该Blob对象
const url = URL.createObjectURL(blob);
 
// 使用uniapp.openDocument API打开文档进行预览
uni.openDocument({
  filePath: url,
  success: function (res) {
    console.log('文档打开成功');
  },
  fail: function (err) {
    console.error('文档打开失败', err);
  }
});

3.最后才拍案决定的实现效果思路,把base64生成本地路径(storage的路径,尽可放心生成,清除手机缓存即可)再实现app在线预览。具体代码如下:

用到的技术点

1.uni.base64ToArrayBuffer(base64),将 后台返回的Base64 字符串转成 ArrayBuffer 对象;
uni.base64ToArrayBuffe用法

2.new Uint8Array(arrLength),因为ArrayBuffer 为原始的字节序列,不是所谓的“数组”,无法用下标来查看,因此需要使用 Uint8Array来实现访问;

3.uni.arrayBufferToBase64(arrayBuffer),将 ArrayBuffer 对象转成 Base64 字符串;
uni.arrayBufferToBase64用法

4.用到了image-tools.js把base64转换成本地路径 ,再使用plus.io.convertLocalFileSystemURL(path),将本地URL路径转换成平台绝对路径

5.使用uniapp插件市场中的pdf阅读器来实现在app里预览pdf文件
Android iOS PDF阅读器

这里说下,为啥要把base64转成成arrayBuffer,再转换成base64,因为后端返回的是需要合并的pdf流,所以需要进行转换的操作来进行循环合并再生成完整的base64

代码实现

废话不多说,开始实现自己的思路

image-tools.js代码,粘过去放置common或者util文件夹下

function getLocalFilePath(path) {
    if (path.indexOf('_www') === 0 || path.indexOf('_doc') === 0 || path.indexOf('_documents') === 0 || path.indexOf('_downloads') === 0) {
        return path
    }
    if (path.indexOf('file://') === 0) {
        return path
    }
    if (path.indexOf('/storage/emulated/0/') === 0) {
        return path
    }
    if (path.indexOf('/') === 0) {
        var localFilePath = plus.io.convertAbsoluteFileSystem(path)
        if (localFilePath !== path) {
            return localFilePath
        } else {
            path = path.substr(1)
        }
    }
    return '_www/' + path
}

function dataUrlToBase64(str) {
    var array = str.split(',')
    return array[array.length - 1]
}

var index = 0
function getNewFileId() {
    return Date.now() + String(index++)
}

function biggerThan(v1, v2) {
    var v1Array = v1.split('.')
    var v2Array = v2.split('.')
    var update = false
    for (var index = 0; index < v2Array.length; index++) {
        var diff = v1Array[index] - v2Array[index]
        if (diff !== 0) {
            update = diff > 0
            break
        }
    }
    return update
}

export function pathToBase64(path) {
    return new Promise(function(resolve, reject) {
        if (typeof window === 'object' && 'document' in window) {
            if (typeof FileReader === 'function') {
                var xhr = new XMLHttpRequest()
                xhr.open('GET', path, true)
                xhr.responseType = 'blob'
                xhr.onload = function() {
                    if (this.status === 200) {
                        let fileReader = new FileReader()
                        fileReader.onload = function(e) {
                            resolve(e.target.result)
                        }
                        fileReader.onerror = reject
                        fileReader.readAsDataURL(this.response)
                    }
                }
                xhr.onerror = reject
                xhr.send()
                return
            }
            var canvas = document.createElement('canvas')
            var c2x = canvas.getContext('2d')
            var img = new Image
            img.onload = function() {
                canvas.width = img.width
                canvas.height = img.height
                c2x.drawImage(img, 0, 0)
                resolve(canvas.toDataURL())
                canvas.height = canvas.width = 0
            }
            img.onerror = reject
            img.src = path
            return
        }
        if (typeof plus === 'object') {
            plus.io.resolveLocalFileSystemURL(getLocalFilePath(path), function(entry) {
                entry.file(function(file) {
                    var fileReader = new plus.io.FileReader()
                    fileReader.onload = function(data) {
                        resolve(data.target.result)
                    }
                    fileReader.onerror = function(error) {
                        reject(error)
                    }
                    fileReader.readAsDataURL(file)
                }, function(error) {
                    reject(error)
                })
            }, function(error) {
                reject(error)
            })
            return
        }
        if (typeof wx === 'object' && wx.canIUse('getFileSystemManager')) {
            wx.getFileSystemManager().readFile({
                filePath: path,
                encoding: 'base64',
                success: function(res) {
                    resolve('data:image/png;base64,' + res.data)
                },
                fail: function(error) {
                    reject(error)
                }
            })
            return
        }
        reject(new Error('not support'))
    })
}

export function base64ToPath(base64) {
    return new Promise(function(resolve, reject) {
        if (typeof window === 'object' && 'document' in window) {
            base64 = base64.split(',')
            var type = base64[0].match(/:(.*?);/)[1]
            var str = atob(base64[1])
            var n = str.length
            var array = new Uint8Array(n)
            while (n--) {
                array[n] = str.charCodeAt(n)
            }
            return resolve((window.URL || window.webkitURL).createObjectURL(new Blob([array], { type: type })))
        }
        var extName = base64.split(',')[0].match(/data\:\S+\/(\S+);/)
        if (extName) {
            extName = extName[1]
        } else {
            reject(new Error('base64 error'))
        }
        var fileName = getNewFileId() + '.' + extName
        if (typeof plus === 'object') {
            var basePath = '_doc'
            var dirPath = 'uniapp_temp'
            var filePath = basePath + '/' + dirPath + '/' + fileName
            if (!biggerThan(plus.os.name === 'Android' ? '1.9.9.80627' : '1.9.9.80472', plus.runtime.innerVersion)) {
                plus.io.resolveLocalFileSystemURL(basePath, function(entry) {
                    entry.getDirectory(dirPath, {
                        create: true,
                        exclusive: false,
                    }, function(entry) {
                        entry.getFile(fileName, {
                            create: true,
                            exclusive: false,
                        }, function(entry) {
                            entry.createWriter(function(writer) {
                                writer.onwrite = function() {
                                    resolve(filePath)
                                }
                                writer.onerror = reject
                                writer.seek(0)
                                writer.writeAsBinary(dataUrlToBase64(base64))
                            }, reject)
                        }, reject)
                    }, reject)
                }, reject)
                return
            }
            var bitmap = new plus.nativeObj.Bitmap(fileName)
            bitmap.loadBase64Data(base64, function() {
                bitmap.save(filePath, {}, function() {
                    bitmap.clear()
                    resolve(filePath)
                }, function(error) {
                    bitmap.clear()
                    reject(error)
                })
            }, function(error) {
                bitmap.clear()
                reject(error)
            })
            return
        }
        if (typeof wx === 'object' && wx.canIUse('getFileSystemManager')) {
            var filePath = wx.env.USER_DATA_PATH + '/' + fileName
            wx.getFileSystemManager().writeFile({
                filePath: filePath,
                data: dataUrlToBase64(base64),
                encoding: 'base64',
                success: function() {
                    resolve(filePath)
                },
                fail: function(error) {
                    reject(error)
                }
            })
            return
        }
        reject(new Error('not support'))
    })
}

代码逻辑实现

<view v-for="(item,index) in fileList" :key="index" class="file-item" :class="'color-'+(index+1)" @click="goPage(item)">
	<image :src="item.icon" mode="aspectFit"></image>
	<view>{{item.name}} 预览</view>
</view>
import { base64ToPath } from '@/util/image-tools.js'
data() {
	return {
		allUrl: '',//传过去的路径
		titleSubview: '',
		fileList: [
			{
				type: 'pdf',
				name: 'pdf',
				icon: '../../static/pdf.png',
				id:'xxxxx'
				src: '',
				title: ''//获取pdf名称
			}
		]
	}
},
goPage(item) {
	uni.showToast({
		title: '获取' + item.name + '内容...',
		icon: 'loading',
		duration: 2000,
		mask: true
	});
	//我这是因为传不同的id,获取不同的内容
	this.getData(item.id, item.type).then(res => {
		item.title = res.name
		let stream = res.stream;
		let binaryData = [];
		for (var i in stream) {
			var binary_string = uni.base64ToArrayBuffer(stream[i]);
			binaryData.push(binary_string)
		}
		let fileData = this.mergeArrayBuffer(binaryData)
		let base64Data = uni.arrayBufferToBase64(fileData)
		if (item.type == 'pdf') {
			let pdfBase64 = `data:application/pdf;base64,${base64Data}}`;
			let filepathss = ''
			base64ToPath(pdfBase64).then(path => {
				filepathss = plus.io.convertLocalFileSystemURL(path);//生成平台相对路径
				item.src = filepathss
				uni.navigateTo({
					url: '/pages/pdfPage/pdfPage?item=' + encodeURIComponent(JSON.stringify(item))
				})
			})
		} else {
			let base64Img = `data:image/jpeg;base64,${base64Data}}`;
			item.src = base64Img
			uni.navigateTo({
				url: '/pages/pdfPage/pdfPage?item=' + encodeURIComponent(JSON.stringify(item))
			})
		}
	})
},
getData(id, type) {
	return new Promise((reslove, reject) => {
		uni.request({
			url: 'xxx',
			method: 'GET',
			data: {
				id: id,
			},
			success: (res) => {
				if (res.data.code == 200) {
					reslove(res.data.data)
				}
			}
		})
	})
},
mergeArrayBuffer(arrays) {
	let totalLen = 0;
	for (let arr of arrays) {
		totalLen += arr.byteLength;
	}
	let res = new Uint8Array(totalLen)
	let offset = 0
	for (let earr of arrays) {
		for (let arr of [earr]) {
			let uint8Arr = new Uint8Array(arr)
			res.set(uint8Arr, offset)
			offset += arr.byteLength
		}
	}
	return res.buffer
}

pdfPage页面,引入了插件市场的插件,具体可以pdf阅读器里的参数配置以及方法

<template>
	<view>
		<view class="pdfbox">
			<syczuan-pdfview v-if="show && type == 'pdf'" ref="pdfview" class="pdfview"  @load="pdfLoad" :config="config" />
		</view>
	</view>
</template>

<script>
	export default {
		data() {
			return {
				show: false,
				config: {
					// 文件路径 网络路径、本地绝对路径
					src: '',
					// 自定义网络pdf保存路径(src为网络路径时保存的本地路径)
					// 如果自定义路径保存失败则继续使用缓存路径显示pdf
					customFilePath: '/xxx/xxx/xxx/test.pdf',
					// 默认显示第N页(默认1)
					defaultPage: 1,
					// 浏览方向 竖向(默认):vertical 横向:horizontal
					reverse: 'vertical',
					// pdf密码
					password: '',
					// 页码访问区间(默认[],仅android)
					scope: [],
					// 开启双击控制缩放(安卓关闭会导致onTap事件失效,可设置zoom代替,默认true)
					enableDoubletap: true,
					// 启用抗锯齿(默认false,仅android)
					enableAntialiasing: true,
					// 开启滑动阻塞(默认false,仅android)
					obstruct: false,
					// 页面间距(背景颜色跟随组件背景,默认0)
					spacing: 10,
					// 错误页背景色(默认#dddddd,仅android)
					errorColor: '#dddddd',
					// 是否开启单页模式(默认false,仅ios)
					singlePage: false,
					// 开启页面回弹(默认false,仅ios)
					bounces: false
				},
				pages: {
					total: 0,
					current: 1
				},
				imageList:[],
				type:''
			}
		},
		onLoad(option) {
			let options = JSON.parse(decodeURIComponent(option.item))
			uni.setNavigationBarTitle({
				title: options.title
			});
			this.$nextTick(() => {
				this.type = options.type
				if(this.type == 'pdf'){
					this.config.src = options.src
					this.show = true;
				}
			})
		},
		mounted() {},
		methods: {
			// pdf加载完成
			pdfLoad(e) {
				console.log('pdfLoad', e.detail);
			}
		}
	}
</script>

<style lang="scss" scoped>
	.pdfbox {
		width: 750rpx;
		flex: 1;
	}

	.pdfview {
		flex: 1;
	}
</style>

小结

至此需求全部完成,并且在android和ios经过测试,效果都不错。只能说需求是什么,我们就去往这方面去想办法怎么实现,实现这个功能试了好多方法,以及用了好多插件,最终才选择这个比较好用的插件,也感谢这个插件的作者。其实做完后还有一个比较好的思路去实现,就是让后端把pdf链接进行加密,然后把解密方法告诉前端,去解密再直接去渲染pdf链接,这个应该是简单的方法了~~~~~。只能说后知后觉吧,好在是花费了两天的时间搞出来了,没有思路的同学可以借鉴参考下本文章,感谢阅读!!

悦读

道可道,非常道;名可名,非常名。 无名,天地之始,有名,万物之母。 故常无欲,以观其妙,常有欲,以观其徼。 此两者,同出而异名,同谓之玄,玄之又玄,众妙之门。

;