GeoJSON を力技で PowerPoint へ取り込む

## はじめに

ときどき、地図データを PowerPoint へ取り込みたくなることがあります。他の GIS ソフトウェア等で作成したデータを画像として取り込むことが多いと思いますが、PowerPoint 上で編集するという需要もあるかもしれません。

今回は技術的な興味として、GeoJSON ファイルを PowerPoint 上で編集できる形で取り込む方法について考えてみます。こだわるとキリがなさそうなので、今回は、**ラインデータとポリゴンデータを、PowerPoint へ「線」として取り込む**ことにします。

:::note warn
正確には、PowerPoint はソフトウェアの名前ですが、PowerPoint ソフトウェアで利用するプレゼンテーション用ファイルもひっくるめて「PowerPoint」と呼ぶことにします。
:::

## PowerPoint の中身

知る人は知る情報だと思いますが、PowerPoint に限らず、Word や Excel 用のファイルは、実態は zip 形式で、複数の XML ファイルを中心に構成ファイル一式を圧縮したものです。拡張子（pptx 等）を zip に変更すると、Windows の通常機能等で解凍して、構成ファイルを確認することができます。

PowerPoint で図形を作成することがありますが、この図形も XML として記述されています。ですから、GeoJSON のジオメトリの情報を PowerPoint で利用できる XML 形式へ変換できればよい、という発想になります。


## DrawingML の概要

PowerPoint、Word や Excel 用の各ファイルを構成する XML の仕様は Office Open XML として仕様が策定されています。PowerPoint 関係の仕様は PresentationML としてまとめられていますが、それとは別に、図形に関する仕様は DrawingML という共通の機能としてまとめられています。DrawingML の記述も XML となります。


PresentationML の場合、図形の描画設定は `./ppt/slides/slide1.xml` に記載されています（slide1.xml の番号はスライド番号に相当）。以下、PresentationML と DrawingML のうち、線の描画関係に絞って必要な機能を抽出して、概略図を作成してみました。

![DraingML-線関係抽出-概略.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/c1cbae4c-0340-21b0-1f71-eff3c0ea47f7.png)



上記概略図で示した XML の構造を簡単に説明すると、`<p:spTree>` の下に、図形の分だけ `<p:sp>` があります。 `<p:sp>` に含まれる `<p:spPr>` の下に、主な設定が集まっています。`<a:xfrm>` には、図形の描画位置・描画範囲が含まれています。`<a:custGeom>` が図形のジオメトリの本体で、座標値とそれを用いた線の引き方が定義されます。`<a:ln>` で、線のデザインを設定できます。

図形の位置や形状を記述するには、上記の DrawingML の構造の中に、適切に座標値を設定できれば良いことになります。


## GeoJSON の経緯度をスライド内の座標値へ変換する

経緯度をスライド内の座標へ変換する方法を考えます。

まず、経緯度とスライド内の座標の違いとして、北半球の場合、座標の軸（緯度方向/Y軸方向）の方向が異なります。また、地球は丸いため、単純に経緯度の値を用いて描画してしまうと、形状が歪んでしまいます。

これを解消するため、経緯度を [Slippy map tilenames](https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames) の座標（以下、「タイル座標」と言います）へ変換することとしました。（[ウェブメルカトル投影](https://en.wikipedia.org/wiki/Web_Mercator_projection)）

タイル座標では、Y軸の方向がスライド内の座標と一致します。また、座標についても、そのまま拡大・縮小・平行移動でスライド内の座標へ変更しても違和感がなくなります。

:::note
タイル座標はウェブ地図でよく使われているものなので、ウェブ地図で取得した画像と重ね合わせやすいというメリットもあります
:::

![座標変換の流れ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/3d9886a0-26ae-cef9-0478-b85feae6ed9e.png)


タイル座標をスライド内の座標へ変換する具体的な流れですが、まず、描画したい地物全ての最も西の頂点のX座標値、最も北の頂点のY座標値を全地物の原点として、スライドの原点に合わせます。

また、各地物についても、最も西の頂点のX座標値、最も北の頂点のY座標値を計算します。これを各地物の原点とします。各地物の位置（`<a:off>`）は、全地物の原点と各地物の原点のオフセットで指定します。また、地物の描画領域として、各地物の最も東の頂点のX座標値、最も南の頂点のY座標値を用いて、各地物との差分を計算します（`<a:ext>`、`<a:path>`）。各頂点は、各地物の原点からの差分で記述します（`<a:pt>`）。

![スライド内での座標の計算.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/d532dd8f-de67-67b4-443e-2992b3fbcc5b.png)

:::note 
なお、スライド内の座標は、"English Metrin Unit (EMU)" と呼ばれており、360,000 EMU = 1 cm となっています。非常に大きい数値ですが、位置を整数で正確に示すことができるため、小数点にまつわる誤差を少なくすることができます。この発想にしたがい、タイル座標についても、なるべく大きなズームレベルで計算することとします。
:::

以下、経緯度をスライド内座標へ変換するコードです。マジックナンバー（12 や 1024）が多いと思われますが、なるべく大きなズームレベルで計算するとともに、スライド内でタイルにして縦1.5枚、横2.5枚くらいになるように調整した結果です。

```JavaScript
// 経緯度をタイル座標へ変換（ https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames の記載例を修正）
const lon2tiled = (lon,zoom) => { return ((lon+180)/360*Math.pow(2,zoom)); }
const lat2tiled = (lat,zoom) => { return ((1-Math.log(Math.tan(lat*Math.PI/180) + 1/Math.cos(lat*Math.PI/180))/Math.PI)/2 *Math.pow(2,zoom)); }
const tile2long = (x,z) => { return (x/Math.pow(2,z)*360-180); }
const tile2lat  = (y,z) => {
  const n=Math.PI-2*Math.PI*y/Math.pow(2,z);
  return (180/Math.PI*Math.atan(0.5*(Math.exp(n)-Math.exp(-n))));
}

// 基準となるタイル（全地物の原点として利用）
const global = {
  btile: [14, 14623, 6017],
  zl: 14
}

// 変換用関数
const cvPt = ( xy, info = {} ) => {
  const ext = 12;
  const b = global.btile || [2, 3, 1]; //左上
  const bx = b[1] * Math.pow(2, ext);
  const by = b[2] * Math.pow(2, ext);
  
  // offset はスライド内座標（EMU）で与えられることを想定
  const ox = info.offsetX || 0;
  const oy = info.offsetY || 0;
  
  // タイル座標へ変換
  const cx = lon2tiled(xy[0], b[0] + ext);
  const cy = lat2tiled(xy[1], b[0] + ext);
  
  // オフセットを算出
  const dx = Math.abs( cx - bx ); // 大体ZL15で3桁くらい
  const dy = Math.abs( cy - by );
  
  // 1024 をかけて、スライド内で適当な大きさで表示されるようにする。
  const X = Math.floor( dx * 1024 - ox );
  const Y = Math.floor( dy * 1024 - oy );
  
  return {
    "_": `<a:pt x="${X}" y="${Y}"/>`,
    "x": X, 
    "y": Y
  };
  
};

// 実行
const pt = cvPt([135.5, 35.5], {"offsetX": 0, "offsetY": 0});
console.log(pt);
```


## 変換ツールの作成

変換ツールを以下のレポジトリで公開しております。

https://github.com/mghs15/geojson2presentation

レポジトリ内の `mkSlideXml.js` を Node.js で実行できます。使うときには、ファイル内最後の方にハードコードされている設定を調整します。

```JavaScript
global.zl = 13.5  // 変換したい GeoJSON データの領域に合わせて調整
const slide = mkSlide(["./data.json"]);  // 変換したいファイルへのパス（配列で複数指定可）
```


:::note warn
拡張子は json しか受け付けません。これは、内部で `require()` を用いてファイルを取り込んでいるからです。
:::


以下、実行例です。

```bash
node mkSlideXml.js > slide.xml
```

ツールにおける処理の要点としては、以下の通りです。

* ポリゴンも線に変換しています。
* 線のスタイルは色と幅に絞っています。
  * 線のスタイルは、データの属性値に合わせて条件分岐させています（`mkSlideXml.js` 内にハードコード）。
* 上記で設定する `global.zl` の値は、変換したい GeoJSON データの領域に合わせて調整します。
  * たとえば、ズームレベル13のタイル2枚分のデータの時、ズームレベル14を指定すると、タイル4枚分の大きさで描画します。

:::note warn
上述しましたが、スライドは横2.5枚くらい×縦タイル1.5枚くらいになるように調整しています。タイル2枚がスライドの縦に収まるようにするときは、`global.zl` に本来のズームレベルから0.5を差し引いたくらいの値を設定すると良いです。
:::

### ウェブサイト版ツール

Node.js がなくとも、ブラウザ上で動くようにウェブサイト版も作成しました。ただし、Node.js よりも明らかにパフォーマンスが悪いです。

https://mghs15.github.io/geojson2presentation/geo2slide.html


## サンプル

以前作成した[地理院地図Vector から GeoJSON を取得するツール](https://github.com/mghs15/get-geojson-from-gsivector)で、国土地理院の[最適化ベクトルタイル](https://github.com/gsi-cyberjapan/optimal_bvmap) のデータを PowerPoint へ取り込んでみます。

なお、Windows 10 の WSL 1 (Ubuntu 20.04.5 LTS) 上の Node.js (v16.13.2) で実行しています。

1\. 上記のツールで適当な場所を開いて、好きな場所のデータを GeoJSON で取得します。今回は、ズームレベル15で、徳島駅周辺のタイル6枚分を取得し、`data.json` という名前で、`mkSlideXml.js` と同じ場所に保存します。

![徳島駅周辺の6タイル.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/e53a80a2-61cd-95c7-37ee-f0b7dddd07e7.png)

:::note warn
あまりに広すぎると、データ量が大きくなり、処理が大変になりますし、なにより PowerPoint が動かなくなりますので、目安としてはタイル6枚（3×2）くらいにします。また、建物が多い都市部でもデータ量が多くなりますので、建物のデータを除くなどの工夫も有効です。
:::

2\. ツールの設定は以下の通りにします。

```JavaScript
global.zl = 14.5  // 変換したい GeoJSON データの領域に合わせて調整
const slide = mkSlide(["./data.json"]);  // 変換したいファイルへのパス（配列で複数指定可）
```

この設定の下、ツールを実行します。

```bash
node mkSlideXml.js > slide1.xml
```

3\. PowerPoint でプレゼンテーションを新規作成して、スライドを1枚作製し、保存します。その後、拡張子を pptx から zip へ変換し、解凍します。

4\. 解凍して出てきたファイル群のうち、`./ppt/slides/slide1.xml` をツールで作成した `slide1.xml` で置き換えます。

5\. 置き換えたら、ファイル一式を zip に圧縮します。

:::note warn
圧縮時に zip の中のフォルダ構造に注意してください。Windows 10 の場合、ファイル一式を選択後、「右クリック」→「送る」→「圧縮 (zip　形式) フォルダー」でうまくいきました。
:::

6\. 圧縮した zip ファイルの拡張子を pptx にすれば、PowerPoint で開くことができます。



![完成サンプル.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/4f6fa005-f945-2330-5ea8-5c366da6bd41.png)

:::note warn
地物の種類を色と幅だけで分けていますが、見づらいかもしれません。基本図のように複雑なデータだと、破線や二重線などの線のスタイルも活用する必要がありそうです。また、ポリゴンは塗りつぶしていないため、境界線が目立ってしまいます。
:::

![完成サンプル（全選択）.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/0e78d724-9baf-420e-2dca-9e1ea4cde01d.png)

:::note warn
全選択するとものすごく重いです。また、地物の領域がミスっているもの（右にはみ出しているもの）がありますね……。
:::

![完成サンプル（編集中の様子）.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/477856ff-bc85-44a3-e414-22c0a1ed15f3.png)

:::note
取り込んだ地図の各地物は、図形として、PowerPoint 上でいつものように編集することが可能です。
:::

## 終わりに

GeoJSON も PowerPoint（DrawingML）の図形も、どちらも座標で図形の形状を指定しているので、仕様さえ分かれば変換することができました。

複雑な地図データをスライド上に表示できたときは、（自己）満足感が非常に高く、いつまでも時間を溶かしていけます。

ただし、PowerPoint へ地図を取り込んだとしても、編集のしやすさは地図編集ソフトに劣りますので、実務的には、複雑な地図データよりも**ちょっとした地物の形状をトレースする代わりに使う**のが良いかもしれません。どちらかと言うと、大縮尺よりも小縮尺のデータの方が活用できそうです。


## 参考文献

### Office Open XML 関係

http://officeopenxml.com/drwOverview.php

https://www.amazon.co.jp/%E3%81%82%E3%81%A8%E4%B8%80%E6%AD%A9%E6%B7%B1%E3%81%84%E6%83%85%E5%A0%B1%E3%82%92%E5%BE%97%E3%82%8B%E3%81%9F%E3%82%81%E3%81%AE%E3%83%AD%E3%83%BC%E3%83%89%E3%83%9E%E3%83%83%E3%83%97%EF%BD%9EOffice-Open-XML%E3%83%95%E3%82%A9%E3%83%BC%E3%83%9E%E3%83%83%E3%83%88%E3%82%AC%E3%82%A4%E3%83%89-%E6%8A%80%E8%A1%93%E3%81%AE%E6%B3%89%E3%82%B7%E3%83%AA%E3%83%BC%E3%82%BA%EF%BC%88NextPublishing%EF%BC%89-%E6%8A%98%E6%88%B8/dp/4844378236/ref=tmm_pap_swatch_0?_encoding=UTF8&qid=&sr=

### 地図データ

https://github.com/gsi-cyberjapan/optimal_bvmap