1、思路:pdf或者图片通过canvas预览,作为背景;印章则通过另一个canvas覆盖其上,使用fabric进行拖动。最后获取印章的相对位置、相对尺寸等信息,传递给后端,由后端生成最终文件。
2、npm install fabric
npm install pdfjs-dist
我用的是 fabric 5.3.0,pdfjs-dist 2.5.207 版本
3、代码部分
(1)pdf或者图片canvas预览 pdfOImgPreview.vue
*注意:pdfjs引用
<template> <div class="center"> <div v-if="isFilePDF"> <el-button size="mini" @click="prevPage">上一页</el-button> <el-button size="mini" @click="nextPage">下一页</el-button> <span>页码: {{ pageNum }} / <span id="page_count"></span></span> </div> <canvas id="the-canvas" /> </div> </template> <script> import * as PDFJS from "pdfjs-dist/legacy/build/pdf.js"; import pdfjsWorker from "pdfjs-dist/legacy/build/pdf.worker.entry"; PDFJS.GlobalWorkerOptions.workerSrc = pdfjsWorker; export default { name: "pdfOImgPreview", props: { //预览文件 sealFile: { type: File | null, required: true, default: () => null, }, }, data() { return { pdfDoc: null, pageNum: 1, pageRendering: false, pageNumPending: null, canvas: null, ctx: null, pdfScale: 1, }; }, computed: { //文件类型是否是pdf isFilePDF() { return this.sealFile.type.includes("pdf"); }, }, async mounted() { this.canvas = document.getElementById("the-canvas"); this.ctx = this.canvas.getContext("2d"); if (this.isFilePDF) { await this.printPDF(this.sealFile); } else { await this.printImg(this.sealFile); } }, methods: { //预览pdf async printPDF(pdfData) { const pdfjsLib = PDFJS; const Base64Prefix = "data:application/pdf;base64,"; pdfData = pdfData instanceof Blob ? await this.readBlob(pdfData) : pdfData; const data = atob( pdfData.startsWith(Base64Prefix) ? pdfData.substring(Base64Prefix.length) : pdfData ); // Using DocumentInitParameters object to load binary data. const loadingTask = pdfjsLib.getDocument({ data }); return loadingTask.promise.then((pdfDoc_) => { this.pdfDoc = pdfDoc_; document.getElementById("page_count").textContent = this.pdfDoc.numPages; this.renderPage(this.pageNum).then((res) => { this.$emit("renderPdf", { width: this.canvas.width, height: this.canvas.height, pages: this.pdfDoc.numPages, ratio: this.pdfScale, }); }); }); }, readBlob(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.addEventListener("load", () => resolve(reader.result)); reader.addEventListener("error", reject); reader.readAsDataURL(blob); }); }, renderPage(num) { let _this = this; this.pageRendering = true; // Using promise to fetch the page return this.pdfDoc.getPage(num).then((page) => { let viewport = page.getViewport({ scale: 1 }); const { ratio } = this.getWidthHeight4Pdf(viewport); const newScale = +ratio.toFixed(1) - 0.1; this.pdfScale = newScale; console.log("pdfScale", newScale); viewport = page.getViewport({ scale: newScale }); _this.canvas.height = viewport.height; _this.canvas.width = viewport.width; // Render PDF page into canvas context let renderContext = { canvasContext: _this.ctx, viewport: viewport, }; let renderTask = page.render(renderContext); // Wait for rendering to finish renderTask.promise.then(() => { _this.pageRendering = false; if (_this.pageNumPending !== null) { // New page rendering is pending this.renderPage(_this.pageNumPending); _this.pageNumPending = null; } }); }); }, queueRenderPage(num) { if (this.pageRendering) { this.pageNumPending = num; } else { this.renderPage(num); } }, prevPage() { if (this.pageNum <= 1) { return; } this.pageNum--; this.queueRenderPage(this.pageNum); this.$emit("getPageNum", { oldNum: this.pageNum + 1, newNum: this.pageNum, }); }, nextPage() { if (this.pageNum >= this.pdfDoc.numPages) { return; } this.pageNum++; this.queueRenderPage(this.pageNum); this.$emit("getPageNum", { oldNum: this.pageNum - 1, newNum: this.pageNum, }); }, //获取PDF宽高,策略:按照dialog宽度,同比例缩放 getWidthHeight4Pdf({ width }) { const newWidth = window.screen.width * 0.9 - 60; const ratio = newWidth / width; return { ratio }; }, //获取图片宽高,策略:如果超过宽度超过dialog,则同比例缩小,否则取原图大小 getWidthHeight4Img({ width, height }) { const maxWidth = window.screen.width * 0.9 - 60; const isOverMax = width > maxWidth; const ratio = isOverMax ? maxWidth / width : 1; const newWidth = isOverMax ? maxWidth : width; const newHeight = ratio * height; return { width: newWidth, height: newHeight, ratio }; }, //预览图片 async printImg(imgData) { const img = new Image(); img.src = URL.createObjectURL(imgData); const that = this; img.onload = () => { const { width, height, ratio } = this.getWidthHeight4Img(img); that.canvas.width = width; that.canvas.height = height; that.ctx.drawImage(img, 0, 0, width, height); that.$emit("renderPdf", { width: width, height: height, pages: 1, ratio, }); }; }, }, }; </script> <style lang="scss" scoped> .center { width: 100%; height: 100%; > div { height: 30px; } } </style>
(2)完成盖章部分,此处我使用弹框的方式展现。 fabricSeal.vue
<template> <el-dialog width="90%" :title="title" :visible.sync="dialogIsVisible" :close-on-click-modal="false" append-to-body destroy-on-close class="fabricSeal" > <div class="elesign" id="elesign"> <!-- pdf或者图片canvas预览 --> <pdfOImgPreview v-if="sealFile_" ref="preview" :sealFile="sealFile_" @renderPdf="renderPdf" @getPageNum="getPageNum" ></pdfOImgPreview> <!-- 盖章部分 --> <canvas id="ele-canvas"></canvas> </div> <div slot="footer" class="dialog-footer"> <el-button size="small" @click="onClose"> 取消 </el-button> <el-button size="small" type="primary" @click="onConfirm"> 确定 </el-button> </div> </el-dialog> </template> <script> import { fabric } from "fabric"; export default { components: { pdfOImgPreview: () => import("./pdfOimgPreview.vue"), }, props: { title: { type: String, default: () => "手动设置电子章", }, // 控制弹窗显隐开关 必传 dialogVisible: { type: Boolean, required: true, default: () => false, }, //预览文件 必传 sealFile: { type: File | null, required: true, default: () => null, }, //印章图片链接 sealUrl: { type: String, default: () => "", }, //印章初始化参数 apiDataInit: { type: Array, default: () => [ { left: undefined, top: undefined, height: undefined, width: undefined, pageNum: 1, }, ], }, //pdf情况,每个页面是否允许独立设置印章 //false: signaData 返回对象,true: signaData 返回数组 isPageSeal: { type: Boolean, default: () => false, }, }, data() { return { canvas: null, whDatas: null, apiData: [], }; }, computed: { //实现.sync双向绑定数据 dialogIsVisible: { get() { return this.dialogVisible; }, set(newValue) { this.$emit("update:dialogVisible", newValue); }, }, sealFile_() { return this.dialogIsVisible ? this.sealFile : null; }, //文件类型是否是pdf isFilePDF() { return this.sealFile.type.includes("pdf"); }, }, watch: { whDatas: { handler() { if (!!this.whDatas) { this.renderFabric(); this.canvasEvents(); } }, }, }, methods: { //取消 onClose() { this.$emit("update:dialogVisible", false); }, //确定 onConfirm() { const pageNum = this.$refs.preview.pageNum; this.setPageData(pageNum); console.log("this.apiData", this.apiData); const apiData = this.isPageSeal ? this.apiData : this.apiData.find((item) => item.pageNum === pageNum); this.$emit("getSealData", apiData); this.onClose(); }, //设置每页参数 setPageData(pageNum) { const data = this.canvas.getObjects()[0]; const apiData = { ...this.signaData2ApiData(data), ...{ pageNum }, }; const index = this.apiData.findIndex((item) => item.pageNum === pageNum); this.apiData[index] = apiData; }, //获取pdf页码 getPageNum({ oldNum, newNum }) { if (!this.isPageSeal) { return; } this.setPageData(oldNum); this.removeSignature(); const tempData = this.apiData.find((item) => item.pageNum === newNum); let apiData = undefined; if (tempData.top) { apiData = tempData; } this.addSignature(apiData); }, // 设置绘图区域宽高 renderPdf(data) { this.whDatas = data; const { width: whWdith, height: whHeight } = this.whDatas; let apiData = []; for (let i = 1; i <= this.whDatas.pages; i++) { const defaultApiData = { width: +(150 / whWdith).toFixed(2), height: +(150 / whHeight).toFixed(2), top: 0.01, left: 0.01, pageNum: i, }; const index = this.apiDataInit.findIndex((item) => item.pageNum === i); if (index < 0) { apiData.push(defaultApiData); } else { const newApiDatum = { ...defaultApiData, ...this.apiDataInit[i] }; apiData.push(newApiDatum); } } this.apiData = apiData; document.querySelector(".elesign").style.width = `${data.width}px`; this.addSignature(); }, // 生成绘图区域 renderFabric() { const { width, height } = this.whDatas; const canvaEle = document.querySelector("#ele-canvas"); canvaEle.width = width; canvaEle.height = height; this.canvas = new fabric.Canvas(canvaEle); const container = document.querySelector(".canvas-container"); container.id = "newContainer"; Object.assign(container.style, { position: "absolute", top: `${this.isFilePDF ? 120 : 90}px`, }); }, // 相关事件操作哟 canvasEvents() { // 拖拽边界 不能将图片拖拽到绘图区域外 this.canvas.on("object:moving", (e) => { let obj = e.target; obj.setCoords(); if ( obj.getBoundingRect().top - obj.cornerSize / 2 < 0 || obj.getBoundingRect().left - obj.cornerSize / 2 < 0 ) { obj.top = Math.max( obj.top, obj.top - obj.getBoundingRect().top + obj.cornerSize / 2 ); obj.left = Math.max( obj.left, obj.left - obj.getBoundingRect().left + obj.cornerSize / 2 ); } if ( obj.getBoundingRect().top + obj.getBoundingRect().height + obj.cornerSize > obj.canvas.height || obj.getBoundingRect().left + obj.getBoundingRect().width + obj.cornerSize > obj.canvas.width ) { obj.top = Math.min( obj.top, obj.canvas.height - obj.getBoundingRect().height + obj.top - obj.getBoundingRect().top - obj.cornerSize / 2 ); obj.left = Math.min( obj.left, obj.canvas.width - obj.getBoundingRect().width + obj.left - obj.getBoundingRect().left - obj.cornerSize / 2 ); } }); },// 删除签章 removeSignature() { this.canvas.remove(this.canvas.getObjects()[0]); }, // 添加签章 async addSignature(apiDataInit = this.apiDataInit[0]) { const that = this; const sealUrl = this.sealUrl; //设置印章初始化参数 const signaData = this.apiData2SignaData(apiDataInit); const { left, top, scaleX, scaleY, angle, height, width, opacity, lockRotation, } = signaData; fabric.Image.fromURL( sealUrl, (oImg) => { oImg.set({ left: left > this.canvas.width - width * scaleX - 10 ? this.canvas.width - width * scaleX - 10 : left, top: top > this.canvas.height - height * scaleY - 10 ? this.canvas.height - height * scaleY - 10 : top, scaleX, scaleY, angle, }); that.canvas.add(oImg); }, { opacity, lockRotation } ); }, // 签章参数===》接口参数 signaData2ApiData(signaData) { const { width: whWdith, height: whHeight } = this.whDatas; const { left = 10, top = 10, scaleX = 0.5, scaleY = 0.5, angle = 0, height = 300, width = 300, opacity = 0.5, lockRotation = true, } = signaData; const ratio = this.whDatas.ratio; return { width: +((width / whWdith) * scaleX).toFixed(2), //印章宽度百分比(对比背景宽度) height: +((height / whHeight) * scaleY).toFixed(2), //印章高度百分比(对比背景高度) left: +(left / whWdith).toFixed(2), //印章距左边百分比 top: +(top / whHeight).toFixed(2), //印章顶边百分比 scaleX: +scaleX.toFixed(2), //印章宽度百分比(对比印章原始宽度) scaleY: +scaleY.toFixed(2), //印章宽度百分比(对比印章原始高度) widthpx: +width.toFixed(0), //印章宽度 px heightpx: +height.toFixed(0), //印章高度 px leftpx: +left.toFixed(0), //印章距左边 px toppx: +top.toFixed(0), //印章距左边 px ratio, //图片预览时缩放的尺寸 }; }, // 接口参数===》签章参数 apiData2SignaData(apiData) { const { width: whWdith, height: whHeight } = this.whDatas; const { left = 0.01, top = 0.01, width = 150 / whWdith, height = 150 / whHeight, } = apiData; return { left: +(left * whWdith).toFixed(0), top: +(top * whHeight).toFixed(0), scaleX: +((width * whWdith) / 300).toFixed(2), scaleY: +((height * whHeight) / 300).toFixed(2), angle: 0, height: 300, width: 300, opacity: 0.5, lockRotation: true, }; }, }, }; </script> <style lang="scss" scoped> .fabricSeal { /deep/.el-dialog { margin: 0 auto !important; .el-dialog__header { line-height: 30px; } // .el-dialog__footer{ // position: absolute; // bottom: 0; // right: 0; // } } } </style>
(3)父组件使用
<template> <fabricSeal v-if="showFabricSeal" :dialogVisible.sync="showFabricSeal" :sealFile="sealFile" :sealUrl="sealUrl" @getSealData="getSealData" ></fabricSeal> </template> <script> export default { components:{ fabricSeal: () => import("./fabricSeal.vue"), }, data(){ return{ showFabricSeal:false, sealFile:"",//预览文件,可以通过el-upload获取 sealUrl:"",//印章路由 } }, methods:{ //手动拼图获取印章位置信息 getSealData(sealData){ } } } </script>