实现原理

使用 uni-app 的 <web-view> 嵌入本地 HTML 页面,通过 PDF.js 来渲染 PDF,实现跨平台的移动端 PDF 浏览。PDF.js 是一个纯 JavaScript 库,基于 HTML5 Canvas API 绘制 PDF 页面。在实现上,本地 HTML 页面包含一个用于显示 PDF 的容器(如 <div id="myPDF">),底层通过 Canvas 渲染每一页内容,并支持滑动翻页、缩放等交互。盖章盖章功能则通过在 PDF 预览层上叠加可拖拽的 DOM 图层来实现:页面上创建一个绝对定位的容器(如 #my-signImg)用来显示盖章图片,用户可通过触摸事件拖动或通过右下角的缩放柄调整大小。当前时间戳则先在一个隐藏的 Canvas 上绘制,然后转成图片添加到叠加层。所有交互产生的数据(如盖章位置、页码等)通过 uni.postMessage 发送回 uni-app 侧进行后续处理。PDF.js 自身对多页有良好支持,可渲染多页并内置缩略图、缩放、翻页等功能,因此该实现可以在单一容器中连续查看多页 PDF 并对任意一页进行签章。

关键模块

  • PDF 预览模块:位于 SignPdf.vue,使用 <web-view :src="..."> 加载 hybrid/html/readPdf/index/index.html。该页面通过引入 pdf.jsjquery.touchPDF.js 等脚本,完成 PDF 文档的加载和渲染。用户在此视图中可以翻页、缩放 PDF 内容。

  • 签章叠加层:在 HTML 中以一个绝对定位的 div 作为盖章容器(#my-signImg),内部含有盖章图片(<img id="signUrl">)和缩放柄(<span class="br">)。盖章完成后,还会动态创建另一个类似的容器用于显示时间戳图片。通过监听触摸事件,实现对这两个层的拖拽移动和右下角拉伸缩放。

  • 盖章画板模块:独立的 sign.vue 页面提供一个可手写盖章的画布(<canvas>),用户可以绘制或清除笔迹、将盖章保存成图片等。完成后可返回上一页(SignPdf)以作为盖章图像。

  • 通信模块:Web 页面的盖章操作(点击“重新盖章”或“保存”)会调用 uni.postMessage() 向 uni-app 页面传递指令或数据(如 {type:'reSign'} 或签章位置信息)。SignPdf.vue@message 事件监听接收这些数据,根据类型跳转到盖章页面或结束签章流程。

技术亮点

  • 纯前端跨平台渲染:采用 PDF.js 在浏览器/WebView 中渲染 PDF,无需依赖原生组件或第三方插件,可统一 iOS、Android 环境,开发和调试效率高。基于 Canvas 的绘制保证了在不同设备上对文字和图形的渲染一致性。

  • 交互友好:结合 jquery.touchPDF.jsjquery.panzoom.js 等插件,实现了手势缩放、滑动翻页等移动端友好功能;盖章盖章通过可视化拖拽、拉伸操作完成,符合用户习惯。

  • 多页文档支持:PDF.js 天生支持多页文档的加载与渲染,该 Demo 可连续翻阅多页并针对任意页添加盖章盖章;同时提供缩略图、缩放控制等功能。

部分代码

<template>
	<view class="content">
		<view>
			<web-view :src="websrc" @message="handleMessage"></web-view>
		</view>
	</view>
</template>
<script>
	export default {
		data() {
			return {
				websrc: '',
				appUrl: '/hybrid/html/readPdf/index/index.html', //app内的web地址
				pdfUrl: {
					url: './my.pdf'
				},
				signImgUrl: {
					url: './index (1).png'
				},
				title: '合同盖章', //pdf文件名称
			}
		},
		onLoad(query) {
			this.refresh(query)
		},
		methods: {
			async refresh(query) {
				try {
					uni.showLoading({
						title: '加载中'
					})
					//app 直接跳转到app内的web页面
					this.websrc = this.appUrl + '?url=' + encodeURIComponent(this.pdfUrl.url) + '&tname=' + encodeURIComponent(this.title) + '&signImgUrl=' + encodeURIComponent(this.signImgUrl.url)
				} catch (e) {
					uni.showModal({
						title: '发生错误',
						content: e
					})
				} finally {
					uni.hideLoading()
				}
			},
			async handleMessage(evt) {
				if (evt.detail.data[0]?.type == 'message') {
					uni.showToast({
						title: evt.detail.data[0]?.content,
						duration: 2000
					});
				}else if (evt.detail.data[0]?.type == 'loading') {
					uni.showLoading({
						title: '导出pdf中...'
					});
				}else if (evt.detail.data[0]?.type == 'save') {
					uni.showLoading({
						title: '正在打开...'
					});
					plus.io.requestFileSystem(plus.io.PRIVATE_DOC, function(fs) {
						console.log(fs.root.fullPath)
						const filePath = `${fs.root.fullPath}files/${Date.now()}.pdf`;
						console.log(`filePath: ${filePath}`); // 打印正确的filePath
						fs.root.getFile(filePath, {
							create: true
						}, function(fileEntry) {
							console.log('filePath' + filePath)
							fileEntry.createWriter(function(writer) {
								writer.onwrite = function() {
									console.log(`success filePath: ${filePath}`); // 在输出时使用模板字符串
									uni.openDocument({
										filePath: filePath,
										showMenu: true,
										success: () => {
											uni.hideLoading()
										},
										fail: (error) => {
											uni.hideLoading()
											console.log(error.message || '操作失败,请稍后再试');
										}
									})
								}
								writer.onerror = function(e) {
									uni.hideLoading()
									console.log('写入文件失败:' + e.message)
								}
								// console.log(evt.detail.data[0]?.base64);
								writer.writeAsBinary(evt.detail.data[0]?.base64)
							})
						})
					},
				}
			}
		},
	}
</script>
<style>
</style>