Sassでlinear-gradient()のmixinをつくる その4

2013年2月も終わりかけですが、CSS Preprocessor Advent Calendar 2012の記事の続編です。

今回は linear-gradient() から、SVGのグラデーション画像を生成するmixinをつくります。なんでそんなことをするのかというと、IE9linear-gradient() を実装しておらず、またグラデーションを生成できる独自フィルタもIE9標準モードで使えないからです。面倒ですねえ。Windows 7版のIE10の登場とそれへの移行が早く済むとよいのですが。

Part 6 ― SVGはパーセントエンコードされたdata: URLで表現

今回やるのは、異なるCSS構文の変換ではなく、CSSSVGというフォーマットの変換が主です。IE9からサポートされたSVGを利用し、グラデーションを含んだSVGファイルを、background-image で参照させます。

background-image: url(SVGファイルへのパス);

これでSVGファイルを適用できますが、Sassにはファイル生成をする関数なんて用意されてません。なので今回はファイルではなくdata: URLを使い、CSSファイルに直にデータを埋め込みます。

data: URLですが、こんな仕組みになっています。

data:image/png;base64,iVBORw0KGgoAAAANS...

data: スキームに続けてフォーマットのMIME(この場合はPNGのもの), それに続けてエンコードされたデータをつなげると、それがリソース自体のURL表現になります。

SVGMIMEimage/svg+xml です。さて、データですが、先ほどのPNGの場合は base64, という文字が示す通り、データをBase64エンコードし埋め込んでいます。今回のSVGでもそうできればよいのですが、SassにはBase64エンコードするような関数はやはりありません。

そこに登場するのがパーセントエンコードです。URLでコンポーネントに使えない文字列は%xxといった形にエンコードされます。Googleなどで検索した時、q=%E7%9F%A2%E5%80%89%E7%9C%9E%E9%9A%86Mなどという文字列が検索結果のURLにくっついてるかと思いますが、あれです。

要はURLで使える文字列になればOKなので、パーセントエンコーディングでもdata: URLがつくれます。というわけでSVGファイルをパーセントエンコードしdata: URLにしましょう。

ふう。ようやくSVGファイルの構造までたどり着きました。上から下のグラデーションを定義するSVGはこんな感じになります。

<svg xmlns="http://www.w3.org/2000/svg">
    <linearGradient id="g" x2="0%" y2="100%">
        <stop offset="0%" stop-color="#fff"/>
        <stop offset="100%" stop-color="#fc0"/>
    </linearGradient>
    <rect width="100%" height="100%" fill="url(#g)"/>
</svg>

軽く説明すると、こんなことをしています。

  • グラデーション(<linearGradient> 要素)を定義し、それを <rect> 要素に適用(塗りつぶし)する
  • グラデーションの始点と終点は <linearGradient> 要素の属性 (x1, x2, y1, y2) で表す
  • カラーストップは <linearGradient> 要素の子要素に <stop> 要素をストップの数ぶん指定する
  • <stop> 要素には、オフセット (offset) と色 (stop-color) というふたつの属性を指定する

というわけで、Sass側ではlinear-gradient()の引数から次の3つを変換し生成すればよいわけです。

  • グラデーションの方向(<linearGradient>x1, x2, y1, y2 属性の組み)
  • カラーストップの位置(<stop> 要素の offset属性)
  • カラーストップの色(<stop> 要素の stop-color属性)

方向ですが、CSSのキーワードに対応するSVGの方向(属性の組み)を変数にし、それをリストにでもしときます。-webkit-gradient() を生成した時とだいたい同じです。

カラーストップの位置ですが、offset 属性は -webkit-gradient() と同じく、0から1までの数値もしくはパーセンテージしか受け付けません。ですので -webkit-gradient() と同じ制約が必要になってしまいます。今回はパーセンテージのみに絞ります。

カラーストップの色はCSS<color> となっています。SVG 1.1はCSS 2.1を参照していますが、CSS3で定義された rgba() なども利用可能です。これは特に何も変換が必要なさそうです…あっ。

そうでした。生成しないといけないのはパーセントエンコード後の値です。
では、見づらいですが、エンコードされた <linearGradient> 要素(の開始タグ)と <stop> 要素を見てみましょう。

<linearGradient id="g" x2="0%" y2="100%">
%3ClinearGradient%20id%3D%22g%22%20x2%3D%220%25%22%20y2%3D%22100%25%22%3E
<stop offset="100%" stop-color="#fc0"/>
%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23fc0%22%2F%3E

強調箇所で、パーセントエンコードされた文字は次のとおりです。

半角スペース %20
" %22
# %23
% %25
= %3D

まず、方向はパーセントエンコード済の文字列のリストを持っておけばOKそうです。

ただ、あとの2つが困ったことになりました。0%0%25 に、#fff%23fff にしないといけませんが、Sassには文字列置換をする関数がありません。うーん……

やっぱだめかなーと悩んでいたのですが、ちょっと考えたところ、迂回策が見つかりました。

  • %25 は文字列として付加するようにして、変換に使う変数には % を取り除いた数値を使えばよい
  • stop-color 属性はCSS<color> を取るので、# を使わない(文字列置換を必要としない)別の <color> に変換すればよい

ずるい!けどこれでなんとかなりそうです。

パーセンテージの除去は、1% で割ればよいです。

@function removePercentage($percentage) {
    @return $percentage / 1%;
}

色は、rgb() もしくは rgba() 表記に変換します。Sassには色関連の関数が沢山用意されているので、それを駆使します。

@function convertToRGBa($color) {
    $r: red($color);
    $g: green($color);
    $b: blue($color);
    $a: alpha($color);

    // rgb()/rgba()表記をエンコード
    @return if($a == 1, 'rgb%28'+$r+'%2C'+$g+'%2C'+$b+'%29', 'rgba%28'+$r+'%2C'+$g+'%2C'+$b+'%2C'+$a+'%29');
}

おお、いけそうです。あとはちょっと長いmixinを書くだけです。書くだけです……

@mixin lg-svg($first, $rest...) {

    $direction: false;
    $svg_direction: false;

    $colorstops: false;

    // SVGの位置キーワード(x1, x2, y1, y2属性)
    $x1_0: ''; // '%20x1%3D%220%25%22';   // ' x1="0%"'   // default
    $x1_1: '%20x1%3D%22100%25%22';        // ' x1="100%'
    $x2_0: '%20x2%3D%220%25%22';          // ' x2="0%"'
    $x2_1: ''; // '%20x2%3D%22100%25%22'; // ' x2="100%"' // default
    $y1_0: ''; // '%20y1%3D%220%25%22';   // ' y1="0%"'   // default
    $y1_1: '%20y1%3D%22100%25%22';        // ' y1="100%'
    $y2_0: ''; // '%20y2%3D%220%25%22';   // ' y2="0%"'   // default
    $y2_1: '%20y2%3D%22100%25%22';        // ' y2="100%'

    $svg_down:  $x2_0 + $y2_1; // ' x2="0%" y2="100%"'
    $svg_left:  $x1_1 + $x2_0; // ' x1="100%" x2="0%"'
    $svg_up:    $y1_1 + $x2_0; // ' y1="100%" x2="0%"'
    $svg_right: $x1_0 + $x2_1; // ' x1="0%" x2="100%"'

    $svg_to_TL: $x1_1 + $y1_1 + $x2_0 + $y2_0;
    $svg_to_TR: $x1_0 + $y1_1 + $x2_1 + $y2_0;
    $svg_to_BR: $x1_0 + $y1_0 + $x2_1 + $y2_1;
    $svg_to_BL: $x1_1 + $y1_0 + $x2_0 + $y2_1;

    // 色だった場合
    @if type-of(nth($first, 1)) == 'color' {
        $direction: $first;
        $svg_direction: $svg_down;

        $colorstops: $rest; // ISSUE
    }
    // 'to' からはじまるキーワードの場合
    @else if type-of($first) == 'list' and nth($first, 1) == 'to' {

        $standard_keywords:
            to bottom, to left, to top, to right,
            to top left, to top right, to bottom right, to bottom left,
            to left top, to right top, to right bottom, to left bottom;

        $svg_keywords:
            ($svg_down), ($svg_left), ($svg_up), ($svg_right),
            ($svg_to_TL), ($svg_to_TR), ($svg_to_BR), ($svg_to_BL),
            ($svg_to_TL), ($svg_to_TR), ($svg_to_BR), ($svg_to_BL);

        $idx: index($standard_keywords, $first);

        @if $idx {
            $direction: $first;
            $svg_direction: nth($svg_keywords, $idx);

            $colorstops: $rest;
        }
    }
    // 角度かそれ以外か
    @else {
        $msg: 'SVGに変換できないよ: #{$first}';
        @warn $msg;
        /* #{$msg} */
    }

    // いよいよ出力
    @if ($colorstops) {
        $svg_colorstops: '';

        // いけてないけど……
        @if type-of(nth($first, 1)) == 'color' {
            $svg_colorstops: conv_svg_colorstop($first);
        }

        // カラーストップを変換してはくっつけ、を繰り返す
        @each $cs in $colorstops {
            $svg_colorstops: $svg_colorstops + conv_svg_colorstop($cs);
        }
        // data: URL
        $svg_data: 'data:image/svg+xml,' +
            '%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E' +
                '%3ClinearGradient%20id%3D%22g%22' + $svg_direction + '%3E' +
                    $svg_colorstops +
                '%3C%2FlinearGradient%3E' +
                '%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22url(%23g)%22%2F%3E%3C' +
            '%2Fsvg%3E';
        background-image: url($svg_data);
        background-image: linear-gradient($direction, $colorstops);
    }
}

// color-stop() に変換する関数
@function conv_svg_colorstop($colorstop) {
    // $colorstop は <color> と <percentage> のリスト
    $color: nth($colorstop, 1);
    $stop: nth($colorstop, 2);

    @if unit($stop) != '%' {
        $msg: 'パーセンテージで指定しよう: #{$colorstop}';
        @warn $msg;
    }
    $svg_stop: $stop / 1%;

    $r: red($color);
    $g: green($color);
    $b: blue($color);
    $a: alpha($color);
    $svg_color: if($a == 1, 'rgb%28' + $r + '%2C' + $g + '%2C' + $b + '%29', 'rgba%28' + $r + '%2C' + $g + '%2C' + $b + '%2C' + $a + '%29');

    @return '%3Cstop%20offset%3D%22' + $svg_stop + '%22%20stop-color%3D%22' + $svg_color + '%22%2F%3E'
}

できてしまいました。

パーセントエンコードのため、Base64エンコードなdata: URLよりも長くなってしまうところがあまりよろしくないですね。とはいえ、パーセントエンコードが基本的には文字列置換だったから今回のMixinが実現できたというのもあるので、これはOKにしておきましょう。

悔しいのが、色の出力をrgb(), rgba()形式に固定してしまったところですね。ただ、いま気づいたんですが、10進数を16進数に変換する関数を自分で用意すれば、いったんred(), green(), blue()で10進数の値を取って、そこから16進数表記に戻すこともできますね。

ふう。SVGのへの変換もできました。このままもう終えたいのですが、これまで定義したmixinではlinear-gradient()-webkit-linear-gradient(), linear-gradient()-webkit-gradient(), linear-gradient()SVGと出力を分けてしまっているので、実用性がありません。

というわけで、もうだいぶ書くのしんどいのですが、次回はlinear-gradient()から他のvariantすべてを生成するmixinをつくります。次でたぶん終われる…と思う。