日本列島をLaTeXで表示させたかったのでSVGをTikZに変換するプログラムを作った

この記事はTeX & LaTeX Advent Calendar 2019 16日目の記事です. 15日目は aminophenさんでした.17日目はt-kemmochiさんです.

はじめに

LaTeXは論文や理工系のレポートを書くのにとても特化したすばらしいツールです. また,様々なパッケージが提供されており,TeXLaTeXだけで様々なものを作れます.

自分はこれまでそんなLaTeXを使っていろいろ遊んできました.

LaTeXで提供されているTikZという図を描画できるソフトでデレマスの名刺を作ったり muscle-keisuke.hatenablog.com

GPSのログをLaTeXで処理して表示した画像上にTikZでGPSの軌跡を表示したり muscle-keisuke.hatenablog.com

TeXで定義できるマクロを再帰的に呼び出せることを利用しTikZでフラクタル図形を描画したりしました. muscle-keisuke.hatenablog.com

さて,今回のテーマは

「日本列島をLaTeX(TikZ)のコマンドだけで描画してみる」です.

今年のAdvent CalendarのテーマはLuaLaTeXですが,全く守る気がありません.

きっかけ

CountriesOfEurope

CTANにはCountriesOfEuropeというヨーロッパ諸国を表示できるコマンドが定義されているパッケージがあります.

www.ctan.org

\documentclass{article}
\usepackage[Scale=7.5]{countriesofeurope}
\begin{document}
This is Germany $\rightarrow$ \EUCountry[Scale=3]{Germany}
\end{document}

もうこれだけでドイツが出ます.(もちろん他のヨーロッパ圏の国も出せます.) f:id:muscle_keisuke:20191211012542p:plain

しかし,ヨーロッパだけで自分が住んでいる日本がないのはとても悔しかったのです.

「なければ自分で作ればよい」

ということで,作ることにしました.

方針

自分の中で2つの方法が思い浮かびました. - 日本地図を目視で確認してTikZのコマンドを巧みに使って一から描いていく - ベクタ画像のテキストをTikZのコマンドに変換するスクリプトを作る

今回はスケジュールを見積もった結果2番目の方法が良さそうなので変換スクリプトを作ることにしました.

作っていく

まずは日本列島のベクタ画像を持ってくる

今回は画像をLaTeXの形式に変換するので,点で画像を構成しているラスタ画像ではなく,線で構成しているベクタ画像を持ってきます.

ここの画像を持ってきます. 画像形式はSVG形式です.SVGはScalable Vector Graphicsの略でXMLベースのスキーマで描画命令が書かれているベクタ画像の形式です.

https://d-maps.com/m/asia/japan/japon/japon04.svg

SVGだったのでSVGの描画命令を理解する

SVGを開くとテキストが以下のように羅列しています.

<path class="fil1 str0" d="M15159.54 1008.44l-433.65 0c8.17,33.38 12.53,58.06 17.98,92.12 4.78,30.66 17.04,45.65 24.53,76.3
 4.08,16.35 5.45,27.25 8.17,43.6 9.54,57.23 10.9,90.61 10.9,148.51 0,23.85 7.5,38.84 0,61.32 -2.72,8.86 -8.85,13.62 -13.62,21.79 
8.17,4.78 14.98,5.45 21.8,10.9 14.31,12.27 15.67,27.25 24.52,43.6 7.5,13.63 19.76,18.4 24.53,32.7 7.5,20.44 6.13,35.43 16.35,54.5 
7.5,14.31 19.76,18.4 29.97,29.98 17.04,18.4 29.3,27.25 51.78,38.15 3.41,-45.64 1.36,-73.58 21.8,-114.45 5.44,-11.58 5.44,-22.48 
13.62,-32.7 12.26,-14.99 32.7,-11.58 43.6,-27.25 6.81,-10.21 4.09,-21.11 8.17,-32.7 10.23,-27.25 22.49,-39.51 35.43,-65.4 
19.76,-40.19 25.89,-67.44 32.7,-111.72 2.05,-13.63 8.17,-21.8 8.17,-36.1l0 -85.84c0,-26.57 7.5,-41.56 16.35,-66.09 10.9,-29.97 
22.59,-52.61 36.9,-81.22zm
...
"/>

まずはこれらのテキストを読み解いてどういう描画を行っているのかを理解する必要があります.

以下のページを参考にSVGの描画命令について見てみました. triple-underscore.github.io

そうすると線の描画を行っているのは

<path class="fil1 str0" d="ここの部分"/>

d=の先の数字の羅列です.この数字の羅列をパスデータといいます. パスデータに含まれているアルファベットが直線を描くのか曲線を描くのかの命令になっています. アルファベットと描画命令の対応は以下のようになっています.

描画命令と引数 意味
M(x,y) 絶対座標(x,y)に描画点を移動する
L(x,y) 現在の描画点から絶対座標(x,y)に直線を描画する
C(x1,y1,x2,y2,x,y)+ 現在の描画点から絶対座標(x1,y1),(x2,y2)を制御点として(x,y)まで三次ベジェ曲線を描画する,3つ以上引数に取ることができて,その後の引数は第一制御点,第二制御点,曲線の終点の座標の順番を繰り返し指定する.
z(引数なし) 線を閉じるように直線を引く

それぞれの大文字に対応する小文字は引数に取る座標が現在の描画点に対する相対座標となります. また,引数の間はカンマでも空白でも認識されます. つまり

<path class="fil1 str0" d="M15159.54 1008.44l-433.65 0c8.17,33.38 12.53,58.06 17.98,92.12 4.78,30.66 17.04,45.65 24.53,76.3

というパスデータをアルファベット毎に改行すると

M15159.54 1008.44
l-433.65 0
c8.17,33.38 12.53,58.06 17.98,92.12 4.78,30.66 17.04,45.65 24.53,76.3

となり,絶対座標(15159.54, 1008.44)を開始点とし,そこから相対座標(-433.65, 0)へ直線を引き第一制御点を相対座標(8.17,33.38) ,第二制御点(12.53,58.06),曲線の終点(17.98,92.12)の三次ベジェ曲線,第一制御点を相対座標(4.78,30.66) ,第二制御点(17.04,45.65),曲線の終点(24.53,76.3)の三次ベジェ曲線を引く という命令になります.

3次ベジェ曲線とは

開始点P 第一制御点Q 第二制御点R 終点S としたとき PQ,QR,RSをt:1-tで内分したときのそれぞれの内分点P',Q',R'を結んだ P'Q',Q'R'を更にt:1-tで内分した点P'',Q''を結んだ点をt:1-tで内分した点の集合で描画される曲線(tを0から1動かしたときの軌跡)

以下のサイトは手書きでベジェ曲線を描いていてわかりやすいと思います.

nixeneko.hatenablog.com

SVGの命令を文字列処理してTikZに変換するプログラムを作る

SVGの命令がわかったところで,これをTikZに変換するプログラムを作っていきます.

Perlで作る(LaTeXでやれや)

現在,業務でPerlを使うことが多いので練習がてらPerlを使って作ることにします. いつもならLaTeXで全部実装するところなんですが,時間がないのでPerlでちゃちゃっと作っちゃいます.

SVGの命令とTikZのコマンド対応を考える

SVGの命令は先程の表の通りです.対応するTikZのコマンドは

描画命令と引数 意味 TikZコマンド
M(x,y) 絶対座標(x,y)に描画点を移動する | start_x,start_y,current_x,current_yx,yを代入する
L(x,y) 現在の描画点から絶対座標(x,y)に直線を描画する \draw(current_x, current_y) -- (x, y);
C(x1,y1,x2,y2,x,y)+ 現在の描画点から絶対座標(x1,y1),(x2,y2)を制御点として(x,y)まで三次ベジェ曲線を描画する,3つ以上引数に取ることができて,その後の引数は第一制御点,第二制御点,曲線の終点の座標の順番を繰り返し指定する. \draw (current_x, current_y) .. contorols (x1, y1) and (x2, y2) .. (x, y);
z(引数なし) 線を閉じるように直線を引く \draw (current_x, current_y) -- (start_x, start_y);

current_x, current_yは現在の描画点の座標start_x,start_yは描画開始した際の一番最初の座標です.

SVGは現在の描画点を内部的に保存して次の線を引きますが,TikZは線を引くたびに引き始めの座標を指定する必要があるのでプログラムの変数で保存しておく必要があります.

なので,描画点を決めるM(x,y)の命令が来たときにstart_x,start_y,current_x,current_yを代入するようにします.

原寸大はでかすぎてタイプセットできない

<path class="fil1 str0" d="M15159.54 1008.4...

のように最初から1万超えの座標から描画するとLaTeXはすぐに描画できなくなります. なので,TikZに変換するときに縮小して計算して,TikZのコードを生成します.

Perlで実装していく

SVGファイルを読み込む

sub load_SVG_file {
    my $file_name = shift;

    die 'Can\'t open file !' unless(
        open(
            my $svg, '<', $file_name
        )
    );
    my $svg_code = do {local $/; <$svg>};
    close $svg;

    return [ split(/\n/, $svg_code) ];
}

ファイルから読み込んだテキストから描画命令を分割する

sub read_path_list {
    my $svg_lines = shift;

    return grep { 
        $_ =~ /<path class=/ 
    } @$svg_lines;
}

<path class=...に描画に関する情報があるので,パターンマッチで取り出します.

sub get_svg_opes {
    my $svg_info = shift;

    $svg_info =~ /<path class=.*d="(.*)"\/>/;
    my $coodinates_string = $1;

    return [ split(/(?=[a-zA-Z])/, $coodinates_string) ]; # 肯定的先読み
}

正規表現を使って描画命令(アルファベット)毎に文字列を分割しますが,分割文字を消したくはないので肯定的先読みで分割した上でアルファベットを残します.

abicky.net

取得した描画命令によって分岐する

sub convert_tikz_from_svg_opes {
    my ($svg_opes, $scale) = @_;

    my $start_point = [0, 0];
    my $current_point = [0, 0];
    my $output_code = '';

    for my $cursor (@$svg_opes) {
        if ($cursor =~ /M/) {
            $current_point = fetch_abs_point($cursor, $scale);
            $start_point = $current_point;
        }
        elsif ($cursor =~ /m/) {
            $current_point = fetch_rel_point($cursor, $current_point, $scale);
            $start_point = $current_point;
        }
        elsif ($cursor =~ /l/) {
            my $converted = fetch_draw_line_ope($cursor, $current_point, $scale);
            $current_point = $converted->{new_point};
            $output_code .= $converted->{tikz_ope};
        }
        elsif ($cursor =~ /c/) {
            my $converted = fetch_draw_cubic_bezier_curve_ope($cursor, $current_point, $scale);
            $current_point = $converted->{new_point};
            $output_code .= $converted->{tikz_ope};
        }
        elsif($cursor =~ /z/) {
            my $converted = fetch_close_path_ope($current_point, $start_point);
            $current_point = $converted->{new_point};
            $output_code .= $converted->{tikz_ope};
        }
    }
    return $output_code;
}

M(描画点を指定の絶対座標に移動する)

sub fetch_abs_point {
    my ($ope, $scale) = @_;
    $ope =~ /M(-?[0-9.]*)[ ,](-?[0-9.]*)/;

    return [$1*$scale, $2*$scale];
}

m(描画点を指定の相対座標に移動する)

sub fetch_rel_point {
    my ($ope, $current_point, $scale) = @_;
    $ope =~ /m(-?[0-9.]*)[ ,](-?[0-9.]*)/;

    return [($1*$scale + $current_point->[0]), ($2*$scale + $current_point->[1])];
}

l(現在の描画点から指定の相対座標に直線を引く)

sub fetch_draw_line_ope {
    my ($ope, $current_point, $scale) = @_;
    $ope =~ /l(-?[0-9.]*)[ ,](-?[0-9.]*)/;
    my $tikz_ope = sprintf(
        "\\draw (%f, %f) -- (%f, %f);\n",
        $current_point->[0],
        $current_point->[1],
        $1*$scale + $current_point->[0], $2*$scale + $current_point->[1]
    );
    return +{
        new_point => [($1*$scale + $current_point->[0]), ($2*$scale + $current_point->[1])],
        tikz_ope  => $tikz_ope
    };
}

c(現在の描画点から指定した第一制御点,第二制御点を元に指定した相対座標まで三次ベジェ曲線を引く)

sub fetch_draw_cubic_bezier_curve_ope {
    my ($ope, $current_point, $scale) = @_;
    my $tikz_ope = '';
    while(
        $ope =~ /(-?[0-9.]*),(-?[0-9.]*) (-?[0-9.]*),(-?[0-9.]*) (-?[0-9.]*),(-?[0-9.]*)/g
    ) {
        $tikz_ope .= sprintf(
            "\\draw (%f, %f) .. controls (%f, %f) and (%f, %f) .. (%f, %f);\n",
            $current_point->[0], $current_point->[1],
            $1*$scale + $current_point->[0],
            $2*$scale + $current_point->[1],
            $3*$scale + $current_point->[0],
            $4*$scale + $current_point->[1],
            $5*$scale + $current_point->[0],
            $6*$scale + $current_point->[1]
        );
        $current_point = [($5*$scale + $current_point->[0]), ($6*$scale + $current_point->[1])];
    }
    return +{
        new_point => $current_point,
        tikz_ope  => $tikz_ope
    };
}

z(現在の描画点から引き始めの描画点まで閉じるように直線を引く)

sub fetch_close_path_ope {
    my ($current_point, $start_point) = @_;
    my $tikz_ope = sprintf(
        "\\draw (%f, %f) -- (%f, %f);\n",
        $current_point->[0],
        $current_point->[1],
        $start_point->[0],
        $start_point->[1]
    );
    return +{
        new_point => $start_point,
        tikz_ope  => $tikz_ope
    };
}

最後にTeXファイルに出力する

sub save_TeX_file {
    my ($file_name, $save_texts) = @_;
    die 'Can\'t open file !' unless(
        open(
            my $tex, '>', $file_name
        )
    );
    print $tex $save_texts;
    die 'Can\'t close file' unless(
        close $tex
    )
}

出力結果はこんな感じです.

\draw (151.595400, 10.084400) -- (147.258900, 10.084400);
\draw (147.258900, 10.084400) .. controls (147.340600, 10.418200) and (147.384200, 10.665000) .. (147.438700, 11.005600);
\draw (147.438700, 11.005600) .. controls (147.486500, 11.312200) and (147.609100, 11.462100) .. (147.684000, 11.768600);
\draw (147.684000, 11.768600) .. controls (147.724800, 11.932100) and (147.738500, 12.041100) .. (147.765700, 12.204600);
\draw (147.765700, 12.204600) .. controls (147.861100, 12.776900) and (147.874700, 13.110700) .. (147.874700, 13.689700);
\draw (147.874700, 13.689700) .. controls (147.874700, 13.928200) and (147.949700, 14.078100) .. (147.874700, 14.302900);
\draw (147.874700, 14.302900) .. controls (147.847500, 14.391500) and (147.786200, 14.439100) .. (147.738500, 14.520800);
\draw (147.738500, 14.520800) .. controls (147.820200, 14.568600) and (147.888300, 14.575300) .. (147.956500, 14.629800);
...

 よさそう. 早速,\inputで読み込んで出力してみます.

\documentclass[dvipdfmx]{standalone}
\usepackage{tikz}
\begin{document}
  \begin{tikzpicture}
    \input{island}
  \end{tikzpicture}
\end{document}

逆さまの日本列島

これで生成してみると逆さまの日本列島が出ました. これは座標系がSVGLaTeXで違うのが原因だと考えられます. f:id:muscle_keisuke:20191214210114p:plain

y軸の正負を逆にする

Perlで変換する際のy軸の正負を逆にして出力します. f:id:muscle_keisuke:20191215220309p:plain

できた.

まとめ

ベクタ画像であるSVGも全てファイルはバイナリではなくテキストなので,簡単に変換できました.

汚いソースコードですが,一応,作ったものなので公開します.

Perl script for convert to SVG to TikZ