前端實現水印功能

jansen發表於2022-06-16

前端水印

為什麼要有水印的存在?

  1. 保護智慧財產權,防止未經允許被隨意盜用。比如淘寶美團的圖片,背後都有水印。
  2. 保護公司機密資訊,防止有心之人洩密

通常來說,前後端都可以實現水印的新增。

  • 前端水印適用場景:資源不跟某一個單獨的使用者繫結,而是一份資源,多個使用者檢視,需要在每一個使用者檢視的時候新增使用者特有的水印,多用於某些機密文件或者展示機密資訊的頁面,水印的目的在於文件外流的時候可以追究到責任人
  • 服務端水印使用場景:資源為某個使用者獨有,一份原始資源只需要做一次處理,將其儲存之後就無需再次處理,水印的目的在於標示資源的歸屬人

從前端的角度來說,有哪些實現方案

DOM覆蓋

利用div來做水印,需要兩個關鍵css屬性。
use-select:nonepointer-events,不讓使用者選中我這個水印,以及讓使用者穿透我這個水印遮罩。
然後利用想要出現水印區域的寬高以及水印塊的寬高,計算出我需要生成多少個水印塊,然後鋪開。

initDivWaterMark(userId: string) {
    const waterHeight = 100
    const waterWidth = 100
    const { clientWidth, clientHeight } =
      document.documentElement || document.body
    const column = Math.ceil(clientWidth / waterWidth)
    const rows = Math.ceil(clientHeight / waterHeight)
    for (let i = 0; i < column * rows; i++) {
      const wrap = document.createElement('div')
      wrap.setAttribute('class', 'watermark-item')
      wrap.style.width = waterWidth + 'px'
      wrap.style.height = waterHeight + 'px'
      wrap.textContent = userId
      this.box.appendChild(wrap)
    }
}

ok,可以看到,我們的水印出現了。但是有一個問題是,使用dom重複生成的話,還不停的append的話,感覺不優雅。而且一下就被人看到了,所以也可以用shadowdom

shadowdom ShadowDom MDN

Web components 的一個重要屬性是封裝——可以將標記結構、樣式和行為隱藏起來,並與頁面上的其他程式碼相隔離,保證不同的部分不會混在一起,可使程式碼更加乾淨、整潔。其中,Shadow DOM 介面是關鍵所在,它可以將一個隱藏的、獨立的 DOM 附加到一個元素上。說白了就是隔離。

可以使用 Element.attachShadow() 方法來將一個 shadow root 附加到任何一個元素上。它接受一個配置物件作為引數,該物件有一個 mode 屬性,值可以是 open 或者 closed

let shadow = elementRef.attachShadow({mode: 'open'});
let shadow = elementRef.attachShadow({mode: 'closed'});

open 表示可以通過頁面內的 JavaScript 方法來獲取 Shadow DOM,例如使用 Element.shadowRoot 屬性:

let myShadowDom = myCustomElem.shadowRoot;

如果你將一個 Shadow root 附加到一個 Custom element 上,並且將 mode 設定為 closed,那麼就不可以從外部獲取 Shadow DOM 了, myCustomElem.shadowRoot 將會返回 null。瀏覽器中的某些內建元素就是如此,例如<video>,包含了不可訪問的 Shadow DOM

所以為了簡單,我們用了closed

initShadowdomWaterMark(userId: string) {
    const shadowRoot = this.box.attachShadow({ mode: 'closed' })

    const waterHeight = 100
    const waterWidth = 100
    const { clientWidth, clientHeight } =
      document.documentElement || document.body
    const column = Math.ceil(clientWidth / waterWidth)
    const rows = Math.ceil(clientHeight / waterHeight)
    for (let i = 0; i < column * rows; i++) {
      const wrap = document.createElement('div')
      wrap.setAttribute('class', 'watermark-item')
      //   const styleStr = `
      //             color: #f20;
      //             text-align: center;
      //             transform: rotate(-30deg);
      //         `
      //   wrap.setAttribute('style', styleStr)

      //   wrap.setAttribute('part', 'watermark')
      wrap.style.width = waterWidth + 'px'
      wrap.style.height = waterHeight + 'px'
      wrap.textContent = userId
      shadowRoot.appendChild(wrap)
    }
}

可有看到,這樣寫,樣式沒有了,其實就是因為 shadowdom 起到了隔離的作用,微前端裡很重要的一個點就是沙箱隔離,其中像qiankun這樣的框架的css隔離就是用了shadowdom

使用內聯或者使用特殊的:偽類也可以解決,這裡就先直接內聯了。 Css part

canvas/svg背景圖

可以看到,不管是使用dom還是shadowdom,都避免不了的進行for迴圈來進行新增元素,還需要計算。依然不是那麼的優雅。所以我們可以考慮用canvas輸出一個背景圖,然後通過background-repeat: repeat來實現。

getCanvasUrl(userId: string) {
    const angle = -30
    const txt = userId
    this.canvas = document.createElement('canvas')
    this.canvas.width = 100
    this.canvas.height = 100
    this.ctx = this.canvas.getContext('2d')!
    this.ctx.clearRect(0, 0, 100, 100)
    this.ctx.fillStyle = '#f20'
    this.ctx.font = `14px`
    this.ctx.rotate((Math.PI / 180) * angle)
    this.ctx.fillText(txt, 0, 50)
    return this.canvas.toDataURL()
}

可以看到,我們只用了一個標籤,以及背景圖的方式,來實現了一個水印,但是呢,如果你是一個有心之人,我們只需要動動手指,開啟F12,把這個標籤刪了,或者修改它的背景,都可以把水印去掉。那我們應該怎麼辦呢?那就需要用到MutationObserver了,說到觀察者,現在使用的頻率也是越來越高了。

MutationObserverMDN

MutationObserver應用的方式還挺多的

  1. 如我們上週提到的guide mask的解決方案問題。我們可以通過mutationObserver對它要籠罩的父級節點進行監控,並設定一個超時時間disconnect,在時間內對它進行矯正。我曾經有做過一個錨點的功能,也利用到了它來進行矯正操作。
  2. 我們可以通過MutationObserver來對真正的可用效能進行監控,通過判斷節點的增加趨勢,來獲得真正可以使用的時間點。
  3. Vue nexttick的實現原理,利用MutationObserver是個micro task,來進行下一tick的通知。當然是promise.then不好使的情況下,模擬實現如下

    function myNextTick(func) {
         var textNode = document.createTextNode('0'); //新建文字節點
         var callback = (mutationsList, observer) => {
             func.call(this);
         };
         var observer = new MutationObserver(callback);
         observer.observe(textNode, { characterData: true });
         textNode.data = '1'; //修改文字資訊,觸發dom更新
     }
    
  4. 監控我們的水印節點是否被變更,我們也可以針對性的進行恢復。
initObserver() {
    // 觀察器的配置
    const config = { attributes: true, childList: true, subtree: true }
    // 當觀察到變動時執行的回撥函式
    const callback: MutationCallback = (mutationsList, observer) => {
      for (const mutation of mutationsList) {
        mutation.removedNodes.forEach((item) => {
          if (item === this.box) {
            this.warnningTargetChanged = true
            // 省事,直接新增在body上了
            document.body.appendChild(this.box)
          }
        })
      }
      if (this.warnningTargetChanged) {
        this.warnningTargetChanged = false
        console.log(`使用者${this.userId}的水印變動了,可能涉嫌違規操作!!!`)
      }
    }
    // 監聽元素
    const targetNode = document.body
    // 建立一個觀察器例項並傳入回撥函式
    const observer = new MutationObserver(callback)
    // 以上述配置開始觀察目標節點
    observer.observe(targetNode, config)
} 

當然,並不是說這樣就萬無一失了,因為我們還可以通過 disabled javascript來解決。像有的網站,開啟F12就會無限迴圈debugger,也可以解決。

暗水印

那如果說,就是有這樣的人存在,統統都能搞定呢?這時候就需要隱藏水印的出現了。比如大眾點評上面的圖片,其實也都是有隱藏版權的水印存在的。如果是商用盜用,都是能被查到的。

暗水印的生成方式有很多,常見的為通過修改RGB 分量值的小量變動、DWT、DCT 和 FFT 等方法。DFT、DCT和DWT的聯絡和區別

前端實現主要看RGB 分量值的小量變動。

我們都知道圖片都是有一個個畫素點構成的,每個畫素點都是由 RGB 三種元素構成。當我們把其中的一個分量修改,人的肉眼是很難看出其中的變化,甚至是畫素眼的設計師也很難分辨出。

那麼,我們只要能獲取到一張圖片上每個畫素點上的具體資訊,就可以再RGB上動動手腳,就可以把我們想要的資訊藏進去。那如何獲取畫素點的資訊呢?
就需要用到 canvas 的 CanvasRenderingContext2D.getImageData() 了,這個方法會返回一個 ImageData物件,其中就包含了畫素的資訊陣列。所以我們應該可以利用這個方法,來做取色器

這個一維陣列儲存了所有的畫素資訊,一共有 256 256 4 = 262144 個值。其中 4 個值一組,為什麼呢?在瀏覽器中解析圖片,除了 RGB 值外,每組第 4 個值為透明度值,即畫素資訊實際為大家熟知的 rgba 值。

pic

以我們想要藏的文字資訊為例

    const txt = '測試點'
    this.canvas = document.createElement('canvas')
    this.canvas.width = 10
    this.canvas.height = 10
    this.ctx = this.canvas.getContext('2d')
    this.ctx.clearRect(0, 0, 10, 10)
    this.ctx.font = `14px`

    this.ctx.fillText(txt, 0, 0)
    const textData = this.ctx.getImageData(0, 0, 10, 10).data

把上面程式碼複製到控制檯可以發現,字型的資料基本都是0,0,0,xx。

那現在我們有了文字資料和圖片資料,我們就可以設計一個演算法。以R通道為例子,這個是紅色通道,(255, 0, 0, 255) 就代表的是純紅色。

我們遍歷圖片資料,檢查它的每一個R點位,如果這個點位的文字資料是有的,也就是alpha 值不為0,那我們就強行把當前圖片資訊的這個點的值改成奇數,如果這個點位沒有數字,就把它改成偶數。

那麼最後,這個圖片資料裡奇數的部分,就是有文字蓋著的部分。而偶數部分,就是無關緊要的了。那最後想要找到我們的目標文案的時候,只需要把奇數部分的值變成255,把其他通道以及偶數部分的,全都改成0。 文字就出現了。

// 加密核心方法
 for (let i = 0; i < oData.length; i++) {
      if (i % 4 === bit) {
        // 如果文字在這裡沒有資料,並且圖片R通道是奇數,那我們把它改成偶數。
        if (newData[i + offset] === 0 && oData[i] % 2 === 1) {
          // 沒有資訊的畫素,該通道最低位置0,但不要越界
          if (oData[i] === 255) {
            oData[i]--
          } else {
            oData[i]++
          }
          // 如果文字在這裡是有資料的,並且圖片R通道是偶數,那我們把它改成奇數。
        } else if (newData[i + offset] !== 0 && oData[i] % 2 === 0) {
          oData[i]++
        }
        // 也就是說,如果是奇數,說明一定是有文字壓在上面的
      }
}

// 解密核心方法
for (let i = 0; i < data.length; i++) {
      // R通道
      if (i % 4 === 0) {
        // 目標分量,把偶數的關閉。因為文字沒有資料在這裡。
        if (data[i] % 2 === 0) {
          data[i] = 0
        } else {
          data[i] = 255
          data[i + 3] = 255
        }
      } else if (i % 4 === 3) {
        continue
      } else {
        data[i] = 0
      }
}

但是我們實際想要見到的隱藏水印的形式,肯定不是侷限在圖片上的。我們希望的是給我們整體的文章之類的打上隱藏水印。那這怎麼做呢?

其實我們可以把之前cavans的水印,把顏色換成黑色,旋轉去掉,透明度降低到0.005,為啥是這個值,因為0.005 * 255=1.27。基本算是最小透明度了。但是因為我們是高層級,所以截圖的時候,一定會把這資訊包含進去。那麼我是(0,0,0,1)疊加到原來的圖片上,至少會影響到原圖的顏色,也就是說它的R通道,起碼會動個1。我編不下去了。我也不知道為啥。但是確實可以。大家可以一起探討下。

相關文章