TS从0到1实现一个完整前端水印SDK,并发布到NPM(下)

1,838 阅读6分钟

本系列文章将分上上下两篇文章,使用typescript从0到1实现一个前端水印sdk,并发发布到npm(包名为l-watermark)

先会用,再看原理,传送门:TS从0到1实现一个完整前端水印SDK,并发布到NPM(上)

完整代码地址:GitHub

本文将对前端水印的原理以及代码实现做详细论述,包括给页面添加水印、给图片添加水印、暗水印、发布到npm等。先看下水印SDK的流程图:

watermark-ER图

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,感谢点赞评论!