本系列文章将分上上下两篇文章,使用typescript从0到1实现一个前端水印sdk,并发发布到npm(包名为l-watermark)
先会用,再看原理,传送门:TS从0到1实现一个完整前端水印SDK,并发布到NPM(上)
完整代码地址:GitHub
本文将对前端水印的原理以及代码实现做详细论述,包括给页面添加水印、给图片添加水印、暗水印、发布到npm等。先看下水印SDK的流程图:
1. 关于水印的配置
先了解下水印的配置,方便更容易看懂都后续的代码
1.1 对页面添加水印的配置
export interface UserPageWaterMarkConfig {
text?: string // 水印文字
image?: string // 水印图片
containerEl?: HTMLElement // 添加水印的目标元素
color?: string // 水印字体颜色rgba
fontSize?: number // 水印字体大小
zIndex?: string // 层级
cSpace?: number // 水印横向间距
vSpace?: number // 水印纵向间距
angle?: number // 水印旋转角度
onchange?(): void // 添加成功回调
onerror?(err: ErrorType): void // 发生错误回调
success?(): void // 添加成功回调
}
1.2 对图片添加水印的配置
export interface UserImageWaterMarkConfig = {
target?: HTMLImageElement // 目标图片
image?: string // 水印图片
text?: string // 水印文字
imageWidth?: number // 水印图片的宽度
imageHeight?: number // 水印图片的高度
secret?: boolean // 开启暗水印
position?: string // 水印位置
color?: string // 文字水印字体颜色rgba
fontSize?: number // 文字水印字体大小
cSpace?: number // 水印横向间距
vSpace?: number // 水印纵向间距
angle?: number // 水印旋转角度
success?(base64: string): void // 添加成功回调
onerror?(err: ErrorType): void // 发生错误回调
}
2. 实现页面水印
从文章开始流程图可以看出,给页面添加图片水印只是少了一步
“文字转换成水印图片”
而已,所以本节以给页面添加文字水印为例,看看时如何一步步实现的?
2.1 第一步:文字转换成水印图片
将文字绘制带到 canvas
,然后利用 canvas.toDataURL()
获取水印图片的base64
createTextWatermark() {
let base64 = ''
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
if (context) {
const { width, height } = getTextSize(config.text, config.fontSize)
canvas.width = width + config.cSpace
canvas.height = width + config.vSpace
// 字体及大小
context.font = `${config.fontSize}px Microsoft YaHei`
// 设置文字的颜色,由于为rgba,所以也设置了透明度
context.fillStyle = config.color
// 设置文字对齐方式
context.textAlign = 'center'
context.textBaseline = 'middle'
// translate()方法重新映射画布上的 (0,0) 位置为canvas的中央,默认为左上角
context.translate(canvas.width / 2, canvas.height / 2)
// angle转换成弧度进行旋转
context.rotate((Math.PI / 180) * config.angle)
// 将文字绘制到canvas
context.fillText(config.text, 0, 0)
// 获取base64
base64 = canvas.toDataURL()
} else {
// 如果不支持canvas,执行错误回调
config.onerror && config.onerror(ErrorMsg.NoSupportCanvas())
}
this._addWatermark2Container(base64) // 将水印图片铺满div
this._observeWaterMark() // 守护水印不被破坏
}
2.2 第二步:将水印铺满div
_addWatermark2Container(base64: string) {
// 新建一个div
this.watermrk = document.createElement('div')
// 设置背景图片
this.watermakr.style.backgroundImage = `url(${base64})`
// 设置css
this.watermakr.style.position = this.config.containerEl === document.body ? 'fixed' : 'absolute'
this.watermakr.style.top = '0px'
this.watermakr.style.right = '0px'
this.watermakr.style.bottom = '0px'
this.watermakr.style.left = '0px'
this.watermakr.style.pointerEvents = 'none'
this.watermakr.style.backgroundRepeat = 'repeat'
this.watermakr.style.zIndex = this.config.zIndex
// 将水印div放到页面
this.config.containerEl.appendChild(this.watermakr)
}
2.3 第三步:守护水印不被破坏
利用 MutationObserver()
监视body,监控其所有变化,如有是水印div的删除或改动,就重新添加一个水印div
start() {
const body = document.body
// MutationObserver的配置
const config = { characterData: true, attributes: true, childList: true, subtree: true }
// 实例化一个MutationObserver,传入一个_callback回调处理dom变化
this.observer = new MutationObserver(this._callback)
// 部分老版本浏览器可能不支持MutationObserver
if (!this.observer) {
this.onerror && this.onerror(ErrorMsg.NoSupportMutation())
}
// 开启监视body
this.observer.observe(body, config)
// 成功的回调
this.success && this.success()
}
_callback方法:监控用户是否删除了或者修改了水印div,如果是的话,则调用 _readdDom()
方法重新向页面中添加一个水印div
ts
_callback = (mutationsList: MutationRecord[]) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') { // 删除了水印div
mutation.removedNodes.forEach((item) => {
// target为要监视的水印div:即this.watermark
if (item === this.target) {
this.onchange && this.onchange() // 回调
this._readdDom() // 重新添加
}
})
} else if (this.target === mutation.target) { // 修改了水印div
this.onchange && this.onchange(mutation) // 回调
this._readdDom() // 重新添加
}
}
}
_readdDom方法:
_readdDom() {
// cloneTarget是深拷贝了一个第二步创建的水印div的
const newTarget = this.cloneTarget.cloneNode(true) as HTMLElement
// 重新添加到页面:parent是配置中的containerEl
this.parent.appendChild(newTarget)
// 重新更换要监控的目标为newTarget
this.target = newTarget
// 停止监控老的水印div
this.observer.disconnect()
// 开始新的监控
this.start()
}
3. 实现图片水印
文字开始的流程图可以看出,无论是给图片添加文字水印还是图片水印,都需要先
“将目标图片转换为canvas”
,然后在根据不同类型的水印进行不同的处理
// 将目标图片转换为canvas
initTargetCanvas() {
const img = this.config.target
const canvas = document.createElement('canvas')
const { width, height } = img
// 设置canvas画布的尺寸与目标图片一致
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (ctx) {
// 将图片绘制到canvas
ctx.drawImage(img, 0, 0, width, height)
this.canvas = canvas
} else {
// 错误回调:不支持canvas
this.config.onerror && this.config.onerror(ErrorMsg.NoSupportCanvas())
}
}
3.1 为图片添加图片水印
首先利用 canvas的drawImage方法
,将水印图片绘制到目标图片的canvas上,然后将canvas转换成base64,替换目标图片的src即可
image2canvas: () => void = async () => {
const { image, position, target, success, imageWidth, imageHeight } = this.config as Image2Image
// 调用_url2img:将水印图片的url转换成图片dom
const img = await this._url2img(image, imageWidth, imageHeight)
const { width: newImgWidth, height: newImgHeight } = img
const { width, height } = this.canvas
const ctx = this.canvas.getContext('2d')
// 根据配置position进行不同水印的绘制
if (ctx) {
switch (position) {
// 铺满整个目标图片
case 'repeat':
let w = 0
let h = 0
while (h < height) {
while (w < width) {
ctx.drawImage(img, w, h, newImgWidth, newImgHeight)
w += newImgWidth
}
w = 0
h += newImgHeight
}
break
// 在目标图片中间绘制一个
case 'center':
ctx.drawImage(
img,
(width - newImgWidth) / 2,
(height - newImgHeight) / 2,
newImgWidth,
newImgHeight
)
break
// 在目标图片左上角绘制一个
case 'topLeft':
ctx.drawImage(img, 0, 0, newImgWidth, newImgHeight)
break
// 在目标图片右上角绘制一个
case 'topRight':
ctx.drawImage(img, width - newImgWidth, 0, newImgWidth, newImgHeight)
break
// 在目标图片右下角绘制一个
case 'bottomRight':
ctx.drawImage(img, width - newImgWidth, height - newImgHeight, newImgWidth, newImgHeight)
break
// 在目标图片左下角绘制一个
case 'bottomLeft':
ctx.drawImage(img, 0, height - newImgHeight, newImgWidth, newImgHeight)
break
}
// 获取base64
const base64 = this.canvas.toDataURL()
// 替换目标图片
target.src = base64
// 成功回调
success && success(base64)
}
}
_url2img方法:根据url(可以是图片路径、网络地址、base64)转换成图片dom
_url2img(src: string, width?: number, height?: number) {
const img = new Image(width, height)
// 防止报跨域错误
img.setAttribute('crossorigin', 'crossorigin')
img.src = src
return new Promise<HTMLImageElement>((resolve, reject) => {
img.onload = () => {
resolve(img)
}
img.onerror = () => {
reject(new Image())
}
})
}
3.2 为图片添加文字水印
为图片添加文字水印又分为 明水印
和 暗水印
,明水印就是直接将水印文字绘制到目标图片上;而暗水印则是通过一定的算法, “偷偷的” 将水印文字绘制到图片上,暗水印正常用户是看不到的。关于暗水印的核心实现主要参考:不能说的秘密——前端也能玩的图片隐写术
明水印与 "3.1为图片添加图片水印" 原理一致,只是开始多一步将水印文字转换成图片而已,转换方法与 "2.1 第一步:文字转换成水印图片" 一致,因此接下来只完成“为图片添加文字暗水印”
添加暗水印的入口:
drawEncryptedText2canvas() {
// this.canvas为本节开始initTargetCanvas方法将目标图片转换的canvas
const { width, height } = this.canvas
const ctx = this.canvas.getContext('2d')
if (ctx) {
// 获取目标图片的ImageData
const targetImageData = ctx.getImageData(0, 0, width, height)
// 获取水印文字的ImageData
const textImageData = this._text2ImageData(this.config as Text2Image, width, height)
// 核心:根据textImageData和targetImageData进行添加暗水印
const watermarkImageData = this._encryptAndMergeImageData(targetImageData, textImageData)
// 将最终的图片的ImageData绘制到canvas
ctx.putImageData(watermarkImageData, 0, 0)
// 获取base64
const base64 = this.canvas.toDataURL()
// 替换目标元素
this.config.target.src = base64
// 成功回调
this.config.success && this.config.success(base64)
}
}
_text2ImageData方法:将文字绘制到canvas,利用 getImageData
获取ImageData
_text2ImageData(config: Text2Image, width: number, height: number) {
let data = new ImageData(1, 1)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (ctx) {
this._fillText2Ctx(ctx, config, width, height)
data = ctx.getImageData(0, 0, width, height)
}
return data
}
_encryptAndMergeImageData方法:根据水印文字的ImageData修改目标图片的ImageData,返回修改后的目标图片的ImageData,即完成暗水印的添加(核心思想就是:不能说的秘密——前端也能玩的图片隐写术,这里不在过多陈述)
_encryptAndMergeImageData(targetData: ImageData, textData: ImageData, rgb?: string) {
const oData = targetData.data
const tData = textData.data
let bit
let offset
// 处理哪个通道,默认是处理R红色通道
switch (rgb) {
case 'G':
bit = 1
offset = 2
break
case 'B':
bit = 2
offset = 1
break
default:
bit = 0
offset = 3
}
for (let i = 0; i < oData.length; i++) {
if (i % 4 === bit) {
// 对目标通道:水印文字为空的地方 原图处理为偶数
// 水印文字不为空的地方,原图处理为奇数
if (tData[i + offset] === 0 && oData[i] % 2 === 1) {
// 水印文字为空为原图为奇数 -> 变为偶数
if (oData[i] === 255) {
oData[i]--
} else {
oData[i]++
}
} else if (tData[i + offset] !== 0 && oData[i] % 2 === 0) {
// 水印文字不为空,原图为偶数 -> 变为奇数
oData[i]++
}
}
}
return targetData
}
3.3 暗水印图片解密
根据图片的url,先获取图片的ImageData(先转成图片dom,然后将图片dom转换成canvas,再获取其ImageData),拿到了图片的ImageData,就可以进行解密了,解密方法与加密同理
decodeImage: (url: string) => Promise<string> = async (url) => {
let result = ''
let width = 0
let height = 0
// 获取图片的ImageData
const img = new Image()
img.setAttribute('crossorigin', 'crossorigin')
img.src = url
const originalData = await new Promise<ImageData>((resolve, reject) => {
img.onload = () => {
width = img.width
height = img.height
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.drawImage(img, 0, 0)
const data = ctx.getImageData(0, 0, width, height)
resolve(data)
}
}
img.onerror = () => {
reject(new ImageData(1, 1))
}
})
// 开始对ImageData进行解密
const { data } = originalData
for (let i = 0; i < data.length; i++) {
if (i % 4 === 0) {
// 红色分量
if (data[i] % 2 === 0) {
data[i] = 0
} else {
data[i] = 255
}
} else if (i % 4 === 3) {
// alpha通道不做处理
continue
} else {
// 关闭其他分量
data[i] = 0
}
}
// 将解密后的ImageData转换成base64
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.putImageData(originalData, 0, 0)
result = canvas.toDataURL()
}
return result
}
至此整个水印SDK的开发工作已经完成
4. 发布到npm
主要参考:
这两篇文章说的已经非常详细啦,本文就不再进行多余陈述,欢迎指正,完整代码传送门:GitHub,感谢点赞评论!