SVG與foreignObject元素
可縮放矢量圖形Scalable Vector Graphics - SVG
基於XML
標記語言,用於描述二維的矢量圖形。作爲一個基於文本的開放網絡標準,SVG
能夠優雅而簡潔地渲染不同大小的圖形,並和CSS
、DOM
、JavaScript
等其他網絡標準無縫銜接。SVG
圖像及其相關行爲被定義於XML
文本文件之中,這意味着可以對其進行搜索、索引、編寫腳本以及壓縮,此外這也意味着可以使用任何文本編輯器和繪圖軟件來創建和編輯SVG
。
SVG
SVG
是可縮放矢量圖形Scalable Vector Graphics
的縮寫,其是一種用於描述二維矢量圖形的XML
可擴展標記語言標準,與基於像素的圖像格式(如JPEG
和PNG
)不同,SVG
使用數學方程和幾何描述來定義圖像,這使得其能夠無損地縮放和調整大小,而不會失真或模糊。SVG
圖像由基本形狀(如線段、曲線、矩形、圓形等)和路徑組成,還可以包含文本、漸變、圖案和圖像剪裁等元素。SVG
圖形可以使用文本編輯器手動創建,也可以使用專業的矢量圖形編輯軟件生成,其可以在Web頁面上直接嵌入,也可以通過CSS
樣式表和JavaScript
進行控制和交互,由於SVG
圖形是基於矢量的,因此在放大或縮小時不會失去清晰度,這使得SVG
在響應式設計、圖標、地圖、數據可視化和動畫等領域中非常有用。此外SVG
還兼容支持各種瀏覽器,並且可以與其他Web
技術無縫集成。
SVG
有着諸多優點,並且擁有通用的標準,但是也存在一些限制,那麼在這裏我們主要討論SVG
中text
元素也就是文本元素的一些侷限。SVG
的text
元素提供了基本的文本渲染功能,可以在指定位置繪製單行或多行文本,然而SVG
並沒有提供像HTML
和CSS
中的強大布局功能,比如文本自動換行、對齊方式等,這意味着在SVG
中實現複雜的文本佈局需要手動計算和調整位置。此外SVG
的text
元素支持一些基本的文字樣式屬性,如字體大小、顏色、字體粗細等,然而相對於CSS
提供的豐富樣式選項,SVG
的文字樣式相對有限,例如無法直接設置文字陰影、文字間距等效果等。
實際上在平時使用中我們並不需要關注這些問題,但是在一些基於SVG
的可視化編輯器中比如DrawIO
中這些就是需要重視的問題了,當然現在可能可視化編輯更多的會選擇使用Canvas
來實現,但是這個複雜度非常高,就不在本文討論範圍內了。那麼如果使用text
來繪製文本在日常使用中最大的問題實際上就是文本的換行,如果只是平時人工來繪製SVG
可能並沒有什麼問題,text
同樣提供了大量的屬性來展示文本,但是想做一個通用的解決方案可能就麻煩一點了,舉個例子如果我想批量生成一些SVG
,那麼人工單獨調整文本是不太可能的,當然在這個例子中我們還是可以批量去計算文字寬度來控制換行的,但是我們更希望的是有一種通用的能力來解決這個問題。我們可以先來看看文本溢出不自動換行的例子:
-----------------------------------
| This is a long text that cannot | automatically wrap
| |
| |
-----------------------------------
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="200">
<g>
<rect width="200" height="100" fill="lightgray" />
<text x="10" y="20" font-size="12" fill="black">
This is a long text that cannot automatically wrap within the rectangle.
</text>
</g>
</svg>
在這個例子中,text
元素是無法自動換行的,即使在text
元素上添加width
屬性也是無法實現這個效果的。此外<text>
標籤不能直接放在<rect>
標籤內部,其具有嚴格的嵌套規則,<text>
標籤是一個獨立的元素,用於在SVG
畫布上繪製文本,而<rect>
標籤是用於繪製矩形的元素,所以繪製的矩形並沒有限制文本展示範圍,但是實際上這個文本的長度是超出了整個SVG
元素設置的width: 300
,也就是說這段文本實際上是沒有能夠完全顯示出來,從圖中也可以看出wrap
之後的文本沒有了,並且其並沒有能夠自動換行。如果想實現換行效果,則必須要自行計算文本長度與高度進行切割來計算位置:
-----------------------------------
| This is a long text that |
| cannot automatically wrap |
| within the rectangle. |
-----------------------------------
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="200">
<g>
<rect width="200" height="100" fill="lightgray" />
<text x="10" y="20" font-size="12" fill="black">
<tspan x="10" dy="1.2em">This is a long text that</tspan>
<tspan x="10" dy="1.2em">cannot automatically wrap </tspan>
<tspan x="10" dy="1.2em">within the rectangle.</tspan>
</text>
</g>
</svg>
foreignObject元素
那麼如果想以比較低的成本實現接近於HTML
的文本繪製體驗,可以藉助foreignObject
元素,<foreignObject>
元素允許在SVG
文檔中嵌入HTML
、XML
或其他非SVG
命名空間的內容,也就是說我們可以直接在SVG
中嵌入HTML
,藉助HTML
的能力來展示我們的元素,例如上邊的這個例子,我們就可以將其改造爲如下的形式:
-----------------------------------
| This is a long text that |
| will automatically wrap |
| within the rectangle. |
-----------------------------------
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="200">
<g>
<rect width="200" height="100" fill="lightgray" />
<foreignObject x="10" width="180" height="80">
<div xmlns="http://www.w3.org/1999/xhtml">
<p>This is a long text that will automatically wrap within the rectangle.</p>
</div>
</foreignObject>
</g>
</svg>
當我們打開DrawIO
繪製流程圖時,其實也能發現其在繪製文本時使用的就是<foreignObject>
元素,當然DrawIO
爲了更通用的場景做了很多兼容處理,特別是表現在行內樣式上,類似於上述例子中的SVG
在DrawIO
表現出來是如下的示例,需要注意的是,直接從DrawIO
導出的當前這個文件需要保存爲.html
文件而不是.svg
文件,因爲其沒有聲明命名空間,如果需要要保存爲.svg
文件並且能夠正常展示的話,需要在svg
元素上加入xmlns="http://www.w3.org/2000/svg"
命名空間聲明,但是僅僅加上這一個聲明是不夠的,如果此時打開.svg
文件發現只展示了矩形而沒有文字內容,此時我們還需要在<foreignObject>
元素的第一個<div>
上加入xmlns="http://www.w3.org/1999/xhtml"
的命名空間聲明,此時就可以將矩形與文字完整地表現出來。
-----------------------------------------------------
| This is a long text that will automatically wrap |
| within the rectangle. |
-----------------------------------------------------
<svg
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1"
width="263px"
height="103px"
viewBox="-0.5 -0.5 263 103"
>
<defs></defs>
<g>
<rect
x="1"
y="1"
width="260"
height="100"
fill="#ffffff"
stroke="#000000"
pointer-events="all"
></rect>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject
style="overflow: visible; text-align: left;"
pointer-events="none"
width="100%"
height="100%"
requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"
>
<div style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 258px; height: 1px; padding-top: 51px; margin-left: 2px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<div>
<span>
This is a long text that will automatically wrap within the rectangle.
</span>
</div>
</div>
</div>
</div>
</foreignObject>
<text
x="131"
y="55"
fill="#000000"
font-family="Helvetica"
font-size="12px"
text-anchor="middle"
>
This is a long text that will automatically...
</text>
</switch>
</g>
</g>
<switch>
<g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"></g>
<a
transform="translate(0,-5)"
xlink:href="https://desk.draw.io/support/solutions/articles/16000042487"
target="_blank"
>
<text text-anchor="middle" font-size="10px" x="50%" y="100%">
Viewer does not support full SVG 1.1
</text>
</a>
</switch>
</svg>
看起來一切都很完美,我們既能夠藉助SVG
繪製矢量圖形,又能夠在比較複雜的情況下藉助HTML
的能力完成需求,但是事情總有兩面性,當我們在某一方面享受到便利的時候,就可能在另一處帶來意想不到的麻煩。設想一個場景,假設此時我們需要在後端將SVG
繪製出來,然後將其轉換爲PNG
格式的圖片給予用戶下載,在前端做一些批量的操作是不太現實的,再假設我們需要將這個SVG
繪製出來拼接到Word
或者Excel
中,那麼這些操作都要求我們需要在後端完整地將整個圖片繪製出來,那麼此時我們可能會想到node-canvas
在後端創建和操作圖形,但是當我們真的使用node-canvas
繪製我們的SVG
圖形時例如上邊的DrawIO
的例子,會發現所有的圖形形狀是可以被繪製出來的,但是所有的文本都丟失了,那麼既然node-canvas
做不到,那麼我們可能會想到sharp
來完成圖像處理的相關功能,例如先將SVG
轉換爲PNG
,但是很遺憾的是sharp
也做不到這一點,最終效果與node-canvas
是一致的。
https://github.com/lovell/sharp/issues/3668
https://github.com/Automattic/node-canvas/issues/1325
那麼既然需求擺在這,而業務上又非常需要這個功能,那麼我們應該如何實現這個能力呢,實際上這個問題最終的結局方案反而很簡單,既然這個SVG
只能在瀏覽器中繪製,那麼我們直接在後端運行一個Headless Chromium
就可以了。那麼此時我們就可以藉助Puppeteer
,Puppeteer
允許我們以編程方式模擬用戶在瀏覽器中的行爲,進行網頁截圖、生成PDF
、執行自動化測試、進行數據抓取等任務。那麼此時我們的任務就變得簡單許多了,主要的麻煩是配置環境,Chromium
是有環境要求的,例如在Debian
系列的最新版Chromium
就需要Debian 10
以上的環境,並且還需要安裝依賴,可以藉助ldd xxxx/chrome | grep no
命令來檢查未安裝的動態鏈接庫。如果碰到安裝問題,也可以node node_modules/puppeteer/install.js
進行重試,此外還有一些字體的問題,因爲是在後端將文本渲染出來的,就需要服務器本身安裝一些中文字體,例如思源fonts-noto-cjk
、中文語言包language-pack-zh*
等等。
那麼在我們將環境搭建好了之後,後續就是要將SVG
渲染並且轉換爲Buffer
了,這個工作實際上比較簡單,只需要在我們的Headless Chromium
中將SVG
渲染出來,並且將ViewPort
截圖即可,Puppeteer
提供的API
比較簡單,並且方法有很多,下邊是一個例子,此外Puppeteer
能夠實現的能力還有很多,比如導出PDF
等,在這裏就不展開了。
const puppeteer = require('puppeteer');
// 實際上可以維護單實例的`browser`對象
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
// 同樣也可以維護單實例的`page`對象
const page = await browser.newPage();
// 如果有視窗長寬的話可以直接設置
// 否則先繪製`SVG`獲取視窗長寬之後再設置視窗的大小也可以
await page.setViewport({
width: 1000,
height: 1000,
deviceScaleFactor: 3, // 不設置則會導致截圖模糊
});
await page.setContent(svg);
const element = await page.$('svg');
let buffer: Buffer | null = null;
if(element){
const box = await element.boundingBox();
if(box){
buffer = await page.screenshot({
clip: {
x: box.x,
y: box.y,
width: box.width,
height: box.height,
},
type: 'png',
omitBackground: true,
});
}
}
await page.close();
await browser.close();
return buffer;
DOM TO IMAGE
讓我們想一想,foreignObject
元素看起來是個非常神奇的設計,通過foreignObject
元素我們可以把HTML
繪製到SVG
當中,那麼我們是不是可以有一個非常神奇的點子,如果我們此時需要將瀏覽器當中的DOM
繪製出來,實現於類似於截圖的效果,那麼我我們是不是就可以藉助foreignObject
元素來實現呢。這當然是可行的,而且是一件非常有意思的事情,我們可以將DOM + CSS
繪製到SVG
當中,緊接着將其轉換爲DATA URL
,藉助canvas
將其繪製出來,最終我們就可以將DOM
生成圖像以及導出了。
下面就是個這個能力的實現,當然在這裏的實現還是比較簡單的,主要處理的部分就是將DOM
進行clone
以及樣式全部內聯,由此來生成完整的SVG
圖像。實際上這其中還有很多需要注意的地方,例如生成僞元素、@font-face
字體的聲明、BASE64
編碼的內容、img
元素到CSS background
屬性的轉換等等,想要比較完整地實現整個功能還是需要考慮到很多case
的,在這裏就不涉及具體的實現了,可以參考dom-to-image-more
。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DOM IMAGE</title>
<style>
#root {
width: 300px;
border: 1px solid #eee;
}
.list > .list-item {
display: flex;
background-color: #aaa;
color: #fff;
align-items: center;
justify-content: space-between;
}
</style>
</head>
<body>
<!-- #root START -->
<!-- `DOM`內容-->
<div id="root">
<h1>Title</h1>
<hr />
<div>Content</div>
<div class="list">
<div class="list-item">
<span>label</span>
<span>value</span>
</div>
<div class="list-item">
<span>label</span>
<span>value</span>
</div>
</div>
</div>
<!-- #root END -->
<button onclick="onDOMToImage()">下載</button>
</body>
<script>
const cloneCSS = (target, origin) => {
const style = window.getComputedStyle(origin);
// 生成所有樣式表
const cssText = Array.from(style).reduce((acc, key) => {
return `${acc}${key}:${style.getPropertyValue(key)};`;
}, "");
target.style.cssText = cssText;
};
const cloneDOM = (origin) => {
const target = origin.cloneNode(true);
const targetNodes = target.querySelectorAll("*");
const originNodes = origin.querySelectorAll("*");
// 複製根節點樣式
cloneCSS(target, origin);
// 複製所有節點樣式
Array.from(targetNodes).forEach((node, index) => {
cloneCSS(node, originNodes[index]);
});
// 去除元素的外邊距
target.style.margin =
target.style.marginLeft =
target.style.marginTop =
target.style.marginBottom =
target.style.marginRight =
"";
return target;
};
const buildSVGUrl = (node, width, height) => {
const xml = new XMLSerializer().serializeToString(node);
const data = `
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
<foreignObject width="100%" height="100%">
${xml}
</foreignObject>
</svg>
`;
return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(data);
};
const onDOMToImage = () => {
const origin = document.getElementById("root");
const { width, height } = root.getBoundingClientRect();
const target = cloneDOM(origin);
const data = buildSVGUrl(target, width, height);
const image = new Image();
image.crossOrigin = "anonymous";
image.src = data;
image.onload = () => {
const canvas = document.createElement("canvas");
// 值越大像素越高
const ratio = window.devicePixelRatio || 1;
canvas.width = width * ratio;
canvas.height = height * ratio;
const ctx = canvas.getContext("2d");
ctx.scale(ratio, ratio);
ctx.drawImage(image, 0, 0);
const a = document.createElement("a");
a.href = canvas.toDataURL("image/png");
a.download = "image.png";
a.click();
};
};
</script>
</html>
每日一題
https://github.com/WindrunnerMax/EveryDay
參考
https://github.com/jgraph/drawio
https://github.com/pbakaus/domvas
https://github.com/puppeteer/puppeteer
https://www.npmjs.com/package/dom-to-image-more
https://developer.mozilla.org/zh-CN/docs/Web/SVG
https://zzerd.com/blog/2021/04/10/linux/debian_install_puppeteer
https://developer.mozilla.org/zh-CN/docs/Web/SVG/Element/foreignObject
https://developer.mozilla.org/en-US/docs/Web/SVG/Namespaces_Crash_Course