前端使用 Konva 實現視覺化設計器(9)- 另存為SVG

xachary發表於2024-05-07

請大家動動小手,給我一個免費的 Star 吧~

大家如果發現了 Bug,歡迎來提 Issue 喲~

github原始碼

gitee原始碼

示例地址

另存為SVG

這一章增強了另存為的能力,實現“另存為SVG”,大概是全網唯一的例項分享了吧。

靈感來源:react-konva-custom-context-canvas-for-use-with-canvas2svg

大神提到了 canvas2svg,表達了可以透過建立一個 canvas2svg 的例項,作為 CanvasRenderingContext2D 替換了 Konva 原有 canvas 的 CanvasRenderingContext2D,並使其 layer 重繪,canvas2svg 的例項藉此監聽 canvas 的動作,轉換成 Svg 動作,最終生成 svg 內容。

不過,大神的例子,並沒有說明如何處理並匯出圖片節點

透過測試大神的例子,並觀察匯出的 svg xml 特點,以下是基本實現思路和注意事項:
1、必須透過替換 layer 的 context 實現,透過 stage 是無效的。
2、匯出的 svg xml,圖片節點將以 svg 的 image 節點存在。
3、svg 圖片素材節點的 xlink:href 以 blob: 連結定義。
4、其它圖片素材節點的 xlink:href 是以一般路徑連結定義。
5、透過正規表示式提取圖片素材節點連結。
6、fetch svg 圖片素材節點連結,獲得 svg xml 文字。
7、fetch 其它圖片素材節點,獲得 blob 後,轉換為 base64 連結。
8、替換 canvas2svg 匯出的 svg xml 內的 svg 圖片素材節點為內嵌 svg 節點(xml)。
9、替換 canvas2svg 匯出的 svg xml 內的其它圖片素材節點的 xlink:href 為 base64 連結
10、匯出替換完成的 svg xml。

關鍵邏輯

功能入口

主要是 canvas2svg 的使用,獲得原始的 rawSvg xml 內容:

  // 獲取Svg
  async getSvg() {
    // 獲取可視節點和 layer
    const copy = this.getView()
    // 獲取 main layer
    const main = copy.children[0] as Konva.Layer
    // 獲取 layer 的 canvas context
    const ctx = main.canvas.context._context

    if (ctx) {
      // 建立 canvas2svg
      const c2s = new C2S({ ctx, ...main.size() })
      // 替換 layer 的 canvas context
      main.canvas.context._context = c2s
      // 重繪
      main.draw()

      // 獲得 svg
      const rawSvg = c2s.getSerializedSvg()
      // 替換 image 連結
      const svg = await this.parseImage(rawSvg)

      // 輸出 svg
      return svg
    }
    return Promise.resolve(
      `<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="720"></svg>`
    )
  }

替換 image 連結方法

根據 xlink:href 連結的特點,透過正規表示式提取,用於後續處理:


  // 替換 image 連結
  parseImage(xml: string): Promise<string> {
    return new Promise((resolve) => {
      // 找出 blob:http 圖片連結(目前發現只有 svg 是)
      const svgs = xml.match(/(?<=xlink:href=")blob:https?:\/\/[^"]+(?=")/g) ?? []
      // 其他圖片轉為 base64
      const imgs = xml.match(/(?<=xlink:href=")(?<!blob:)[^"]+(?=")/g) ?? []

      Promise.all([this.parseSvgImage(svgs), this.parseOtherImage(imgs)]).then(
        ([svgXmls, imgUrls]) => {
          // svg xml
          svgs.forEach((svg, idx) => {
            // 替換
            xml = xml.replace(
              new RegExp(`<image[^><]* xlink:href="${svg}"[^><]*/>`),
              svgXmls[idx].match(/<svg[^><]*>.*<\/svg>/)?.[0] ?? '' // 僅保留 svg 結構
            )
          })

          // base64
          imgs.forEach((img, idx) => {
            // 替換
            xml = xml.replace(`"${img}"`, `"${imgUrls[idx]}"`)
          })

          // 替換完成
          resolve(xml)
        }
      )
    })
  }

替換 svg blob: 連結

批次 fetch svg blob: 連結,獲得 xml 內容:

  // 替換 svg blob: 連結
  parseSvgImage(urls: string[]): Promise<string[]> {
    return new Promise((resolve) => {
      if (urls.length > 0) {
        Promise.all(urls.map((o) => fetch(o))).then((rs: Response[]) => {
          // fetch

          // 替換為 svg 巢狀
          Promise.all(rs.map((o) => o.text())).then((xmls: string[]) => {
            // svg xml
            resolve(xmls)
          })
        })
      } else {
        resolve([])
      }
    })
  }

替換其他 image 連結

批次 fetch 圖片連結,獲得 base64 連結:

  // blob to base64 url
  blobToBase64(blob: Blob, type: string): Promise<string> {
    return new Promise((resolve) => {
      const file = new File([blob], 'image', { type })
      const fileReader = new FileReader()
      fileReader.readAsDataURL(file)
      fileReader.onload = function () {
        resolve((this.result as string) ?? '')
      }
    })
  }

  // 替換其他 image 連結
  parseOtherImage(urls: string[]): Promise<string[]> {
    return new Promise((resolve) => {
      if (urls.length > 0) {
        Promise.all(urls.map((o) => fetch(o))).then((rs: Response[]) => {
          // fetch

          // 替換為 base64 url image
          Promise.all(rs.map((o) => o.blob())).then((bs: Blob[]) => {
            // blob
            Promise.all(bs.map((o) => this.blobToBase64(o, 'image/*'))).then((urls: string[]) => {
              // base64
              resolve(urls)
            })
          })
        })
      } else {
        resolve([])
      }
    })
  }

過程示例

透過 canvas2svg 獲得原始的 rawSvg xml 內容:

image

<?xml version="1.0" encoding="utf-8"?>

<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="428" height="448">
  <defs/>
  <g>
    <rect fill="#FFFFFF" stroke="none" x="0" y="0" width="428" height="448"/>
    <g transform="matrix(1,0,0,1,69,80)">
      <!-- gif 圖片 -->
      <image width="200" height="200" preserveAspectRatio="none" xlink:href="data:image/png;base64,略..."/>
      <g transform="translate(0,0)"/>
    </g>
    <g transform="matrix(1,0,0,1,17,22)">
      <!-- png 圖片 -->
      <image width="64" height="64" preserveAspectRatio="none" xlink:href="/src/assets/img/png/2.png"/>
      <g transform="translate(0,0)"/>
    </g>
    <g transform="matrix(1,0,0,1,228,232)">
      <!-- svg 圖片 -->
      <image width="200" height="200" preserveAspectRatio="none" xlink:href="blob:http://localhost:5173/da9ddae7-2ac7-47fb-99c0-e7171aa41655"/>
      <g transform="translate(0,0)"/>
    </g>
  </g>
</svg>

替換之後:

<?xml version="1.0" encoding="utf-8"?>

<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="428" height="448">
  <defs/>
  <g>
    <rect fill="#FFFFFF" stroke="none" x="0" y="0" width="428" height="448"/>
    <g transform="matrix(1,0,0,1,69,80)">
      <!-- gif 圖片 base64 -->
      <image width="200" height="200" preserveAspectRatio="none" xlink:href="data:image/*;base64,略..."/>
      <g transform="translate(0,0)"/>
    </g>
    <g transform="matrix(1,0,0,1,17,22)">
      <!-- png 圖片 base64 -->
      <image width="64" height="64" preserveAspectRatio="none" xlink:href="data:image/*;base64,略..."/>
      <g transform="translate(0,0)"/>
    </g>
    <g transform="matrix(1,0,0,1,228,232)">
      <!-- svg 內嵌 -->
      <svg class="icon" width="200px" height="200.00px" viewBox="0 0 1024 1024" version="1.1">
        <path d="M512 0c282.763636 0 512 229.236364 512 512S794.763636 1024 512 1024 0 794.763636 0 512 229.236364 0 512 0z m0 11.636364C235.659636 11.636364 11.636364 235.659636 11.636364 512s224.023273 500.363636 500.363636 500.363636 500.363636-224.023273 500.363636-500.363636S788.340364 11.636364 512 11.636364z m-114.781091 683.927272c38.388364 6.632727 63.767273 22.853818 103.133091 61.556364l7.563636 7.528727 19.502546 19.921455c4.736 4.770909 9.262545 9.216 13.637818 13.370182l6.434909 6.004363c1.047273 0.965818 2.094545 1.908364 3.141818 2.839273l6.132364 5.352727c30.196364 25.728 53.946182 35.735273 87.226182 36.398546 69.992727 1.361455 119.936-22.027636 150.621091-70.272l2.094545-3.397818 9.972364 6.004363c-32.756364 54.318545-87.354182 80.779636-162.909091 79.290182-41.262545-0.814545-68.817455-14.650182-107.333818-50.583273l-6.714182-6.376727-6.946909-6.818909-7.226182-7.272727-15.709091-16.069819-7.284364-7.26109c-37.922909-37.329455-61.777455-52.596364-97.314909-58.740364-67.397818-11.659636-122.705455 10.24-166.725818 66.106182l-2.792727 3.607272-9.262546-7.028363c47.045818-61.940364 107.578182-86.807273 180.759273-74.158546z"/>
      </svg>
      <g transform="translate(0,0)"/>
    </g>
  </g>
</svg>

關於 gif,實測內嵌於 svg 中是無法顯示的,現在除了 svg 圖片素材節點,其它圖片素材統一按靜態圖片處理。

image

磁貼

增加了對 stage 邏輯邊界的磁貼:
image

其它調整

staget 邏輯區域

原來 stage 的邏輯區域和比例尺的區域是重疊一致的(大小一致,預設根據比例尺大小對齊 0 點而已),實在有點變扭,可能會讓人產生疑惑。
現已經調整 stage 的邏輯區域即為預設可視區域(區別可以觀察紅色虛線框的改變)。
順便使得預覽框的互動最佳化的更符合直覺。

官方的 API 的 Bug

Bug: 恢復 JSON 時候,如果存在已經被放大縮小點元素,點選選擇無效
原因不詳,Hack 了一下,暫時可以消除影響。

接下來,計劃實現下面這些功能:

  • 對齊效果
  • 連線線
  • 等等。。。

是不是值得更多的 Star 呢?勾勾手指~

原始碼

gitee原始碼

示例地址

相關文章