SVG與foreignObject元素

SVG與foreignObject元素

可縮放矢量圖形Scalable Vector Graphics - SVG基於XML標記語言,用於描述二維的矢量圖形。作爲一個基於文本的開放網絡標準,SVG能夠優雅而簡潔地渲染不同大小的圖形,並和CSSDOMJavaScript等其他網絡標準無縫銜接。SVG圖像及其相關行爲被定義於XML文本文件之中,這意味着可以對其進行搜索、索引、編寫腳本以及壓縮,此外這也意味着可以使用任何文本編輯器和繪圖軟件來創建和編輯SVG

SVG

SVG是可縮放矢量圖形Scalable Vector Graphics的縮寫,其是一種用於描述二維矢量圖形的XML可擴展標記語言標準,與基於像素的圖像格式(如JPEGPNG)不同,SVG使用數學方程和幾何描述來定義圖像,這使得其能夠無損地縮放和調整大小,而不會失真或模糊。SVG圖像由基本形狀(如線段、曲線、矩形、圓形等)和路徑組成,還可以包含文本、漸變、圖案和圖像剪裁等元素。SVG圖形可以使用文本編輯器手動創建,也可以使用專業的矢量圖形編輯軟件生成,其可以在Web頁面上直接嵌入,也可以通過CSS樣式表和JavaScript進行控制和交互,由於SVG圖形是基於矢量的,因此在放大或縮小時不會失去清晰度,這使得SVG在響應式設計、圖標、地圖、數據可視化和動畫等領域中非常有用。此外SVG還兼容支持各種瀏覽器,並且可以與其他Web技術無縫集成。

SVG有着諸多優點,並且擁有通用的標準,但是也存在一些限制,那麼在這裏我們主要討論SVGtext元素也就是文本元素的一些侷限。SVGtext元素提供了基本的文本渲染功能,可以在指定位置繪製單行或多行文本,然而SVG並沒有提供像HTMLCSS中的強大布局功能,比如文本自動換行、對齊方式等,這意味着在SVG中實現複雜的文本佈局需要手動計算和調整位置。此外SVGtext元素支持一些基本的文字樣式屬性,如字體大小、顏色、字體粗細等,然而相對於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文檔中嵌入HTMLXML或其他非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爲了更通用的場景做了很多兼容處理,特別是表現在行內樣式上,類似於上述例子中的SVGDrawIO表現出來是如下的示例,需要注意的是,直接從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就可以了。那麼此時我們就可以藉助PuppeteerPuppeteer允許我們以編程方式模擬用戶在瀏覽器中的行爲,進行網頁截圖、生成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
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章