前言
svg 是一種向量圖形,在 web 上應用很廣泛,但是很多時候由於應用的場景,常常需要將 svg 轉為 png 格式,下載到本地等。隨著瀏覽器對 HTML 5 的支援度越來越高,我們可以把 svg 轉為 png 的工作交給瀏覽器來完成。
一般方式
- 建立 imageimage,src = xxx.svg;
- 建立 canvas,dragImage 將圖片貼到 canvas 上;
- 利用 toDataUrl 函式,將 canvas 的表示為 url;
- new image, src = url, download = download.png;
但是,在轉換的時候有時有時會碰到如下的如下的兩個問題:
問題 1 :瀏覽器對 canvas 限制
Canvas 的 W3C 的標準上沒有提及 canvas 的最大高/寬度和麵積,但是每個廠商的瀏覽器出於瀏覽器效能的考慮,在不同的平臺上設定了最大的高/寬度或者是渲染面積,超過了這個閾值渲染的結果會是空白。測試了幾種瀏覽器的 canvas 效能如下:
- chrome (版本 46.0.2490.80 (64-bit))
- 最大面積:268, 435, 456 px^2 = 16, 384 px * 16, 384 px
- 最大寬/高:32, 767 px
- firefox (版本 42.0)
- 最大面積:32, 767 px * 16, 384 px
- 最大寬/高:32, 767px
- safari (版本 9.0.1 (11601.2.7.2))
- 最大面積: 268, 435, 456 px^2 = 16, 384 px * 16, 384 px
- ie 10(版本 10.0.9200.17414)
- 最大寬/高: 8, 192px * 8, 192px
在一般的 web 應用中,可能很少會超過這些限制。但是,如果超過了這些限制,則會導致匯出為空白或者由於記憶體洩露造成瀏覽器崩潰。
而且從另一方面來說,匯出 png 也是一項很消耗記憶體的操作,粗略估算一下,匯出 16, 384 px * 16, 384 px 的 svg 會消耗 16384 * 16384 * 4 / 1024 / 1024 = 1024 M 的記憶體。所以,在接近這些極限值的時候,瀏覽器也會反應變慢,能否匯出成功也跟系統的可用記憶體大小等等都有關係。
對於這個問題,有如下兩種解決方法:
- 將資料傳送給後端,在後端完成轉換;
- 前端將 svg 切分成多個圖片匯出;
第一種方法可以使用 PhantomJS、inkscape、ImageMagick 等工具,相對來說比較簡單,這裡我們主要探討第二種解決方法。
svg 切分成多個圖片匯出
思路:瀏覽器雖然對 canvas 有尺寸和麵積的限制,但是對於 image 元素並沒有明確的限制,也就是第一步生成的 image 其實顯示是正常的,我們要做的只是在第二步 dragImage
的時候分多次將 image 元素切分並貼到 canvas 上然後下載下來。 同時,應注意到 image 的載入是一個非同步的過程。
關鍵程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
// 構造 svg Url,此處省略將 svg 經字元過濾後轉為 url 的過程。 var svgUrl = DomURL.createObjectURL(blob); var svgWidth = document.querySelector('#kity_svg').getAttribute('width'); var svgHeight = document.querySelector('#kity_svg').getAttribute('height'); // 分片的寬度和高度,可根據瀏覽器做適配 var w0 = 8192; var h0 = 8192; // 每行和每列能容納的分片數 var M = Math.ceil(svgWidth / w0); var N = Math.ceil(svgHeight / h0); var idx = 0; loadImage(svgUrl).then(function(img) { while(idx < M * N) { // 要分割的面片在 image 上的座標和尺寸 var targetX = idx % M * w0, targetY = idx / M * h0, targetW = (idx + 1) % M ? w0 : (svgWidth - (M - 1) * w0), targetH = idx >= (N - 1) * M ? (svgHeight - (N - 1) * h0) : h0; var canvas = document.createElement('canvas'), ctx = canvas.getContext('2d'); canvas.width = targetW; canvas.height = targetH; ctx.drawImage(img, targetX, targetY, targetW, targetH, 0, 0, targetW, targetH); console.log('now it is ' + idx); // 準備在前端下載 var a = document.createElement('a'); a.download = 'naotu-' + idx + '.png'; a.href = canvas.toDataURL('image/png'); var clickEvent = new MouseEvent('click', { 'view': window, 'bubbles': true, 'cancelable': false }); a.dispatchEvent(clickEvent); idx++; } }, function(err) { console.log(err); }); // 載入 image function loadImage(url) { return new Promise(function(resolve, reject) { var image = new Image(); image.src = url; image.crossOrigin = 'Anonymous'; image.onload = function() { resolve(this); }; image.onerror = function(err) { reject(err); }; }); } |
說明:
- 由於在前端下載有瀏覽器相容性、使用者體驗等問題,在實際中,可能需要將生成後的資料傳送到後端,並作為一個壓縮包下載。
- 分片的尺寸這裡使用的是 8192 * 9192,在實際中,為了增強相容性和體驗,可以根據瀏覽器和平臺做適配,例如在 iOS 下的 safari 的最大面積是 4096 *4096。
問題 2 :匯出包含圖片的 svg
在匯出的時候,還會碰到另一個問題:如果 svg 裡面包含圖片,你會發現通過以上方法匯出的 png 裡面,原來的圖片是不顯示的。一般認為是 svg 裡面包含的圖片跨域了,但是如果你把這個圖片換成本域的圖片,還是會出現這種情況。
圖片中上部分是匯出前的 svg,下圖是匯出後的 png。svg 中的圖片是本域的,在匯出後不顯示。
問題來源
我們按照文章最開始提出的步驟,逐步排查,會發現在第一步的時候,svg 中的圖片就不顯示了。也就是,當 image 元素的 src 為一個 svg,並且 svg 裡面包含圖片,那麼被包含的圖片是不會顯示的,即使這個圖片是本域的。
W3C 關於這個問題並沒有做說明,最後在 https://bugzilla.mozilla.org/show_bug.cgi?id=628747 找到了關於這個問題的說明。意思是:禁止這麼做是出於安全考慮,svg 裡面引用的所有 外部資源 包括 image, stylesheet, script 等都會被阻止。
裡面還舉了一個例子:假設沒有這個限制,如果一個論壇允許使用者上傳這樣的 svg 作為頭像,就有可能出現這樣的場景,一位黑客上傳 svg 作為頭像,裡面包含程式碼:<image xlink:href="http://evilhacker.com/myimage.png">
(假設這位黑客擁有對於 evilhacker.com 的控制權),那麼這位黑客就完全能做到下面的事情:
- 只要有人檢視他的資料,evilhacker.com 就會接收到一次 ping 的請求(進而可以拿到檢視者的 ip);
- 可以做到對於不同的 ip 地址的人展示不一樣的頭像;
- 可以隨時更換頭像的外觀(而不用通過論壇管理員的稽核)。
看到這裡,大概就明白了整個問題的來龍去脈了,當然還有一點原因可能是避免影像遞迴。
解決辦法
思路:由於安全因素,其實第一步的時候,圖片已經顯示不出來了。那麼我們現在考慮的方法是在第一步之後遍歷 svg 的結構,將所有的 image 元素的 url、位置和尺寸儲存下來。在第三步之後,按順序貼到 canvas 上。這樣,最後匯出的 png 圖片就會有 svg 裡面的 image。關鍵程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
// 此處略去生成 svg url 的過程 var svgUrl = DomURL.createObjectURL(blob); var svgWidth = document.querySelector('#kity_svg').getAttribute('width'); var svgHeight = document.querySelector('#kity_svg').getAttribute('height'); var embededImages = document.querySelectorAll('#kity_svg image'); // 由 nodeList 轉為 array embededImages = Array.prototype.slice.call(embededImages); // 載入底層的圖 loadImage(svgUrl).then(function(img) { var canvas = document.createElement('canvas'), ctx = canvas.getContext("2d"); canvas.width = svgWidth; canvas.height = svgHeight; ctx.drawImage(img, 0, 0); // 遍歷 svg 裡面所有的 image 元素 embededImages.reduce(function(sequence, svgImg){ return sequence.then(function() { var url = svgImg.getAttribute('xlink:href') + 'abc', dX = svgImg.getAttribute('x'), dY = svgImg.getAttribute('y'), dWidth = svgImg.getAttribute('width'), dHeight = svgImg.getAttribute('height'); return loadImage(url).then(function(sImg) { ctx.drawImage(sImg, 0, 0, sImg.width, sImg.height, dX, dY, dWidth, dHeight); }, function(err) { console.log(err); }); }, function(err) { console.log(err); }); }, Promise.resolve()).then(function() { // 準備在前端下載 var a = document.createElement("a"); a.download = 'download.png'; a.href = canvas.toDataURL("image/png"); var clickEvent = new MouseEvent("click", { "view": window, "bubbles": true, "cancelable": false }); a.dispatchEvent(clickEvent); }); }, function(err) { console.log(err); }) // 省略了 loadImage 函式 // 程式碼和第一個例子相同 |
說明:
- 例子中 svg 裡面的影像是根節點下面的,因此用於表示位置的 x, y 直接取來即可使用,在實際中,這些位置可能需要跟其他屬性做一些運算之後得出。如果是基於 svg 庫構建的,那麼可以直接使用庫裡面用於定位的函式,比直接從底層運算更加方便和準確。
- 我們這裡討論的是本域的圖片的匯出問題,跨域的圖片由於「汙染了」畫布,在執行
toDataUrl
函式的時候會報錯。
結語
在這裡和大家分享了在前端將 svg 轉為 png 的方法和過程中可能會遇到的兩個問題,一個是瀏覽器對 canvas 的尺寸限制,另一個是匯出圖片的問題。當然,這兩個問題還有其他的解決方法,同時由於知識所限,本文內容難免有紕漏,歡迎大家批評指正。最後感謝@techird 和 @Naxior 關於這兩個問題的討論。