0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SVG の d 属性、ノードをドラッグできると一気に読める(Svelte 5 で実装)

0
Posted at

<path d="M 100 40 C 60 0 0 40 100 140 C 200 40 140 0 100 40 Z" />

この文字列、ぱっと見で読めますか? 私は無理です。 M は Move とわかるけど C の後に 3 ペアの座標が並ぶのは なぜ 3 なのかZ に数字がつかないのは 何を閉じているのか、そして HS みたいな短縮系を見かけた時の気持ちは「あとで調べる」で 5 年経ちました。

観念してブラウザ上で動く小さなビジュアルエディタを作ったら、この d 文字列が一気に読めるようになったので、その経過と仕組みを書きます。

SVG Path Editor — ハート形プリセット。緑のノードをドラッグすると形が変わり、右の d 文字列もライブ更新する

🔗 Demo: https://sen.ltd/portfolio/svg-path-editor/
📦 GitHub: https://github.com/sen-ltd/svg-path-editor

作ったもの:

  • d 属性を貼り付けると、キャンバスにアンカー(緑)とベジェ制御点(オレンジ)を重ねて表示
  • どのノードもドラッグできて、形が変わると d 文字列が即時に更新される
  • 出力は 絶対座標の M / L / C / Q / Z のみ に正規化された読みやすい形
  • Svelte 5 + TypeScript + Vite。ランタイム依存ゼロ(Svelte だけ)

この記事は実装そのものの話よりも、「SVG のパスグラマが実は結構クセ強いことを知る記事」として書いています。

d 属性は「ペンの移動コマンド列」

d の中身は、仮想のペンに出す命令のリストです。

コマンド 意味 取る座標数
M x y ペンを持ち上げて (x, y) に移動(描かない) 1
L x y 現在地から (x, y) まで直線 1
H x 現在地から水平に x まで 0.5(y は現在値)
V y 現在地から垂直に y まで 0.5
C x1 y1 x2 y2 x y (x1,y1) と (x2,y2) を制御点にした三次ベジェで (x,y) へ 3
S x2 y2 x y 一つ前の三次ベジェの反射で x1 を自動生成する C 2
Q x1 y1 x y (x1,y1) を制御点にした二次ベジェで (x,y) へ 2
T x y 一つ前の二次ベジェの反射で x1 を自動生成する Q 1
A rx ry rot large sweep x y 楕円の弧で (x,y) へ 3.5(旗 2 つあり)
Z 現在のサブパスの始点まで直線で戻って閉じる 0

大文字は絶対座標、小文字は現在地からの相対座標Mm は別の命令だと考えたほうが実装が楽になります。

で、このうち H/V/S/T/相対コマンドは、ぜんぶ M/L/C/Q の「短縮形」 として表現できます。可視化のうえでは全部絶対座標の基本 5 つに畳んでしまったほうがノードの数が一貫して、ドラッグのコードもシンプルになる。これがエディタの設計方針の根っこです。

罠: M x y x y は「M + L」

パーサを書き始めてすぐに引っかかる仕様がこれです。

M 10 10 20 20 30 30

人間的には「3 回 M する」と読みたくなるけど、SVG 的には 「最初のペアだけ M、残りは L」 です。

If a moveto is followed by multiple pairs of coordinates, the subsequent pairs are treated as implicit lineto commands. Hence, implicit lineto commands will be relative if the moveto is relative, and absolute if the moveto is absolute.
SVG 2 Paths §8.3.2

他のコマンド(L/C/Q など)は「同じコマンドが暗黙でリピート」するのに、M だけ L に変わる。このトラップを踏まずに済ませるためだけに、パーサには firstIteration フラグを持ってます:

let firstIteration = true;
while (true) {
  skipWsAndCommas(cursor);
  if (cursor.pos >= cursor.src.length) break;
  if (isCommandLetter(cursor.src[cursor.pos])) break;

  if (upper === 'M') {
    const p = readPoint(cursor);
    const abs = isRelative ? rel(currentPoint, p) : p;
    if (firstIteration) {
      out.push({ kind: 'M', p: abs });
      subpathStart = abs;
    } else {
      out.push({ kind: 'L', p: abs });  // ← ここが罠
    }
    currentPoint = abs;
  }
  // ...他のコマンド
  firstIteration = false;
}

罠: S と T の「反射」

S は「直前が C だったら、その C の c2 を現在地で反射して今回の c1 にする」というコマンドです。

M 0 0 C 10 0 20 10 30 10 S 50 20 60 20
              ^^^^^
              これが c2 = (20, 10)
              ↓ 反射(現在地 (30,10) を中心に)
              新しい c1 = (40, 10)

反射の計算は単純で、中心が (cx, cy)、反射元の点が (px, py) のとき、反射点は (2*cx - px, 2*cy - py)

function reflect(around: Point, p: Point): Point {
  return { x: 2 * around.x - p.x, y: 2 * around.y - p.y };
}

ただし 直前が C でも S でもなかった場合、反射するものがないので「反射点は現在地」(= c1 == current)というルール。T も Q に対して同じ。

パーサ側ではこれを覚えておくために lastCubicC2lastQuadC を別変数で持ち歩いて、C/S/Q/T 以外のコマンドが入ると null にリセットします。

罠: 数値区切り

「数字の間に空白かカンマ」、まで覚えてれば足りるかと思いきや、SVG の仕様では 符号と小数点は区切りとして機能する という 1990 年代くらいの節約仕様が残っています:

M.5.5L1,1    ← 有効。M 0.5 0.5 L 1 1 と同じ
M-1-2L3,4    ← 有効。M -1 -2 L 3 4 と同じ

私は簡略化のため、数値の読み取りを [符号?][整数部][. + 小数部]?[e + 指数部]? の手書きパーサで実装しました。次の文字が -+ なら符号として扱うし、. なら小数開始として扱います。

function readNumber(cursor: Cursor): number {
  skipWsAndCommas(cursor);
  const start = cursor.pos;
  const src = cursor.src;
  if (src[cursor.pos] === '+' || src[cursor.pos] === '-') cursor.pos++;
  while (cursor.pos < src.length && /\d/.test(src[cursor.pos])) cursor.pos++;
  if (src[cursor.pos] === '.') {
    cursor.pos++;
    while (cursor.pos < src.length && /\d/.test(src[cursor.pos])) cursor.pos++;
  }
  // 指数部...
  return parseFloat(src.slice(start, cursor.pos));
}

罠: Z の後の「現在地」

Z はサブパスを閉じるだけでなく、現在地をそのサブパスの始点 (最後の M) に戻します。これを忘れると以下が壊れます:

M 10 10 L 20 20 Z M 30 30 l 5 0
                         ^^^^^^
                         これは (35, 30) であるべき
                         Z 後の現在地は (30, 30) だから

Z の処理のたびに currentPoint = subpathStart を忘れずに。テストで 1 回踏みました。

ビジュアル側: スクリーン座標 → SVG 座標

パースができたら描画は簡単(<path d={dString}> に流すだけ)。編集の核は 「ドラッグ中のマウス位置を SVG 座標に変換してノードの座標を更新する」 ところ。

ブラウザ提供の行列で一発です:

function clientToSvg(clientX: number, clientY: number): Point {
  const ctm = svgEl.getScreenCTM();
  if (!ctm) return { x: 0, y: 0 };
  const inv = ctm.inverse();
  const pt = new DOMPoint(clientX, clientY).matrixTransform(inv);
  return { x: pt.x, y: pt.y };
}

getScreenCTM() は「SVG ユーザー座標 → スクリーンピクセル」の行列を返してくれるので、逆行列にかければ逆変換ができる。viewBox が効いてる状態でもちゃんと動くので、SVG 手書きパーサや自作座標変換を書かずに済みます。

ドラッグの開始は pointerdownsetPointerCapture() を呼んで、以降の pointermove を SVG 要素が捕まえるようにする。pointerup でリリース。ポインタキャプチャのおかげで、ノードから指が外れてもドラッグが続行するし、タッチと PC マウスで実装が分かれません。

全部正規化してから編集する理由

エディタが入力を M/L/C/Q/Z だけの絶対座標に畳んでしまうのは:

  • ノード 1 個 = 1 つの AST ノード、という単純な対応関係が保てる
  • C と S を UI 上で別に扱いたくない(どちらもアンカー + 2 制御点)
  • 出力が毎回同じ形式になるので、コピペして git diff で差分が読める

代償は「元のフォーマットを保持できない」こと(H 100 と書いてあっても L 100 0 になって返る)。ミニファイされた d を復元する用途には合わないけど、学習と編集が目的なら単純さ優先で問題ないと判断しました。

弧 (A) をサポートしない理由

A rx ry x-axis-rotation large-arc-flag sweep-flag x y は SVG の中で唯一、ベジェで書き直しても数値が変わるコマンドです。楕円弧を三次ベジェで近似するには最低でも 2〜4 個のセグメントに分割する必要があり、エディタの中で弧を「1 個のノードとしてドラッグする」と「分割された 4 個のベジェとして扱う」のどちらを優先するかで設計が割れます。

今回のエディタは 前者の対応が複雑すぎ、後者は情報が失われすぎ と判断して、A が現れたら明示的に例外を投げる仕様にしました:

case 'A':
  throw new Error(
    'Arc commands (A/a) are not supported in this editor — ' +
    'convert to cubic béziers first.',
  );

silently 落とすより、「未対応だから変換してから持ってきてね」と言うほうが親切です(弧をベジェに変換する CLI なら svgo など既存ツールがある)。

テストは往復が効く

パーサとシリアライザの組が正しく動く一番の証拠は 「パースしてシリアライズして、またパースしたら結果が一致する」 こと。往復が安定ならお互いが「同じ方言を話してる」と言えます。

it.each([
  'M 0 0 L 10 10 Z',
  'M 100 100 C 150 100 200 150 200 200 Z',
  'M 0 0 Q 50 50 100 0',
])('serialize(parse(%s)) stabilizes', (d) => {
  const once = serializePath(parsePath(d));
  const twice = serializePath(parsePath(once));
  expect(twice).toBe(once);
});

これだけで「H → L 変換」「相対 → 絶対」「S の反射」「数値丸め」の 4 つの正規化ステップが同時に検証される。個別ケース 10 数個と合わせて、パーサで踏んだ罠は全部テストで防げるようになりました。

Svelte 5 の感想

  • $state で書いた path: Path を直接ミューテートしたら UI が更新される。Runes で配列の中身を書き換えただけで再レンダされないケースがあって、path = [...path] で明示的に新しい参照を作る必要があった($state の deep reactivity は多次元配列には意図的に効かせないらしい)
  • bind:this={svgEl} でネイティブ要素への参照を取るのが useRef より短い
  • SVG 要素も普通に JSX ライクに書けて、onpointerdown={(e) => ...} が素直に効く
  • 型推論は React と遜色なし。$state<Path>(...) が推論される

バンドルサイズは dev ビルドで計ってないけど、portfolio 本体(Svelte 5 版、gzip 19 kB)と同じ傾向なはず。依存が Svelte だけなのが効いている。

まとめ

  • SVG の d 属性は「ペンの移動命令列」、M/L/C/Q/Z が基本で H/V/S/T は短縮形
  • 罠: M x y x yM + L、S/T は反射、Z は現在地を戻す、数値区切りに符号/小数点が使える
  • 正規化してから編集すると、UI もデータ構造もシンプルになる
  • 弧 (A) はベジェに畳めないので対応しない(エラーを投げる)
  • ブラウザの getScreenCTM() を使えばスクリーン座標 ↔ SVG 座標の変換で手書き不要

d="M 100 40 C 60 0 0 40 100 140 C 200 40 140 0 100 40 Z" が読めたら、CSS アニメーション (<animate>offset-path) の入り口が一個減ります。入門に試してみてください。

リポジトリ: https://github.com/sen-ltd/svg-path-editor

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?