Node.js 服務端圖片處理利器——sharp 進階操作指南

逆葵發表於2018-05-28

sharp 是 Node.js 平臺上相當熱門的一個影象處理庫,其實際上是基於 C 語言編寫 的 libvips 庫封裝而來,因此高效能也成了 sharp 的一大賣點。sharp 可以方便地實現常見的圖片編輯操作,如裁剪、格式轉換、旋轉變換、濾鏡新增等。當然,網路上相關的文章比較多,sharp 的官方文件也比較詳細,所以這不是本文的重點。這裡主要是想記錄一下我在使用 sharp 過程中遇到的一些稍複雜的圖片處理需求的解決方案,希望分享出來能夠對大家有所幫助。

sharp 基礎

sharp 整體採用流式處理模式,其在讀入影象資料後經過一系列的處理加工然後輸出結果。我們看一個簡單的示例就能理解:

const sharp = require('sharp');
sharp('input.jpg')
  .rotate()
  .resize(200)
  .toBuffer()
  .then( data => ... )
  .catch( err => ... );
複製程式碼

sharp 幾乎所有的函式介面都掛載在 Sharp 例項上,因此影象處理的第一步操作一定是讀入圖片資料(sharp 函式接受圖片本地路徑或者圖片 Buffer 資料作為引數)並將其轉換為 Sharp 例項,然後才是如流水線一般的加工。因此,這裡應該提供一個預處理函式,將服務端接收到的圖片轉換為 Sharp 例項:

/**
*
* @param  { String | Buffer } inputImg 圖片本地路徑或圖片 Buffer 資料
* @return { Sharp }
*/
async convert2Sharp(inputImg) {
    return sharp(inputImg)
}
複製程式碼

然後就可以進行具體的影象處理。

新增水印

後端實現

新增水印功能應該算是比較常見的圖片處理需求了。sharp 在影象合成方面只提供了一個函式:overlayWith,其接受一個圖片引數(同樣是圖片本地路徑字串或者圖片 Buffer 資料)以及一個可選的 options 配置物件(可配置水印圖片的位置等資訊)然後將該圖片覆蓋到原圖上。邏輯上也比較簡單,我們的程式碼如下所示:

/**
* 新增水印
* @param  { Sharp  } img 原圖
* @param  { String } watermarkRaw 水印圖片
* @param  { top } 水印距圖片上邊緣距離
* @param  { left } 水印距圖片左邊緣距離
*/
async watermark(img, { watermarkRaw, top, left }) {
    const watermarkImg = await watermarkRaw.toBuffer()
    return img
        .overlayWith(watermarkImg, { top, left })
}
複製程式碼

這裡簡單起見只支援配置水印圖片的位置,sharp 還支援更復雜的配置引數比如是否重複貼上多個水印圖片、是否只在 α 通道貼上水印圖片等,具體可參見 overlayWith 的文件。

前端實現

這裡還需要順帶提一下前端的實現。當然,如果服務端是按照固定規則給圖片新增水印(比如新浪微博裡圖片水印放置在固定的位置),前端就不必做什麼了。但是某些場景下(比如線上圖片編輯類工具中)使用者新增水印的時候會期望能夠在前端獲得所見即所得的體驗。這個時候如果使用者新增完水印並且選好位置後,必須將資料傳送至服務端處理再得到處理結果,勢必會影響整個服務的流暢性。幸運的是強大的 HTML5 讓前端的功能越來越豐富,藉助 canvas 我們就能在前端實現新增水印的功能。具體的實現細節並不難,主要就是藉助了 canvas 提供的 drawImage 方法,看一下示例:

var canvas = document.getElementById("canvas");
var ctx = canvas.getContext('2d');

// img: 底圖
// watermarkImg: 水印圖片
// x, y 是畫布上放置 img 的座標
ctx.drawImage(img, x, y);
ctx.drawImage(watermarkImg, x, y);
複製程式碼

實際上,整個新增水印的功能(選擇原圖、選擇水印圖片、設定水印圖片位置、獲得新增水印後的圖片)是可以完全由前端完成的。當然,為了追求服務端功能的完整性,還是建議使用前端展示+後端處理的模式。

貼上文字

貼上文字的需求實際上與新增水印比較類似。唯一不同的是新增的水印圖片換成了文字,以及我們可能需要對文字的大小、字型等做一些調整。思路也比較容易想到,把文字轉換成圖片形式即可。這裡我們用到了 text-to-svg 庫,作用是將文字轉換成 svg。利用 svg 的特點我們可以很方便地設定文字的字型大小、顏色等。然後呼叫 Buffer.from 將 svg 轉換為 sharp 可以使用的 buffer 資料。最後就是和上面的水印新增一樣的步驟了。

const Text2SVG = require('text-to-svg')

/**
* 貼上文字
* @param  { Sharp  } img
* @param  { String } text 待貼上文字
* @param  { Number } fontSize 文字大小
* @param  { String } color 文字顏色
* @param  { Number } left 文字距圖片左邊緣距離
* @param  { Number } top 文字距圖片上邊緣距離
*/
async pasteText(img, {
    text, fontSize, color, left, top,
}) {
    const text2SVG = Text2SVG.loadSync()
    const attributes = { fill: color }
    const options = {
        fontSize,
        anchor: 'top',
        attributes,
    }
    const svg = Buffer.from(text2SVG.getSVG(text, options))
    return img
        .overlayWith(svg, { left, top })
}
複製程式碼

拼接圖片

拼接圖片的操作相對來說最為複雜。這裡我們提供了兩個配置項:拼接模式(水平/垂直)以及背景顏色。拼接模式比較好理解,無非是水平或是垂直排列圖片。背景顏色則用於填充留白處。拼接圖片時,圖片以根據軸線居中排列。以水平排列圖片為例,示意圖如下:

Node.js 服務端圖片處理利器——sharp 進階操作指南

這裡也沒有 sharp 提供的現成函式,一切還是用唯一的 overlayWith 解決。overlayWith 的用法是將一張圖貼上至另一張圖上,這與我們拼接圖片的需求略有差異。我們需要轉換一下思維:可以預先建立一張底圖,背景顏色可以根據配置值確定,然後將所有待拼接圖片貼上至其上,即可滿足要求。

首先我們需要讀取所有待拼接圖片的長與寬。假設拼接模式為水平拼接,那麼最終生成的圖片的寬度為所有圖片寬度之和,高度則取所有圖片中的最大高度(垂直拼接的話則反過來):

let totalWidth = 0
let totalHeight = 0
let maxWidth = 0
let maxHeight = 0
const imgMetadataList = []
// 獲取所有圖片的寬和高,計算和及最大值
for (let i = 0, j = imgList.length; i < j; i += i) {
    const { width, height } = await imgList[i].metadata()
    imgMetadataList.push({ width, height })
    totalHeight += height
    totalWidth += width
    maxHeight = Math.max(maxHeight, height)
    maxWidth = Math.max(maxWidth, width)
}
複製程式碼

然後我們用得到的寬度和高度資料新建一個背景顏色為傳入配置(或預設白色)的 base 圖片:

const baseOpt = {
    width: mode === 'horizontal' ? totalWidth : maxWidth,
    height: mode === 'vertical' ? totalHeight : maxHeight,
    channels: 4,
    background: background || {
        r: 255, g: 255, b: 255, alpha: 1,
    },
}

const base = sharp({
    create: baseOpt,
}).jpeg().toBuffer()
複製程式碼

然後在 base 圖片的基礎上重複呼叫 overlayWith 函式,將待拼接圖片逐個貼上至 base 圖片上。這裡需要注意的是圖片的擺放位置,前面也提到過,我們會將圖片根據主軸線進行居中對齊,所以每次擺放圖片時都需要進行 top 和 left 的計算(一個是居中的計算,一個是隨著圖片擺放順序進行偏移的計算),當然,弄明白了原理之後就是小學數學題,沒有太多可講的。另一個需要注意的則是 overlayWith 每次只能完成兩張圖片之間的合成,因此我們用到了 reduce 方法,持續地將圖片貼上至底圖上,並將結果作為下一次的輸入。

imgMetadataList.unshift({ width: 0, height: 0 })
let imgIndex = 0
const result = await imgList.reduce(async (input, overlay) => {
    const offsetOpt = {}
    if (mode === 'horizontal') {
        offsetOpt.left = imgMetadataList[imgIndex++].width
        offsetOpt.top = (maxHeight - imgMetadataList[imgIndex].height) / 2
    } else {
        offsetOpt.top = imgMetadataList[imgIndex++].height
        offsetOpt.left = (maxWidth - imgMetadataList[imgIndex].width) / 2
    }
    overlay = await overlay.toBuffer()
    return input.then(data => sharp(data).overlayWith(overlay, offsetOpt).jpeg().toBuffer())
}, base)
return result
複製程式碼

以下是拼接圖片函式的完整實現:

/**
* 拼接圖片
* @param  { Array<Sharp> } imgList
* @param  { String } mode 拼接模式:horizontal(水平)/vertical(垂直)
* @param  { Object } background 背景顏色 格式為 {r: 0-255, g: 0-255, b: 0-255, alpha: 0-1} 預設 {r: 255, g: 255, b: 255, alpha: 1}
*/
async joinImage(imgList, { mode, background }) {
    let totalWidth = 0
    let totalHeight = 0
    let maxWidth = 0
    let maxHeight = 0
    const imgMetadataList = []
    // 獲取所有圖片的寬和高,計算和及最大值
    for (let i = 0, j = imgList.length; i < j; i += i) {
        const { width, height } = await imgList[i].metadata()
        imgMetadataList.push({ width, height })
        totalHeight += height
        totalWidth += width
        maxHeight = Math.max(maxHeight, height)
        maxWidth = Math.max(maxWidth, width)
    }

    const baseOpt = {
        width: mode === 'horizontal' ? totalWidth : maxWidth,
        height: mode === 'vertical' ? totalHeight : maxHeight,
        channels: 4,
        background: background || {
            r: 255, g: 255, b: 255, alpha: 1,
        },
    }

    const base = sharp({
        create: baseOpt,
    }).jpeg().toBuffer()

    // 獲取圖片的原始尺寸用於偏移
    imgMetadataList.unshift({ width: 0, height: 0 })
    let imgIndex = 0
    const result = await imgList.reduce(async (input, overlay) => {
        const offsetOpt = {}
        if (mode === 'horizontal') {
            offsetOpt.left = imgMetadataList[imgIndex++].width
            offsetOpt.top = (maxHeight - imgMetadataList[imgIndex].height) / 2
        } else {
            offsetOpt.top = imgMetadataList[imgIndex++].height
            offsetOpt.left = (maxWidth - imgMetadataList[imgIndex].width) / 2
        }
        overlay = await overlay.toBuffer()
        return input.then(data => sharp(data).overlayWith(overlay, offsetOpt).jpeg().toBuffer())
    }, base)
    return result
},
複製程式碼

以上就是個人在使用 sharp 過程中總結的一些實用操作。實際上 sharp 還有很多高階的功能我並沒有用到,正應了“二八定律”:80% 的需求常常是通過 20% 的功能完成的。sharp 更多的用法以後如果還有機會折騰,會繼續跟大家分享~

本文首發於我的部落格(點此檢視),歡迎關注。

相關文章