SVG の path 要素のみを用いたくり抜き図形について

react-shareLINE に対応させるプルリクエストを作成する際に SVG アイコンを用意する必要があり、いろいろと慣れない作業をしました。 SVG をエディタで開いて手作業で編集する機会はなかなかないと思うので、半分作業メモのような形で記録を残しておきます。

react-share のアイコンについて

react-shareFacebookTwitter などのソーシャルシェアボタンの React コンポーネントライブラリです。 LINE には対応していなかったので、対応させるプルリクエストを送りました。

react-share ではアイコンに SVG を使っています。 色付きの正方形 rect または円 circle の上に、白色の塗りつぶしパス path を描写することでアイコンを形成しています。

LINE のアイコンを用意するときに悩んだのは、path 要素が 1 つしか使われていないことです1。 1 つの path 要素の d 属性 と fill 属性のみを使って、文字の部分をくり抜いたような図形(以下、くり抜き図形と呼ぶ)を作るのに、どうすればいいのか当初はさっぱりわかりませんでした。

デモを見ると、Reddit のアイコンは明らかにくり抜き図形なので、何らかの方法でできるはずだと思っていろいろと調査しました。

path 要素を使って塗りつぶし図形を作るには

まず SVG のパス(path 要素)を用いて塗りつぶし図形をつくるのに簡単に解説をしておきます。

path 要素の図形は d 属性によって定義されて、d 要素には一連のコマンドによって定義されるパスを複数設定することができます。簡単のため、ここでは直線のみのパスを考えることにします。一連のコマンドとは、以下のようなコマンドを並べたものです。

  • M x y: パスの始点を座標 (x, y) に設定する
  • H y: 現在の位置から Y 座標 y への垂直な直線を描画する
  • V x: 現在の位置から X 座標 x への水平な直線を描画する
  • Z: 現在の位置からパスの始点へ戻る直線を描画する

そして fill 属性に色を指定することでパスで囲まれた部分が塗りつぶされます。例えば正方形を描画する path は次のようになります。わかりやすさのためにパス自体にも stroke 属性を利用して色を付けています。

<svg viewBox="0 0 100 100" width="100" height="100">
  <path d="M 10 10 H 90 V 90 H 10 Z" fill="rgb(147,200,254)" stroke-width="5" stroke="rgb(38,145,252)" />
</svg>

コマンドの詳細は Paths - SVG: Scalable Vector Graphics | MDN などを見てください。

path 要素の fill-rule 属性について

単純な図形の塗りつぶしなら問題はありませんが、くり抜き図形を塗りつぶそうとするとうまくいかない場合があります。そこで用いられるのが fill-rule 属性です。 fill-rule 属性は複数のパスで囲まれる部分がパスの内側かどうかを判断して塗りつぶしを制御するための属性で、値としては主に nonzero (省略した場合のデフォルト値) と evenodd があります。

fill-rule="nonzero" (省略時のデフォルト値)

ある視点を図形の外から内へ向けて進めていくと仮定します。カウント 0 から始めて、右向きのパスを横切るときにカウントに 1 を加え、左向きのパスを横切るときにカウントから 1 を引きます。そしてカウントが 0 でない部分を塗りつぶします。

これを利用してくり抜き図形を作るときは、外側のパスと内側のパスを逆向きにする必要があります。

<!-- 左: 外側パスが反時計回り、内側パスが半時計回り。くり抜けない。 -->
<svg viewBox="0 0 100 100" width="100" height="100">
  <path d="M 10 10 H 90 V 90 H 10 Z M 30 30 H 70 V 70 H 30 Z" fill-rule="nonzero" fill="rgb(147,200,254)" stroke-width="5" stroke="rgb(38,145,252)" />
</svg>

<!-- 右: 外側パスが反時計回り、内側パスが時計周り -->
<svg viewBox="0 0 100 100" width="100" height="100">
  <path d="M 10 10 H 90 V 90 H 10 Z M 30 30 V 70 H 70 V 30 Z" fill-rule="nonzero" fill="rgb(147,200,254)" stroke-width="5" stroke="rgb(38,145,252)" />
</svg>

fill-rule="evenodd"

同じくある視点を図形の外から内へ向けて進めていくと仮定します。カウント 0 から始めて、パスの向きは考慮せずパスを横切るときにカウントに 1 を加えます。そしてカウントが奇数の部分を塗りつぶします。

これを利用してくり抜き図形を作るときは、外側のパスと内側のパスの向きが合っているかどうかは無関係になります。

<!-- 左: 外側パスが反時計回り、内側パスが反時計回り -->
<svg viewBox="0 0 100 100" width="100" height="100">
  <path d="M 10 10 H 90 V 90 H 10 Z M 30 30 H 70 V 70 H 30 Z" fill-rule="evenodd" fill="rgb(147,200,254)" stroke-width="5" stroke="rgb(38,145,252)" />
</svg>

<!-- 右: 外側パスが反時計回り、内側パスが時計周り -->
<svg viewBox="0 0 100 100" width="100" height="100">
  <path d="M 10 10 H 90 V 90 H 10 Z M 30 30 V 70 H 70 V 30 Z" fill-rule="evenodd" fill="rgb(147,200,254)" stroke-width="5" stroke="rgb(38,145,252)" />
</svg>

fill-rule="evenodd"path 要素を fill-rule="nonzero" に変換する

こちらが作業メモになります。

ベクタ画像の編集に Gravit Designer を使っているのですが、これでエクスポートした SVG には fill-rule="evenodd" 属性が付いていました。

まず fill-rule 属性を削除して、図形の様子を確認しました。 案の定どの文字もくり抜かれていなかったので、パスの向きを逆にする必要がありました。

何か良いツールはないかと探したら、svg-path-reverse を発見し、これを使ってパスの向きを逆にすることができました。 使い方はこのような感じです。

const { reverse } = require('svg-path-reverse');

const path = 'ここに d 要素の中身';
console.log(reverse(path, 0));

reverse 関数の第 2 引数にサブパスのインデックス(0 始まり)を指定することで、指定したサブパスだけを逆向きにできます。 今回は外側のパスが最初のサブパスであり、外側のパスだけ逆向きにすればよかったので、第 2 引数に 0 を指定しています。

パスの向き変更とは関係が薄いですが、svg-path-reverse が出力したパスは冗長になりがちだったので、svgo という SVG を最適化するツールも併用しておきました。

まとめ

内部がくり抜かれた塗りつぶし図形を SVGpath 要素のみを用いて描画する方法を調査し、簡単にまとめました。 fill-rule="nonzero"(もしくは省略時)の場合に外側のパスの向きと内側のパスの向きを逆にする方法と、fill-rule="evenodd" を使う方法の 2 通りを紹介しました。

参考