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 })
}
複製程式碼
拼接圖片
拼接圖片的操作相對來說最為複雜。這裡我們提供了兩個配置項:拼接模式(水平/垂直)以及背景顏色。拼接模式比較好理解,無非是水平或是垂直排列圖片。背景顏色則用於填充留白處。拼接圖片時,圖片以根據軸線居中排列。以水平排列圖片為例,示意圖如下:
這裡也沒有 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 更多的用法以後如果還有機會折騰,會繼續跟大家分享~
本文首發於我的部落格(點此檢視),歡迎關注。