fabric.js实现可视化签章以及遮罩打印的功能

前言

业务场景

首先,是因为这样一个需求,我开始尝试使用fabric.js

公司有个项目,是可信电子凭证可视化签章

支持在打开的PDF文件上能随意拖动一个图片到PDF文档的任意位置。能获取当前拖动的图片在PDF文档的x,y坐标。

后来,又一次用到了fabric.js是档案管理平台的项目

文件在线预览的页面,需要增加一个遮罩打印的功能,就是在pdf文件上打上马赛克,遮罩一些内容。不通过安装插件,纯前端技术来解决。在PDF文件的预览区域上,按住鼠标左键然后拖着,可生成马赛克的遮罩打印区域。

图片1.png

图片2.png

了解一门新技术,直接看官网和相关文档。

**fabricjs官网在此:**fabricjs.com/

官网首页,写在这样一段话:

Fabric.js is a powerful and simple Javascript HTML5 canvas library

Fabric.js是一个强大而简单的Javascript HTML5 Canvas库

然后看了相关文档,了解到我们能通过使用它实现在canvas上创建,填充图形,给图形填充渐变颜色。组合图形(包括组合图形,图形文字,图片等)等一系列功能。

简单来说,我们可以通过使用Fabric从而以较为简单的方式,实现较为复杂的Canvas功能。

知道和做到之间,有一条天然的鸿沟。

有时,人们了解到前人的经验踩过的坑,但是仍然不可避免的掉进这些坑里。自己掉进这些坑里,再爬出来,才最终学习到这些经验,最终避开这些坑。

快速上手

了解到其基础概览,和应用场景之后,准备快速上手。

在vue项目中引入服务

npm install fabric
import { fabric } from 'fabric'
 

首先做了一个demo用来实现在pdf预览的区域上拖拽图片。

关于pdf文件预览,之前用的pdf.js基于html的pdf阅读器,从官网下载静态资源,放到项目的static静态资源文件夹里面。

使用pdf.js已经写好的viewer.html页面来预览。

static/pdf/web/viewer.html?file=' + encodeURIComponent(pdf)
 

使用iframe标签去显示。然后,封装成一个公共工作,在需要的地方,直接调用。

组件代码:

<template>
<div class="pdf">
<div class="box-card pdf-viewer">
<iframe
:src="'static/pdf/web/viewer.html?file=' + encodeURIComponent(pdf)"
:height="height"
width="100%"
frameborder="0"
></iframe>
</div>
</div>
</template>
<script>
export default {
name: "PdfDetail",
components: {},
props: {
pdf: {
type: String,
default: "",
},
height: {
type: Number,
default: 560
}
},
data() {
return {};
},
watch: {},
computed: {},
methods: {},
created() {},
mounted() {},
};
</script>
<style scoped>
.wrapper {
}
</style>

这里只是顺带说了一下pdf.js预览的方法,我并没有采用这种方法去实现pdf预览功能。

因为,不仅要预览,还需要将pdf预览区域转换成canvas画布,然后在画布上实现图片拖拽位置的功能,并获取坐标。

我采用的是vue-pdf组件

GitHub地址:

github.com/FranckFreib…

npm install --save vue-pdf
<template>
<pdf src="./static/relativity.pdf"></pdf>
</template>
<script>
import pdf from 'vue-pdf'
export default {
components: {
pdf
}
}
 

核心代码

以下是demo的核心代码

基础方法

// 初始化画布对象
new fabric.Canvas('canvas')
//赋予一个变量,并且添加了双击事件,通过双击事件删除canvas画布上添加的内容
this.canvas = new fabric.Canvas('canvas')
this.canvas.on('mouse:dblclick', (e) => {
let items = this.canvas.getObjects()
items = items.filter((item) => item.width > 1 && item.height > 1)
let itemIdx = items.indexOf(e.target)
this.canvas.remove(this.canvas.item(itemIdx));
this.canvas.renderAll();
})

在created钩子函数中,设置fabric的对象拖拽框

fabric.Object.prototype.setControlsVisibility({
bl: false, // 左下
br: false, // 右下
mb: false, // 下中
ml: false, // 中左
mr: false, // 中右
mt: false, // 上中
tl: false, // 上左
tr: false, // 上右
mtr: false, // 旋转控制键
});

通过图片路径,往画布上添加图片的方法

let imgCoord = fabric.Image.fromURL(imgUrl, (img) => {
img.scale(1).set({
crossOrigin: 'anonymous',
left: 0,
top: 0,
})
this.canvas.add(img).setActiveObject(img)
})

通过图片对象,往画布上添加图片的方法

let image= new Image()
image.src = imgUrl
image.crossOrigin = 'Anonymous';
image.onload = () => {
fabric.Image.fromObject(imgl,(img) => {
img.scale(1).set({
crossOrigin: 'anonymous',
left,
top,
width,
height,
scaleX,
scaleY,
})
this.canvas.add(img).setActiveObject(img)
this.canvas.renderAll()
})
}

除了添加图片,还可以添加文本框,并且设置文字的颜色字体大小等等。

需要注意的是fontSize参数必须为Number类型。

let attributeObject = {
fill,
fontFamily,
fontWeight,
textAlign,
lineHeight,
width,
splitByGrapheme:true,
height,
fontSize:,
originX: 'center',
originY: 'center',
}
var obj = new fabric.Textbox(text, attributeObject)
var group = new fabric.Group([obj], {
left,
top,
})
this.canvas.add(group)
this.canvas.renderAll()
  • 为了最终拿到画布上所有对象的属性,以及坐标。我将这些属性和坐标,放到了一个json对象数组里面,保存起来。
  • 图片和文字,除了能够拖拽,文字框还要求,能够改变字体颜色大小等等。
  • 我加了字体颜色大小等属性的选择框,做了数据双向绑定。
  • 每当json对象数组改变的时候,我就清空画布上的所有对象,然后从json对象数组里面拿到保存的属性和坐标,在画布上重新渲染。
//清空画布
this.canvas.clear()

获取画布上所有对象的坐标

getImgPosition() {
if (!this.canvas) return
this.imgcoordinate = []
let items = this.canvas.getObjects()
items = items.filter((item) => item.width > 1 && item.height > 1)
items.forEach((item, index) => {
let itemcoord = {
floorIndex: index,
tl: {
x: item.aCoords.tl.x,
y: item.aCoords.tl.y,
},
tr: {
x: item.aCoords.tr.x,
y: item.aCoords.tr.y,
},
bl: {
x: item.aCoords.bl.x,
y: item.aCoords.bl.y,
},
br: {
x: item.aCoords.br.x,
y: item.aCoords.br.y,
},
}
this.imgcoordinate.push(itemcoord)
})
this.xycoordinate = this.imgcoordinate.map((item) => item.tl)
}

添加马赛克

最后说一下,在canvas画布通过fabric.js添加马赛克的方法

首先,初始化canvas画布对象的时候,增加鼠标事件监听的方法

this.canvas = new fabric.Canvas("canvas");
this.canvas.on("mouse:down", function (e) {
that.mousedown(e);
});
//鼠标抬起事件
this.canvas.on("mouse:up", function (e) {
that.mouseup(e);
});
// 移动画布事件
this.canvas.on("mouse:move", function (e) {
that.mousemove(e);
});
  • 鼠标点击mousedown事件时,记录下画布上点的位置,
  • 鼠标移动后抬起mouseup事件时,记录下画布上点的最终位置,
  • 这样,就可以算出鼠标拖拽的矩形的初始位置x,y坐标,以及矩形的宽高。
let mouse = this.canvas.getPointer(e.e);

接下来是最关键的,实现马赛克的方法,这个花了很长时间。

  • 在初始化canvas画布对象的时候,需要通过getContext() 方法返回一个用于在画布上绘图的环境。
  • 然后传一个图片路径imgUrl,通过drawImage画出底图。
  • 具体生成马赛克的方法,在setColor中,是通过context对象的getImageData方法,获取图片数据。根据设置的马赛克方块大小,通过rgb的颜色设置,模糊掉底图上的图片,实现遮罩的效果。
let Img = new Image();
Img.src = imgUrl;
that.bgImage = Img;
Img.onload = () => {
that.context.drawImage(Img, 0, 0);
that.context.save();
};

以下,是画矩形方框,并且填充马赛克,最终实现马赛克的方法

drawMake() {
if(!this.canvas)return;
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.context.drawImage(this.bgImage, 0, 0);
this.context.save();
// if (this.canvas) this.canvas.clear();
this.makeList.forEach((item) => {
let { beginX, beginY, w, h } = item;
this.makeGrid(beginX, beginY, w, h);
});
},
makeGrid(beginX, beginY, rectWidth, rectHight) {
const row = Math.round(rectWidth / this.squareEdgeLength) + 1;
const column = Math.round(rectHight / this.squareEdgeLength) + 1;
for (let i = 0; i < row * column; i++) {
let x = (i % row) * this.squareEdgeLength + beginX;
let y = parseInt(i / row) * this.squareEdgeLength + beginY;
this.setColor(x, y);
}
},
setColor(x, y) {
const imgData = this.context.getImageData(
x,
y,
this.squareEdgeLength,
this.squareEdgeLength
).data;
let r = 0,
g = 0,
b = 0;
for (let i = 0; i < imgData.length; i += 4) {
r += imgData[i];
g += imgData[i + 1];
b += imgData[i + 2];
}
r = Math.round(r / (imgData.length / 4));
g = Math.round(g / (imgData.length / 4));
b = Math.round(b / (imgData.length / 4));
this.drawRect(
x,
y,
this.squareEdgeLength,
this.squareEdgeLength,
`rgb(${r}, ${g}, ${b})`,
2,
`rgb(${r}, ${g}, ${b})`
);
},
drawRect(
x,
y,
width,
height,
fillStyle,
lineWidth,
strokeStyle,
globalAlpha
) {
this.context.beginPath();
this.context.rect(x, y, width, height);
this.context.lineWidth = lineWidth;
this.context.strokeStyle = strokeStyle;
fillStyle && (this.context.fillStyle = fillStyle);
globalAlpha && (this.context.globalAlpha = globalAlpha);
this.context.fill();
this.context.stroke();
},

除了fabric.js之外,为了实现遮罩打印的功能。还用到了 html2canvas 和 jsPDF的方法,在此不一一赘述,直接放出遮罩打印的组件完整代码。

实现了基本的业务需求之后,我还做了一些优化,譬如撤销和回退的功能。增加了属性设置弹框,通过拖动滑块选择马赛克方块的大小。通过driver.js实现帮助提示,操作指引。

这里,主要是记录了实现业务需求的解决思路,以及踩坑指南。

参考文章

参考了博客园的两篇文章:

Canvas实用库Fabric.js使用手册

www.cnblogs.com/aaron911/p/…

Vue PDF文件预览vue-pdf

www.cnblogs.com/steamed-twi…

完整代码

遮罩打印的组件,完整代码

<template>
<div class="wrapper">
<div class="web-file">
<!-- 操作栏 -->
<div class="operate-box nowrap flex justify-between">
<div class="flex btn-list">
<div class="page-btn">
<el-button
type="default"
@click="changePdfPage(0)"
:disabled="currentPage == 1"
size="mini"
>
上一页
</el-button>
<div class="page-count">
<div v-show="pageCount">{{ currentPage }} / {{ pageCount }}</div>
</div>
<el-button
type="default"
@click="changePdfPage(1)"
:disabled="currentPage == pageCount"
size="mini"
>
下一页
</el-button>
</div>
<div id="mask-print">
<el-button type="default" @click="printPdf" icon="el-icon-printer" size="mini">
打印
</el-button>
</div>
<div id="mask-setting">
<el-button
type="default"
@click="attributeEdit"
icon="el-icon-setting"
size="mini"
>
遮罩属性设置
</el-button>
</div>
<div id="mask-add">
<el-button type="default" @click="addMask" size="mini">添加遮罩</el-button>
</div>
<el-button type="text" @click="guide" icon="el-icon-info">
帮助
</el-button>
</div>
<div class="flex btn-list">
<div id="mask-back">
<el-button
type="default"
@click="stepBack"
icon="el-icon-arrow-left"
size="mini"
>
回退
</el-button>
</div>
<div id="mask-clear">
<el-button
type="danger"
@click="clearClean"
icon="el-icon-refresh-left"
size="mini"
>
撤销
</el-button>
</div>
<el-button @click="goBack" size="mini">返回原文页</el-button>
</div>
</div>
<!-- pdf翻页 -->
<div v-show="pageCount && pageCount > 0"></div>
<div class="pdf-box" id="pdf" ref="baseMap" v-if="showPdfBoxFlag">
<!-- pdf预览 -->
<pdf
ref="pdf"
:src="pdfsrc"
:page="currentPage"
@num-pages="pageCount = $event"
@page-loaded="pageLoaded"
@loaded="loadPdfHandler"
></pdf>
<div
class="manager_detail"
id="manager_detail"
:class="{ 'accurate-choice': accurateChoiceFlag }"
>
<!-- 画布 -->
<canvas
id="canvas"
ref="imgContent"
:width="canvasObj.width"
:height="canvasObj.height"
style="
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
cursor: crosshair;
"
></canvas>
</div>
</div>
</div>
<attribute-set
:title="attribute.title"
@close="attribute.show = false"
@ok="setAttribute"
v-if="attribute.show"
ref="attribute"
/>
</div>
</template>
<script>
import { fabric } from "fabric";
import pdf from "vue-pdf";
import html2canvas from "html2canvas";
import jsPDF from "jspdf";
import AttributeSet from "./AttributeSet";
// 引导页的功能
import Driver from "driver.js"; // import driver.js
import "driver.js/dist/driver.min.css"; // import driver.js css
import steps from "./steps";
export default {
components: {
pdf,
AttributeSet,
},
props: {
pdfsrc: {
type: String,
default: "",
}
},
data() {
return {
canvas: null,
context: "",
bgImage: null,
pageCount: 1, // pdf文件总页数
currentPage: 1,
clickFlag: false,
clickTimer: -1,
canvasObj: {
width: 0,
height: 0,
},
json: [],
basecoordinate: [], //基础坐标数组
xycoordinate: [], // 左上角的坐标数组
isMasic: true,
squareEdgeLength: 20, //马赛克大小
mouse: {
started: false,
x: 0,
y: 0,
},
accurateChoiceFlag: false,
imgUrl: "",
// maskPic: "/static/img/fabric/mask.png",
makeGridObject: {
beginX: 0,
beginY: 0,
},
makeList: [],
fillColor: "",
attribute: {
show: false,
title: "属性设置",
loading: false,
style: "1", //0代表颜色,1代表马赛克
},
driver: null,
showPdfBoxFlag: false,
};
},
computed: {},
created() {
fabric.Object.prototype.setControlsVisibility({
bl: false, // 左下
br: false, // 右下
mb: false, // 下中
ml: false, // 中左
mr: false, // 中右
mt: false, // 上中
tl: false, // 上左
tr: false, // 上右
mtr: false, // 旋转控制键
});
this.showPdfBoxFlag = true;
},
mounted() {
this.driver = new Driver({
//此处为api
animate: true,
opacity: 0.5,
allowClose: false,
doneBtnText: "完成",
closeBtnText: "关闭",
nextBtnText: "下一步",
prevBtnText: "上一步",
onReset: (Element) => {
//这里写逻辑回调
},
});
},
methods: {
goBack() {
this.$emit('back');
},
guide() {
this.driver.defineSteps(steps);
this.driver.start();
},
setAttribute(item) {
this.attribute.style = item.styleValue;
this.fillColor = item.styleValue == "0" ? "#fff" : "";
this.squareEdgeLength = item.maskValue;
this.drawMake();
},
attributeEdit() {
this.attribute.show = true;
this.$nextTick(() => {
let item = {
styleValue: this.attribute.style,
maskValue: this.squareEdgeLength,
};
this.$refs.attribute.initData(item);
});
},
addMask() {
this.mouse.started = true;
this.initCanvasObjAndEvent();
this.accurateChoiceFlag = true;
},
// 返回上一步
stepBack() {
if (this.makeList.length > 0) {
this.makeList.splice(this.makeList.length - 1, 1);
this.drawMake();
}
},
// 初始化画布对象
initCanvasObjAndEvent() {
if (!this.canvas) {
this.canvas = new fabric.Canvas("canvas");
let imgContent = this.$refs.imgContent;
this.context = imgContent.getContext("2d");
let that = this;
this.pageTransformedIntoCanvas((pageData, PDF) => {
let Img = new Image();
Img.src = pageData;
that.bgImage = Img;
Img.onload = () => {
that.context.drawImage(Img, 0, 0);
that.context.save();
};
});
this.canvas.on("mouse:down", function (e) {
that.mousedown(e);
});
//鼠标抬起事件
this.canvas.on("mouse:up", function (e) {
that.mouseup(e);
});
// 移动画布事件
this.canvas.on("mouse:move", function (e) {
that.mousemove(e);
});
}
},
pageLoaded(e) {
this.currentPage = e;
this.canvasObj.width = document.getElementById("pdf").offsetWidth;
this.canvasObj.height = document.getElementById("pdf").offsetHeight;
},
// pdf加载时
loadPdfHandler(e) {
// this.currentPage = 1; // 加载的时候先加载第一页
},
// 改变PDF页码,val传过来区分上一页下一页的值,0上一页,1下一页
changePdfPage(val) {
if (this.clickFlag) return;
this.clickFlag = true;
this.clickTimer = setTimeout(() => {
this.clickFlag = false;
}, 500);
if (val === 0 && this.currentPage > 1) {
this.currentPage--;
}
if (val === 1 && this.currentPage < this.pageCount) {
this.currentPage++;
}
if(this.canvas)this.canvas.clear();
this.canvas = null;
this.showPdfBoxFlag = false;
this.$nextTick(() =>{
this.showPdfBoxFlag = true;
})
},
// 鼠标事件
mousedown(e) {
if (!this.mouse.started) {
return false;
}
let mouse = this.canvas.getPointer(e.e);
// this.mouse.started = true;
let x = mouse.x;
let y = mouse.y;
this.mouse = {
...this.mouse,
x,
y,
};
this.makeGridObject = {
beginX: x,
beginY: y,
};
},
mousemove(e) {
if (!this.mouse.started) {
return false;
}
var mouse = this.canvas.getPointer(e.e);
var w = Math.abs(mouse.x - this.mouse.x),
h = Math.abs(mouse.y - this.mouse.y);
if (!w || !h) {
return false;
}
},
mouseup(e) {
if (!this.mouse.started) {
return false;
}
this.mouse.started = false;
this.accurateChoiceFlag = false;
let endX = e.offsetX;
let endY = e.offsetY;
// 马赛克遮罩
let { beginX, beginY } = this.makeGridObject;
var mouse = this.canvas.getPointer(e.e);
var w = Math.abs(mouse.x - beginX),
h = Math.abs(mouse.y - beginY);
let obj = {
beginX,
beginY,
w,
h,
};
this.makeList.push(obj);
this.drawMake();
},
pageTransformedIntoCanvas(callback) {
let that = this;
html2canvas(document.getElementById("pdf"), {
allowTaint: true,
useCORS: true,
}).then(function (canvas) {
let contentWidth = canvas.width;
let contentHeight = canvas.height;
//竖向打印
let imgWidth = 595.28;
let imgHeight = (592.28 / contentWidth) * contentHeight;
let pageHeight = (contentWidth / 592.28) * 841.89;
let leftHeight = contentHeight;
let position = 0;
if (contentWidth > contentHeight) {
//横向打印
imgWidth = 841.89;
imgHeight = (841.89 / contentWidth) * contentHeight;
}
let pageData = canvas.toDataURL("image/jpeg", 1.0);
let PDF;
if (contentWidth <= contentHeight) {
//竖向打印
PDF = new jsPDF("", "pt", "a4");
} else {
//    横向打印
PDF = new jsPDF("l", "pt", "a4");
}
if (leftHeight < pageHeight) {
PDF.addImage(pageData, "JPEG", 0, 0, imgWidth, imgHeight);
} else {
while (leftHeight > 0) {
PDF.addImage(pageData, "JPEG", 0, position, imgWidth, imgHeight);
leftHeight -= pageHeight;
position -= 841.89;
if (leftHeight > 0) {
PDF.addPage();
}
}
}
let datauri = PDF.output("dataurlstring");
let base64 = datauri.split("base64,")[1];
callback(pageData, PDF);
});
},
drawMake() {
if(!this.canvas)return;
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.context.drawImage(this.bgImage, 0, 0);
this.context.save();
// if (this.canvas) this.canvas.clear();
this.makeList.forEach((item) => {
let { beginX, beginY, w, h } = item;
this.makeGrid(beginX, beginY, w, h);
});
},
clearClean() {
if (this.canvas) this.canvas.clear();
},
printPdf() {
let base64 = "";
let datauri = "";
let that = this;
this.pageTransformedIntoCanvas((pageData, PDF) => {
let blob = PDF.output("blob");
that.print(blob);
});
},
print(blob) {
var date = new Date().getTime();
var ifr = document.createElement("iframe");
ifr.style.frameborder = "no";
ifr.style.display = "none";
ifr.style.pageBreakBefore = "always";
ifr.setAttribute("id", "printPdf" + date);
ifr.setAttribute("name", "printPdf" + date);
ifr.src = window.URL.createObjectURL(blob);
document.body.appendChild(ifr);
this.doPrint("printPdf" + date);
window.URL.revokeObjectURL(ifr.src); // 释放URL 对象
},
doPrint(val) {
var ordonnance = document.getElementById(val).contentWindow;
setTimeout(() => {
ordonnance.print();
}, 100);
},
makeGrid(beginX, beginY, rectWidth, rectHight) {
const row = Math.round(rectWidth / this.squareEdgeLength) + 1;
const column = Math.round(rectHight / this.squareEdgeLength) + 1;
for (let i = 0; i < row * column; i++) {
let x = (i % row) * this.squareEdgeLength + beginX;
let y = parseInt(i / row) * this.squareEdgeLength + beginY;
this.setColor(x, y);
}
},
setColor(x, y) {
const imgData = this.context.getImageData(
x,
y,
this.squareEdgeLength,
this.squareEdgeLength
).data;
let r = 0,
g = 0,
b = 0;
for (let i = 0; i < imgData.length; i += 4) {
r += imgData[i];
g += imgData[i + 1];
b += imgData[i + 2];
}
r = Math.round(r / (imgData.length / 4));
g = Math.round(g / (imgData.length / 4));
b = Math.round(b / (imgData.length / 4));
this.drawRect(
x,
y,
this.squareEdgeLength,
this.squareEdgeLength,
`rgb(${r}, ${g}, ${b})`,
2,
`rgb(${r}, ${g}, ${b})`
);
},
drawRect(
x,
y,
width,
height,
fillStyle,
lineWidth,
strokeStyle,
globalAlpha
) {
this.context.beginPath();
this.context.rect(x, y, width, height);
this.context.lineWidth = lineWidth;
if (this.fillColor) {
fillStyle = this.fillColor;
strokeStyle = this.fillColor;
}
this.context.strokeStyle = strokeStyle;
fillStyle && (this.context.fillStyle = fillStyle);
globalAlpha && (this.context.globalAlpha = globalAlpha);
this.context.fill();
this.context.stroke();
},
},
};
</script>
<style lang="scss" scoped>
.btn-list {
& > div {
margin-right: 10px;
}
& > div:last-child {
margin-right: 0;
}
}
.page-btn {
text-align: center;
display: flex;
align-items: center;
.page-count {
min-width: 50px;
margin: 0 10px;
}
}
.pdf-box {
position: relative;
}
.manager_detail {
position: absolute;
top: 0;
left: 0;
}
.web-file {
width: 65%;
min-width: 900px;
margin: 0 auto;
.pdf-box {
width: 100%; // height: 55vh;
overflow: hidden;
}
}
.accurate-choice {
cursor: crosshair;
}
</style>

原创文章,作者:我心飞翔,如若转载,请注明出处:https://www.pipipi.net/12592.html

发表评论

登录后才能评论