🦋

SwiftUI: SVGをSwiftUIのPathに変換してコードを出力できるライブラリ作ったよ

2022/09/02に公開

SwiftUIのPathを使って少し複雑な図形を描画したくて、SVGを読み込んでSwiftUIのPathに変換するライブラリを作りました。

https://github.com/kyome22/SVG2Path

SVGを表示するライブラリなんて何番煎じだって感じですが、このライブラリの目的(特徴)はSVGを忠実に画面に描画することではありません。なので塗り色とかストロークの設定(波線とか太さとか)あたりは全く無視していますが、代わりに<g>要素でのグルーピングやtransform=""によるアフィン変換を施した後のパスをSwiftUIのPathのコードの文字列として取得できます。


オリジナルのSVG

SVGのソース
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
  <g transform="translate(-5 -5)">
    <polygon points="9.4509 32.5867 25.4788 33.05 36.7638 20.2932 39.1784 35.4674 53.7839
      40.4097 39.2483 49.3245 36.99 65.1358 25.5919 55.4713 9.5907 60.301 17.0818 45.4131 9.4509 32.5867"/>
    <polygon points="79.0739 74.4843 45.5891 55.6752 48.3313 51.6378 81.8161 70.4469 79.0739 74.4843"/>
    <ellipse cx="87.1488" cy="76.2313" rx="3.8194" ry="3.0078" transform="translate(-27.897 89.2918) rotate(-47.6538)"/>
  </g>
</svg>

👇

SVG2Pathで出力したSwiftUIのPathのコード
path.move(to: CGPoint(x: 4.4509, y: 27.5867))
path.addLine(to: CGPoint(x: 20.4788, y: 28.0500))
path.addLine(to: CGPoint(x: 31.7638, y: 15.2932))
path.addLine(to: CGPoint(x: 34.1784, y: 30.4674))
path.addLine(to: CGPoint(x: 48.7839, y: 35.4097))
path.addLine(to: CGPoint(x: 34.2483, y: 44.3245))
path.addLine(to: CGPoint(x: 31.9900, y: 60.1358))
path.addLine(to: CGPoint(x: 20.5919, y: 50.4713))
path.addLine(to: CGPoint(x: 4.5907, y: 55.3010))
path.addLine(to: CGPoint(x: 12.0818, y: 40.4131))
path.addLine(to: CGPoint(x: 4.4509, y: 27.5867))
path.closeSubpath()
path.move(to: CGPoint(x: 74.0739, y: 69.4843))
path.addLine(to: CGPoint(x: 40.5891, y: 50.6752))
path.addLine(to: CGPoint(x: 43.3313, y: 46.6378))
path.addLine(to: CGPoint(x: 76.8161, y: 65.4469))
path.addLine(to: CGPoint(x: 74.0739, y: 69.4843))
path.closeSubpath()
path.move(to: CGPoint(x: 84.7216, y: 68.4083))
path.addCurve(to: CGPoint(x: 84.3719, y: 73.2573),
              control1: CGPoint(x: 85.9494, y: 69.5273),
              control2: CGPoint(x: 85.7928, y: 71.6983))
path.addCurve(to: CGPoint(x: 79.5761, y: 74.0541),
              control1: CGPoint(x: 82.9510, y: 74.8163),
              control2: CGPoint(x: 80.8038, y: 75.1731))
path.addCurve(to: CGPoint(x: 79.9258, y: 69.2051),
              control1: CGPoint(x: 78.3483, y: 72.9351),
              control2: CGPoint(x: 78.5049, y: 70.7642))
path.addCurve(to: CGPoint(x: 84.7216, y: 68.4083),
              control1: CGPoint(x: 81.3467, y: 67.6461),
              control2: CGPoint(x: 83.4939, y: 67.2894))
path.closeSubpath()

👆これをSwiftUIのコードの中に埋め込めば、下のようなこんな感じになります。

struct ContentView: View {
    var body: some View {
        Path { path in
            path.move(to: CGPoint(x: 4.4509, y: 27.5867))
            path.addLine(to: CGPoint(x: 20.4788, y: 28.0500))
            // 中略
            path.addCurve(to: CGPoint(x: 84.7216, y: 68.4083),
                          control1: CGPoint(x: 81.3467, y: 67.6461),
                          control2: CGPoint(x: 83.4939, y: 67.2894))
            path.closeSubpath()
        }
        .stroke(Color.primary)
        .frame(width: 100, height: 100)
        .padding()
    }
}


SVG2Pathで出力したコードを埋め込んだ結果

もちろん、いちいちSwiftUIのコードを文字列として出力させなくても、SVGを読み込んで変換したPathをそのまま使うこともできます。

使い方

  1. ライブラリSVG2PathをSwift Package Managerで追加してコードにimportする
  2. SVGファイルをテキストで読み込む
  3. SVG2Pathのインスタンスを作っておいて、extractPath(text:)にSVGのテキストを渡す
  4. 戻り値のSVGPathDataからsizepathsが手に入るのでそのまま描画する場合はそれを使う
  5. SwiftUIのPathのコードが出力したい時はPath.codeString()で文字列を取得する
import SwiftUI
import SVG2Path

let url = URL(fileURLWithPath: "SVGのパス")
guard let text = try? String(contentsOf: url, encoding: .utf8),
      let svgPathData = svg2Path.extractPath(text: text)
else { return }
// 中略
ZStack {
    ForEach(svgPathData.paths, id: \.description) { path in
        path.stroke(Color.primary)
            .frame(width: svgPathData.size.width, 
	           height: svgPathData.size.height)
    }
}

開発苦労話

SVGをパースを独自実装で書いたので、文書構造 – SVG 1.1 (第2版) を参考に仕様を勉強しました。途中、仕様の読み込みが浅く、複雑なSVGだとパスが欠けていたりぐちゃぐちゃに描画されてしまう現象が発生し、原因究明に苦労しました。

失敗1


失敗1

SVGの<path>ではd=""で指定されたコマンドを解釈してベクターを描いていきます。
Mで移動、Lで線を引く、Cで曲線を描くというような感じです。
それぞれのコマンドの後には、目的地の座標や制御点の座標の数値が並びます。

こんな感じ
d="M10.7223,33.32578s-8.33087-6.22693-1.9602-11.12744,7.46966-7.31466,6.61569-10.5361Z"

そこで私は、1コマンドにつき1セットの目的地と制御点が書かれるものだと勝手に誤解していたのですが、それがこの失敗1の結果を招きました。
よくよく仕様を読んでみると、『複数の座標を与えて、複ベジェ曲線を描かせることができる。』と書かれており、同じコマンドが連続する場合は、そのコマンドを省略して座標を連続して並べられることがあることがわかりました。

失敗2


失敗2

失敗1を修正したところ、失敗2の状態になりました。
パスの欠けているところは描画されるようになりましたが、線が在らぬところに吹っ飛んでいます。

これは略式/滑 CurveToコマンドの仕様を正しく理解できていなかったことが原因でした。
『現在の点から点 (x, y) へ三次ベジェ曲線を描く。 第一制御点は前の命令の第二制御点の現在の点に対する鏡像(点対称)の地点とみなされる』というところは実装できていたのですが、その後の括弧の中身『(もし前の命令が無いか、あるいは C, c, S, s のいずれでもない場合、第一制御点は現在の点と同一のものとみなされる)』というところの実装が抜けていました。

修正結果


いい感じに描画できました。

Discussion