Open6

RustでSVGの画像ファイルを生成する

SVGクレートを使う。

https://github.com/bodoni/svg

https://crates.io/crates/svg

https://docs.rs/svg/0.9.1/svg/

このクレート、SVGの作りをそのままラップしているだけなので、SVGそのものの仕様をある程度は把握していないと何もできない。上のドキュメントでは何も説明されてないし。

https://developer.mozilla.org/ja/docs/Web/SVG
このMozillaのページの「SVG 要素リファレンス」と「SVG 属性リファレンス」は必携。

まずは公式のサンプルを動かしてみる。(ちなみにこのクレートはパースもできるらしいけど、そっちは扱わない)

cargoで適当にファイルを作り、Cargo.tomldependenciessvg = "0.9.1"を追加する。

んでサンプルコードを打ち込む。(元のままでは色が分かりづらかったので、線の色を黒から赤に変えている)

main.rs
use svg::node::element::path::Data;
use svg::node::element::Path;
use svg::Document;

fn main() {
    let data = Data::new()
        .move_to((10, 10))
        .line_by((0, 50))
        .line_by((50, 0))
        .line_by((0, -50))
        .close();

    let path = Path::new()
        .set("fill", "none")
        .set("stroke", "red")
        .set("stroke-width", 3)
        .set("d", data);

    let document = Document::new().set("viewBox", (0, 0, 70, 70)).add(path);

    svg::save("image.svg", &document).unwrap();
}

最終的に出力されたSVGファイル(image.svg)のコードと表示画像は下のもの。

<svg viewBox="0 0 70 70" xmlns="http://www.w3.org/2000/svg">
<path d="M10,10 l0,50 l50,0 l0,-50 z" fill="none" stroke="red" stroke-width="3"/>
</svg>

コードを簡単に解説していく。
コードは下から見て行った方が実際のSVGファイルの構造と紐付くので分かりやすい気がするので、下から。

svg::save()

最終的にファイルとして保存する処理。

Document

SVG全体の設定を持つ構造体。
svg::Documentになっているけど、実体はsvg::node::element::SVGで、要するにsvg要素。こうなっているのは、svg要素の中にsvg要素を埋め込めるからとか?

set()を使って、viewBox属性を設定している。
(0, 0)から幅70、高さ70の大きさを持つSVG画像としている。

そこに、path要素を加えている。

Path

path要素。
set()で表示時に使用する各属性をセットしている。

属性名の指定が文字列なのは微妙な気がする。普通ならRustの常套手段としてenumにするところ。これならエディタの補完も効くし。だけど要素ごとに指定可能な属性が違うし、属性にも色々な種類があるので、文字列にしているのも分からなくはない。

dは図形のパス。このパス(パスコマンド)が、pathで指定した各属性を使用して描画される。

Data

パス(パスコマンド)のデータ。要するにこのd(パス)。(ほぼ)パスだけで使用するものなので、svg::node::element::path::Dataと、pathの中にある。

MozillaのSVGのページを見れば分かるけど、座標へ移動させたり直線を引いたりベジェ曲線を引いたりするようなパスを、コマンドとパラメータで定義する。
とりあえず知っておくべきことは以下の2つ。

  • move_to()のようにtoで終わるものは、絶対座標を指定している。
  • line_by()のようにbyで終わるものは、相対座標を指定している。(前のコマンドの終了位置からの相対座標)

要するに実際のSVGファイルにおける大文字と小文字の違いがメソッド名の違いになっている。

クレートの当該クラスのドキュメントMozillaのドキュメントを見比べると分かるけど、メソッドとコマンドが一対一対応。なのでパスの操作をメソッドを使いながら書いていくことになる。

不正なパラメータを渡すとどうなるか?

結論から言えば、SVGクレートではチェックしてくれない。

上記コードのパスとして、不正なパラメータを渡してみる。

main.rs
    // コードの抜粋
    let data = Data::new()
        .move_to((10, 10))
        .line_by((0, 50, 20)) // <- 値が3つあるのは不正!
        .line_by((50, 0))
        .line_by((0, -50))
        .close();

実行結果は以下の通り。

image.svg
<svg viewBox="0 0 70 70" xmlns="http://www.w3.org/2000/svg">
<path d="M10,10 l0,50,20 l50,0 l0,-50 z" fill="none" stroke="red" stroke-width="3"/>
</svg>

l0,50,20の部分の値が不正な状態(数字指定が3つある)のまま、SVGが出力されており、図形も正しく描画されていない。

これをチェックしてくれないのは不親切な気がするけど、SVGの仕様を考えると仕方のない部分もある。なぜなら今回編集した部分、値が奇数個だと不正だけど、偶数個なら正当だから。
これをメソッドの引数として絞ったり、不正なときにNoneErrを返すのは使う方としても煩雑すぎる。

ということで、渡されたパラメータを愚直に出力する仕様にしているのではないかと推測。

あと本筋ではないけど、上記のパラメータが4以上の偶数個の場合、直前のコマンド(この場合は小文字のl)が指定したパラメータ(この場合は相対座標)で実行されるので、その相対座標に向けて線が引かれることになる。

文字列を含んだSVGの生成

SVGでは文字列をそのまま扱える。

main.rs
use svg::Document;

fn main() {
    let text_node = svg::node::Text::new("あい愛藍色");
    let text_element = svg::node::element::Text::new()
        .set("x", 10)
        .set("y", 20)
        .set("fill", "cornflowerblue")
        .set("font-family", "游明朝")
        .set("font-size", "18")
        .add(text_node);

    let document = Document::new()
        .set("viewBox", (0, 0, 100, 100))
        .add(text_element);

    svg::save("image.svg", &document).unwrap();
}

出力結果は以下の通り。

<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<text fill="cornflowerblue" font-family="游明朝" font-size="18" x="10" y="20">
あい愛藍色
</text>
</svg>

文字列を出力するにはコードの通り、以下の手順を行う。

  1. svg::node::Text(Textノード)を、文字列を渡して生成する。
  2. svg::node::element::Text(Text要素)を生成し、諸々の設定を行いつつ、Textノードを追加する。
  3. Documentに追加する。

見ての通り、日本語をそのまま扱えるし、フォントもHTML/CSSと同じように指定できる。ただこれはSVGファイルの表示環境に依存していることに注意が必要。SVGファイルを生成した環境ではない。要するに、ファイルを開いて表示している環境に、当該フォントがインストールされていないと表示されないことになる。

工夫すればWebフォントも使えるのかも知れないけど、未検証。

https://ja.stackoverflow.com/questions/27146/svgでフォントを埋め込むことはできますか

Webフォントの使用

Google FontsのようなWebフォントをSVGで使用してみる。

main.rs
use svg::node::element::{Definitions, Style};
use svg::Document;

fn main() {
    let style = Style::new("@import url(\"https://fonts.googleapis.com/css2?family=Pattaya\");");
    let defs = Definitions::new().add(style);

    let text_node = svg::node::Text::new("Test String");
    let text_element = svg::node::element::Text::new()
        .set("x", 10)
        .set("y", 20)
        .set("fill", "cornflowerblue")
        .set("font-family", "Pattaya")
        .set("font-size", "16")
        .add(text_node);

    let document = Document::new()
        .set("viewBox", (0, 0, 100, 30))
        .add(defs)
        .add(text_element);

    svg::save("image.svg", &document).unwrap();
}

実行結果。まぁこう出力されるようにコードを書いただけなんだけど。

<svg viewBox="0 0 100 30" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
@import url("https://fonts.googleapis.com/css2?family=Pattaya");
</style>
</defs>
<text fill="cornflowerblue" font-family="Pattaya" font-size="16" x="10" y="20">
Test String
</text>
</svg>

styleタグの中の@importで、WebフォントのURLを指定している。そのフォント名を、textで指定するだけでオッケー。

ちなみに日本語のフォントも同様に指定できる。
例えば上記コードで、style@import url("https://fonts.googleapis.com/css?family=Sawarabi+Mincho");にして、textfont-familySawarabi Minchoにする。あと、文字も適当に変える。

見ての通り、指定したフォントのグリフに含まれていない文字(この場合は「愛」)は、デフォルトのフォントで表示されている。
このときに注意が必要なのが、URLでは半角スペースが+にエンコードされているけど、font-familyを指定するときにはそこを半角スペースにする必要があるということ。

これは当然、実行環境依存。上述のフォントがインストールされているかどうか、とは異なり、SVGファイルを表示するアプリがWebフォントに対応しているかどうかに依存する(あと、インターネットに繋がっているかもあるけど、さすがにそれは大丈夫でしょ)。試した範囲ではWebブラウザならオッケー(Firefox、Chrome、Edge)だったけど、画像ビューアは全然対応していなかった。

あとこれ、そもそもSVGの書き方が分からなかったんだけど、苦労しながら検索して見付けたこのページの最後のところにサクッと書いてあった。

https://vecta.io/blog/using-fonts-in-svg
正しいのかが何とも言えないけど、この書き方でちゃんと動いているし、理屈として間違っていないと思うし、大丈夫なんじゃないかな。
ログインするとコメントできます