PHPでESRI Shapefileを出力してみた

# 目的
　作ったシステムで、ESRI Shapefileの入出力機能を求められ、先日エクスポート（ダウンロード）機能を実装できたので、その時のメモになります。

# 前提
　php 7.4＋Laravel 7、PostgreSQL 13+PostGISなCentOS7環境。たぶんubuntuでもいいと思う。
　本人Laravel学習道半ば（かつオブジェクト指向やMVCモデルも道半ば）なので、Fat Controller作りがち。

# ESRI Shapefileの入出力
　[PHP Shapefile \- Gaspare Sganga](https://gasparesganga.com/labs/php-shapefile/)を使わせていただきました。
　Shapefileのアップロードは、フォームで.shp, .shx, .dbfの3つのファイルを指定させ、フォーム受信したら一時ディレクトリ上に3つのファイルを置いて読ませる、という流れになりました。
　Shapefileのダウンロードは、publicなディレクトリ上に出力用.shpファイルを指定したら.shp, .shx, .dbf, .prjの4つを出力してくれるので、4つのファイルをZipArchiveクラスで固めて.zipとしてダウンロードさせる、そんな使い方です。

## コントローラーで全部やらず、ファイルI/O周りを別クラスに追い出す
　Laravel 7（というかLaravelそのもの）も詳しくない中で、ついついコントローラーにいろんな処理を詰め込みがちなのですが、かと言ってモデルを作るほどのことでもないように思えて、コントローラー内でクエリビルダを使って得た結果を、ファイル入出力を担当する別クラスに渡して処理させるような流れで実装しました。（設計の話はひとまず置いとく。）
　パス App/Actions を起点に下働きするクラスを定義するようにして、App/Actions/Shapefile/Convert.php などとしました。
　（当初、Shapefileのアップロードしか話がなかったために、Convert.phpを作って、その後ダウンロードもしたいということで、Export.phpも作ってます。）

## App/Actions/Shapefile/Export.php の中身

### コンストラクタ

出力先ファイル名を受け取るためにコンストラクタの引数が出力先Shapefileファイル名のフルパスにしました。
`new ShapefileWriter($shpFullFilename);` したのち、Shapefileに必要なことを仕込みます。
`setPRJ()` メソッドでは、[EPSG:4301](https://epsg.io/4301)を仕込むために、ESRI WKT文字列を固定で与えています。
`setShapeType()` メソッドにてPolygon型を指定しています。
`addNumericField()`や`addCharField()` メソッドにて、.dbfファイルに出力される属性値を定義しています。

なお、例外キャッチが下手くそですみません。

```php
    /**
     * @param string $shpFullFilename Shapefile名
     * @throws \Exception
     */
    public function __construct(string $shpFullFilename)
    {
        $this->shp = $shpFullFilename;

        try {
            // Open Shapefile
            $shapefile = new ShapefileWriter($shpFullFilename);
            $shapefile->setPRJ('GEOGCS["Tokyo",DATUM["D_Tokyo",SPHEROID["Bessel_1841",6377397.155,299.1528128]],PRIMEM["Greenwich",0],UNIT["Degree",0.017453292519943295]]');
            $shapefile->setShapeType(Shapefile::SHAPE_TYPE_POLYGON);
            // Create field structure
            $shapefile->addNumericField('ID', 10);
            $shapefile->addCharField('NAME', 254);
            // （がっつり略）

            $this->Shapefile = $shapefile;
        }
        catch (Shapefile\ShapefileException $e) {
            ddd([
                "Error Type: " . $e->getErrorType(),
                "Message: " . $e->getMessage(),
                "Details: " . $e->getDetails()
            ]);
        }

    }

```

### レコード追加

`public function addRecord(string $name, /* 略 */, string $wkt)` などとしてみました。
`$wkt`でPolygon型のジオメトリデータを渡すのですが、クエリビルダにてこんなことをしてつくってます：

```php
->selectRaw('public.ST_AsText(public.GeomFromEWKT(public.ST_AsEWKT(public.ST_Transform(geom, 4301)))) as wkt')  // MULTIPOLYGONからSRID除去
```

テーブル上ではEPSG:4301じゃなくEPSG:4326のMultiPolygonで持たせているのでジオメトリ変換して、せっかく付帯しているEPSGコードを除去してます。
（このコードに落ち着く前後で、MultiPolygon→Polygonに変換してみたりなど2転3転しているので、もっとスマートな書き方がありそうな気がする。）

で、レコード追加のメソッドはこんなふうに。
IDの値はこのExportクラスのメンバーとしてオートインクリメントするようなつもりでやってます。

```php
    public function addRecord(string $name, /* 略 */, string $wkt)
    {
        $mp = new MultiPolygon();  // MULTIPOLYGON
        $mp->initFromWKT($wkt);
        $mp->setData('ID', $this->nextId());
        $mp->setData('NAME', $name);
        /* 略 */
        // Write the record to the Shapefile
        $this->Shapefile->writeRecord($mp);
    }
```

## zipファイルにまとめる

　zipファイル内でディレクトリ階層を特にもたせることをせずに、雑だけどこんなふうに。

```php
        // 出力されたShapefileをZipにまとめる
        $zip = new \ZipArchive;
        if ($zip->open($zip_filename, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) === true) {
            foreach ([$exp_filename_shp, $exp_filename_shx, $exp_filename_dbf, $exp_filename_prj] as $filename) {
                $zip->addFile($filename, basename($filename));
            }
            $zip->close();
        }
```

## zipファイルをダウンロードさせる

　レスポンスを一工夫。_blankページでダウンロードするように仕向けている。

```php
        $headers = [
            'Content-Disposition' => 'attachment; filename="' . $zip_short_filename . '"'
        ];
        if (file_exists($zip_filename)) {
            return response()->download($zip_filename, $zip_short_filename, $headers);
        } else {
            ddd('ファイル生成に失敗しました。' . $zip_filename);
        }
```


# 最後に
　PostGIS好きな人とつながりたい、Laravel好きな人とつながりたいです。
　MapServerを嫌いにならないでください。
　雑な説明ですみません、例外処理が拙いので改善につながる助言は大歓迎です。
　ご指摘あれば説明を補うつもりです。