Home About Contact
Kotlin , Icon , SVG

SVG のパスコマンドをつかってアイコンデータをつくる

こんなホウキの絵のアイコンをつくりたい。

A broom

手動で点を算出した上であとから調整(拡大縮小や回転)するので、 Kotlin Script を使う。

最終的に完成したアイコンはこれ。

The broom icon final

手書きで SVG コマンドを作成

24 x 24 ピクセルの大きさのアイコンを考える。

ホウキの柄の部分のSVGコマンド

M2.0, 0.0
L2.0, 5.5
Q1.0, 6.5 1.0, 7.5
L4.5, 7.5
Q4.5, 6.5 3.5, 5.5
L3.5, 0.0
Z

ホウキのふさふさした部分(穂)のSVGコマンド

M1.0, 7.75
Q0.0, 9.75 0.5, 11.95
L1.6, 11.95
L1.9, 9.45
L2.1, 11.95
L3.4, 11.95
L3.6, 9.45
L3.9, 11.95
L5.0, 11.95
Q5.5, 9.75 4.5, 7.75
Z

SVG に書き起こすとこうなる。

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" x="0px" y="0px" width="128px" height="128px" viewBox="0 0 24 24">
<g stroke="rgb(32,32,32)" stroke-width="0.25" stroke-dasharray="0.5" fill="none"><rect width="24" height="24" /></g>
<g fill="rgb(101,123,131)">
<path d="
M2.0, 0.0
L2.0, 5.5
Q1.0, 6.5 1.0, 7.5
L4.5, 7.5
Q4.5, 6.5 3.5, 5.5
L3.5, 0.0
Z
M1.0, 7.75
Q0.0, 9.75 0.5, 11.95
L1.6, 11.95
L1.9, 9.45
L2.1, 11.95
L3.4, 11.95
L3.6, 9.45
L3.9, 11.95
L5.0, 11.95
Q5.5, 9.75 4.5, 7.75
Z
">
</path>
</g>
</svg>

レンダリングした結果。

A broom 1

実寸(24x24ピクセル)では小さすぎるので、128x128px に拡大した。

この段階ではホウキは左上でまっすぐに立った状態だが、最終的には多少拡大した上で中央に配置してさらに 30 度ほど回転したい。 座標の計算を紙とペンでするのは難しいので、Kotlin Script を使う。

かつては、電卓、そして今は(コンピュータ)スクリプトに支援してもらっているわけだが、 数年すれば(いや今時点でもすでにできるのか?) ChatGPT に自然文で頼めば点の座標計算をしてくれるのであろうか。

Kotlin Script

点データを反映させるために 点データ用クラスを準備。

data class Pt(val x: Float, val y: Float)

SVGパスコマンドで使用しているのは、M,L,Q,Z のみ。これを Sealed クラス Cmd を使って表現する。

sealed class Cmd {
    data class M(val pt: Pt): Cmd()
    data class L(val pt: Pt): Cmd()
    data class Q(val pt0: Pt, val pt1: Pt): Cmd()
    object Z: Cmd()
}

さらに、今あるSVGコマンドをパースして Cmd を生成する簡易パーサーを用意。

今ある SVGコマンドはこんな感じの文字列、

M2.0, 0.0
L2.0, 5.5
Q1.0, 6.5 1.0, 7.5
L4.5, 7.5
Q4.5, 6.5 3.5, 5.5
L3.5, 0.0
Z

これをパースするパーサー。

val toCmd: (String)->Cmd = { text->
    val regM = "^M(.+),(.+)".toRegex()
    val regL = "^L(.+),(.+)".toRegex()
    val regQ = "^Q(.+),(.+) (.+),(.+)".toRegex()
    val regZ = "^Z".toRegex()

    val matchM = regM.find(text)
    val matchL = regL.find(text)
    val matchQ = regQ.find(text)
    val matchZ = regZ.find(text)

    if( matchM!=null ){
        val x = matchM.groupValues[1].toFloat()
        val y = matchM.groupValues[2].toFloat()
        Cmd.M(Pt(x,y))
    }
    else if( matchL!=null ){
        val x = matchL.groupValues[1].toFloat()
        val y = matchL.groupValues[2].toFloat()
        Cmd.L(Pt(x,y))
    }
    else if( matchQ!=null ){
        val x0 = matchQ.groupValues[1].toFloat()
        val y0 = matchQ.groupValues[2].toFloat()
        val x1 = matchQ.groupValues[3].toFloat()
        val y1 = matchQ.groupValues[4].toFloat()
        Cmd.Q(Pt(x0,y0), Pt(x1,y1))
    }
    else if( matchZ!=null ){
        Cmd.Z
    }
    else {
        Cmd.Z
    }
}

この toCmd パーサーを使って SVGコマンド文字列から List<Cmd> を生成。

val broomCmdList = listOf<Cmd>(
    toCmd("M2.0, 0.0"),
    toCmd("L2.0, 5.5"),
    toCmd("Q1.0, 6.5 1.0, 7.5"),
    toCmd("L4.5, 7.5"),
    toCmd("Q4.5, 6.5 3.5, 5.5"),
    toCmd("L3.5, 0.0"),
    toCmd("Z"),
    toCmd("M1.0, 7.75"),
    toCmd("Q0.0, 9.75 0.5, 11.95"),
    toCmd("L1.6, 11.95"),
    toCmd("L1.9, 9.45"),
    toCmd("L2.1, 11.95"),
    toCmd("L3.4, 11.95"),
    toCmd("L3.6, 9.45"),
    toCmd("L3.9, 11.95"),
    toCmd("L5.0, 11.95"),
    toCmd("Q5.5, 9.75 4.5, 7.75"),
    toCmd("Z"),
)

うまくパースできているか確認してみましょう。

broomCmdList.forEach {
    println( cmdToString(it) )
}

Cmd からSVGコマンド文字列に戻すための関数 cmdToString はこれ。

val cmdToString: (Cmd)->String = { cmd->
    when(cmd){
        is Cmd.M -> {
            "M${cmd.pt.x}, ${cmd.pt.y}"
        }
        is Cmd.L -> {
            "L${cmd.pt.x}, ${cmd.pt.y}"
        }
        is Cmd.Q -> {
            "Q${cmd.pt0.x}, ${cmd.pt0.y} ${cmd.pt1.x}, ${cmd.pt1.y}"
        }
        else -> {
            "Z"
        }
    }
}

ここまで来たらあとは、3x3 マトリックスを使って座標を意図した位置に写すだけです。

まず小手試しに、平行移動を使ってアイコンの中心にホウキを移動してみます。 中心にするための平行移動量は dx = 9.25 , dy = 6.025 です。

3x3 マトリックス値は次のようになります。

1  0  9.25
0  1  6.025
0  0  1

これと (x, y, 1) の積を計算 (後述の transform 関数)すると、(x,y) を移動できます。

平行移動だけの計算ならば 3x3 マトリックスを持ち出さなくても (x+dx, y+dy) するだけの話ですが、 ここではあとで拡大および回転もしたいので、3x3 マトリックスを使った計算を使っています。

transform と補助関数 translateM3x3

data class Pt(val x: Float, val y: Float)

typealias M3x3 = FloatArray

val translateM3x3: (Float, Float)-> M3x3 = { dx, dy->
    floatArrayOf(
        1f, 0f, dx,
        0f, 1f, dy,
        0f, 0f, 1f,
    )
}

val transform: (M3x3, Pt)-> Pt = { a, pt->
    val b = floatArrayOf(pt.x, pt.y, 1f)

    val c00 = a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
    val c10 = a[0 + 3] * b[0] + a[1 + 3] * b[1] + a[2 + 3] * b[2]
    //val c20 = a[0 + 6] * b[0] + a[1 + 6] * b[1] + a[2 + 6] * b[2]

    Pt(c00, c10)
}

今ホウキのSVGコマンドが broomCmdList: List<Cmd> にあるので、これを平行移動させてみます。

val newBroomCmdList = broomCmdList.map { cmd->
    val matrix = translateM3x3(9.25f, 6.025f)
    when(cmd){
        is Cmd.M -> {
            val pt = transform(matrix, cmd.pt)
            Cmd.M(pt)
        }
        is Cmd.L -> {
            val pt = transform(matrix, cmd.pt)
            Cmd.L(pt)
        }
        is Cmd.Q -> {
            val pt0 = transform(matrix, cmd.pt0)
            val pt1 = transform(matrix, cmd.pt1)
            Cmd.Q(pt0, pt1)
        }
        is Cmd.Z -> Cmd.Z
    }
}

平行移動済みの newBroomCmdList から SVGコマンドを生成。

newBroomCmdList.forEach {
    println( cmdToString(it) )
}

実行すると次の文字列が標準出力されます。

M11.25, 6.025
L11.25, 11.525
Q10.25, 12.525 10.25, 13.525
L13.75, 13.525
Q13.75, 12.525 12.75, 11.525
L12.75, 6.025
Z
M10.25, 13.775
Q9.25, 15.775 9.75, 17.975
L10.85, 17.975
L11.15, 15.475
L11.35, 17.975
L12.65, 17.975
L12.85, 15.475
L13.15, 17.975
L14.25, 17.975
Q14.75, 15.775 13.75, 13.775
Z

意図通りセンターに配置できたか SVG にしてレンダリングしてみましょう。

A centered broom icon

うまくいきました。

仕上げ

それでは、1.2 倍に拡大して、30度傾け(回転)して中央に配置することを考えます。

現在ちょうど中央に配置されているので、この状態を出発点して 次のようにします。

  1. 原点に平行移動 dx=-12, dy=-12
  2. 1.2倍に拡大
  3. 30度回転
  4. アイコンの中央に平行移動 dx=12, dy=12

1.2倍に拡大の 3x3 マトリックスは次のようになります。

1.2  0    0
0    1.2  0
0    0    1

30度回転のそれはこれです。

cos(θ) -sin(θ), 0
sin(θ)  cos(θ), 0
0       0       1

θ はラジアンで度(degree)から変換方法はこれ

θ = degree * PI/180

いまは 30度にしたいので、次のようになる。

θ = 30 * PI/180

そして、この4つの変換を逐次行って座標を変換してもかまわないのだが・・・つまり、つぎのようにして、

val resultBroomList = newBroomList.map { 変換1 } .map { 変換2 } .map { 変換3 } .map { 変換4 }

しかし、複数の変換用 3x3 マトリックスはひとつにまとめられる( Pre-Multiplication というらしい)。

「・」をマトリックスの積(multiply)だとしたら、次のように変換順に(前に)preすることで、変換マトリックスをひとつにまとめられる。

変換4のマトリックス・変換3のマトリックス・変換2のマトリックス・変換1のマトリックス

もうだったら最初の状態からセンター平行移動するマトリックスも含めて計算することにしよう。

変換4のマトリックス・変換3のマトリックス・変換2のマトリックス・変換1のマトリックス・変換0のマトリックス

それでは実装してみます。

translateM3x3 関数にならって、scaleM3x3rotateM3x3 関数を定義。

val scaleM3x3: (Float, Float)-> M3x3 = { sx, sy->
    floatArrayOf(
        sx, 0f, 0f,
        0f, sy, 0f,
        0f, 0f, 1f,
    )
}

val rotateM3x3: (Float)-> M3x3 = { degree->
    val θ = degree * Math.PI/180f
    floatArrayOf(
        Math.cos(θ).toFloat(), -Math.sin(θ).toFloat(), 0f,
        Math.sin(θ).toFloat(),  Math.cos(θ).toFloat(), 0f,
        0f, 0f, 1f,
    )
}

次に multiply を定義。

val multiply: ( M3x3, M3x3 )-> M3x3 = { a, b->
    floatArrayOf(
        a[0]*b[0] + a[1]*b[3] + a[2]*b[6],
        a[0]*b[1] + a[1]*b[4] + a[2]*b[7],
        a[0]*b[2] + a[1]*b[5] + a[2]*b[8],

        a[3]*b[0] + a[4]*b[3] + a[5]*b[6],
        a[3]*b[1] + a[4]*b[4] + a[5]*b[7],
        a[3]*b[2] + a[4]*b[5] + a[5]*b[8],

        a[6]*b[0] + a[7]*b[3] + a[8]*b[6],
        a[6]*b[1] + a[7]*b[4] + a[8]*b[7],
        a[6]*b[2] + a[7]*b[5] + a[8]*b[8],
    )
}

マトリックスの積を地味に計算しているだけです。自分で実装しないで、ライブラリを使えばよい。

それでは、変換 m0..m4 のマトリックスを定義したうえで、ひとつにまとめます。

val m0 = translateM3x3(9.25f, 6.025f)
val m1 = translateM3x3(-12f, -12f)
val m2 = scaleM3x3(1.2f, 1.2f)
val m3 = rotateM3x3(30f)
val m4 = translateM3x3(12f, 12f)

val matrix =
  multiply(
    m4,
    multiply(
      m3,
      multiply(
        m2,
        multiply(m1, m0))))

multiply を使ってマトリックスをまとめる部分が読みづらいコードになるので、 infix を使って multiply 関数を書き直します。 ついでに、関数名を x にしてしまいましょう。

//infix fun M3x3.multiply( b:M3x3 ): M3x3 {
infix fun M3x3.x( b:M3x3 ): M3x3 {
    val a = this
    return floatArrayOf(
        a[0]*b[0] + a[1]*b[3] + a[2]*b[6],
        a[0]*b[1] + a[1]*b[4] + a[2]*b[7],
        a[0]*b[2] + a[1]*b[5] + a[2]*b[8],

        a[3]*b[0] + a[4]*b[3] + a[5]*b[6],
        a[3]*b[1] + a[4]*b[4] + a[5]*b[7],
        a[3]*b[2] + a[4]*b[5] + a[5]*b[8],

        a[6]*b[0] + a[7]*b[3] + a[8]*b[6],
        a[6]*b[1] + a[7]*b[4] + a[8]*b[7],
        a[6]*b[2] + a[7]*b[5] + a[8]*b[8],
    )
}

これを使えば、該当部分のコードは次のように書けます。

val matrix = m4 x (m3 x (m2 x (m1 x m0)))

infix は左結合なので、右結合にしたい場合は括弧をつけます。

この matrix を使ってあとは、点(というか Cmd の点)を写すだけです。

val newBroomCmdList = broomCmdList.map { cmd->
    when(cmd){
        is Cmd.M -> {
            val pt = transform(matrix, cmd.pt)
            Cmd.M(pt)
        }
        is Cmd.L -> {
            val pt = transform(matrix, cmd.pt)
            Cmd.L(pt)
        }
        is Cmd.Q -> {
            val pt0 = transform(matrix, cmd.pt0)
            val pt1 = transform(matrix, cmd.pt1)
            Cmd.Q(pt0, pt1)
        }
        is Cmd.Z -> Cmd.Z
    }
}

newBroomCmdList.forEach {
    println( cmdToString(it) )
}

できたコマンドをSVGにしてレンダリング。

The broom icon

うまくいきました。

もう少しホウキを大きくしましょう。1.2 倍に代えて 1.6倍にしてみました。

//val m2 = scaleM3x3(1.2f, 1.2f)
val m2 = scaleM3x3(1.6f, 1.6f)

変更は拡大用マトリックスを 1.6 倍指定に変更しただけです。

The broom icon final

まとめ

コード全体。

broom.main.kts

typealias M3x3 = FloatArray

infix fun M3x3.x( b:M3x3 ): M3x3 {
    val a = this
    return floatArrayOf(
        a[0]*b[0] + a[1]*b[3] + a[2]*b[6],
        a[0]*b[1] + a[1]*b[4] + a[2]*b[7],
        a[0]*b[2] + a[1]*b[5] + a[2]*b[8],

        a[3]*b[0] + a[4]*b[3] + a[5]*b[6],
        a[3]*b[1] + a[4]*b[4] + a[5]*b[7],
        a[3]*b[2] + a[4]*b[5] + a[5]*b[8],

        a[6]*b[0] + a[7]*b[3] + a[8]*b[6],
        a[6]*b[1] + a[7]*b[4] + a[8]*b[7],
        a[6]*b[2] + a[7]*b[5] + a[8]*b[8],
    )
}

val translateM3x3: (Float, Float)-> M3x3 = { dx, dy->
    floatArrayOf(
        1f, 0f, dx,
        0f, 1f, dy,
        0f, 0f, 1f,
    )
}

val scaleM3x3: (Float, Float)-> M3x3 = { sx, sy->
    floatArrayOf(
        sx, 0f, 0f,
        0f, sy, 0f,
        0f, 0f, 1f,
    )
}

val rotateM3x3: (Float)-> M3x3 = { degree->
    val θ = degree * Math.PI/180f
    floatArrayOf(
        Math.cos(θ).toFloat(), -Math.sin(θ).toFloat(), 0f,
        Math.sin(θ).toFloat(),  Math.cos(θ).toFloat(), 0f,
        0f, 0f, 1f,
    )
}


data class Pt(val x: Float, val y: Float)

val transform: (M3x3, Pt)-> Pt = { a, pt->
    val b = floatArrayOf(pt.x, pt.y, 1f)

    val c00 = a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
    val c10 = a[0 + 3] * b[0] + a[1 + 3] * b[1] + a[2 + 3] * b[2]
    //val c20 = a[0 + 6] * b[0] + a[1 + 6] * b[1] + a[2 + 6] * b[2]

    Pt(c00, c10)
}

sealed class Cmd {
    data class M(val pt: Pt): Cmd()
    data class L(val pt: Pt): Cmd()
    data class Q(val pt0: Pt, val pt1: Pt): Cmd()
    object Z: Cmd()
}

val cmdToString: (Cmd)->String = { cmd->
    when(cmd){
        is Cmd.M -> {
            "M${cmd.pt.x}, ${cmd.pt.y}"
        }
        is Cmd.L -> {
            "L${cmd.pt.x}, ${cmd.pt.y}"
        }
        is Cmd.Q -> {
            "Q${cmd.pt0.x}, ${cmd.pt0.y} ${cmd.pt1.x}, ${cmd.pt1.y}"
        }
        else -> {
            "Z"
        }
    }
}

val toCmd: (String)->Cmd = { text->
    val regM = "^M(.+),(.+)".toRegex()
    val regL = "^L(.+),(.+)".toRegex()
    val regQ = "^Q(.+),(.+) (.+),(.+)".toRegex()
    val regZ = "^Z".toRegex()

    val matchM = regM.find(text)
    val matchL = regL.find(text)
    val matchQ = regQ.find(text)
    val matchZ = regZ.find(text)

    if( matchM!=null ){
        val x = matchM.groupValues[1].toFloat()
        val y = matchM.groupValues[2].toFloat()
        Cmd.M(Pt(x,y))
    }
    else if( matchL!=null ){
        val x = matchL.groupValues[1].toFloat()
        val y = matchL.groupValues[2].toFloat()
        Cmd.L(Pt(x,y))
    }
    else if( matchQ!=null ){
        val x0 = matchQ.groupValues[1].toFloat()
        val y0 = matchQ.groupValues[2].toFloat()
        val x1 = matchQ.groupValues[3].toFloat()
        val y1 = matchQ.groupValues[4].toFloat()
        Cmd.Q(Pt(x0,y0), Pt(x1,y1))
    }
    else if( matchZ!=null ){
        Cmd.Z
    }
    else {
        Cmd.Z
    }
}

val broomCmdList = listOf<Cmd>(
    toCmd("M2.0, 0.0"),
    toCmd("L2.0, 5.5"),
    toCmd("Q1.0, 6.5 1.0, 7.5"),
    toCmd("L4.5, 7.5"),
    toCmd("Q4.5, 6.5 3.5, 5.5"),
    toCmd("L3.5, 0.0"),
    toCmd("Z"),
    toCmd("M1.0, 7.75"),
    toCmd("Q0.0, 9.75 0.5, 11.95"),
    toCmd("L1.6, 11.95"),
    toCmd("L1.9, 9.45"),
    toCmd("L2.1, 11.95"),
    toCmd("L3.4, 11.95"),
    toCmd("L3.6, 9.45"),
    toCmd("L3.9, 11.95"),
    toCmd("L5.0, 11.95"),
    toCmd("Q5.5, 9.75 4.5, 7.75"),
    toCmd("Z"),
)

val m0 = translateM3x3(9.25f, 6.025f)
val m1 = translateM3x3(-12f, -12f)
val m2 = scaleM3x3(1.6f, 1.6f)
val m3 = rotateM3x3(30f)
val m4 = translateM3x3(12f, 12f)

val matrix = m4 x (m3 x (m2 x (m1 x m0)))

val newBroomCmdList = broomCmdList.map { cmd->
    when(cmd){
        is Cmd.M -> {
            val pt = transform(matrix, cmd.pt)
            Cmd.M(pt)
        }
        is Cmd.L -> {
            val pt = transform(matrix, cmd.pt)
            Cmd.L(pt)
        }
        is Cmd.Q -> {
            val pt0 = transform(matrix, cmd.pt0)
            val pt1 = transform(matrix, cmd.pt1)
            Cmd.Q(pt0, pt1)
        }
        is Cmd.Z -> Cmd.Z
    }
}

newBroomCmdList.forEach {
    println( cmdToString(it) )
}

kotlin 環境の確認とスクリプトの実行。

$ kotlin -version
Kotlin version 1.9.22-release-704 (JRE 17.0.9+9-Ubuntu-122.04)
$ kotlin broom.main.kts

今回 SVG コマンドを sealed class で表現しました。たしかにこれは便利。