SVGTextElementのBBoxをpuppeteerで取る

なんでこんなことをしてるのかというのは、プロジェクトがクソ忙しい中できちんと説明できないのですが、ふと面白くなっちゃってd3.jsとdagre.jsでSVGを書いてました。

要件としてはブラウザ上でSVGにてテキストが入ったダイアグラムを描く際に、レイアウトのためにテキストの描画高さや幅が欲しかった。取るのは、SVGTextElement$getBBox()で簡単に取れるのですが、これをテストするとなると一転して大変。

 まずコードだけでやる

まずは何も疑わずにjsdomで書きます。ツールはJestです。コードはTypeScript。

import { JSDOM } from 'jsdom';
import * as d3 from 'd3';

describe('dagre-d3.spec', () => {
    test('jsdom-d3', () => {
        const doc = new JSDOM().window.document.documentElement;
        const text = d3.select(doc).select('body').append('svg')
            .attr('width', 1600).attr('height', 900)
            .append<SVGTextElement>('text').attr('x', 40).attr('y', 40)
            .attr('dominant-baseline', 'text-before-edge').attr('font-size', 18)
            .text('Hello World!').node();
        console.log(text!.innerHTML); // <- これはOK
        console.log(text!.getBBox()); // <- TypeError: text.getBBox is not a function
    });
});

爆死です。原因はjsdomのSVGTextElementがgetBBoxを実装していないため。jsdomのソースコード見るとしっかりSVG周辺はオプトアウトされていました。ならばと、このjsdomのSVG欠損問題を解決する代替DOM実装を探します。そして見つけました、svgdom。

    test('d3-svgdom', () => {
        const svgdom = require('svgdom');
        const svg = svgdom.document.documentElement; // documentがいきなりsvgタグ
        const text = d3.select(svg)
            .attr('width', 1600).attr('height', 900)
            .append<SVGTextElement>('text').attr('x', 40).attr('y', 40)
            .attr('dominant-baseline', 'text-before-edge').attr('font-size', 18)
            .text('Hello World!').node();
        console.log(text!.innerHTML); // <- これはOK
        console.log(text!.getBBox()); // <- これも動く!...しかし?
    });

このコードのgetBBox()の出力結果が以下。

 PASS  src/__tests__/dagre-d3 .spec.ts
    ● Console

    console.log src/__tests__/dagre-d3.spec.ts:25
      Hello World!
    console.log src/__tests__/dagre-d3.spec.ts:26
      Box {
        left: 40,
        x: 40,
        top: 20.7607421875,
        y: 20.7607421875,
        width: 103.306640625,
        height: 24.5126953125 }

yの値が40じゃなく、どうやらdominant-baselineが効いてないようです。

また、svgdomは内部でfontkitというパッケージを使い、fontkitはフォントファイルをあらかじめ読み込んでそのグリフやらなんやらをデータ解析して計算していました。で、あらかじめ用意されているフォントは英文のものだけで、あとは自分で入れろと。フォントが違えば結果も違ってしまいますが、その辺なんかめんどいくさいのと、svgdomとfontkit共にTypeScript型定義ファイルがなくてめんどくさいのと、プロジェクト自体もマイナーすぎて信じていいか悩んでしまう。

よって、前置き長かったですがコードだけでやるのは早々に諦めました。jsdomほどのプロジェクトが実装できてないのだから、相当の難度なのでしょう。そこは頑張らなくても怒られないと思う。

諦めてヘッドレスブラウザでやる

github.com

ヘッドレスブラウザでやるのにはpuppeteerを使います。はじめPhantomJSでやろうとしたらドキュメントに色々と開発者のギブアップ宣言があって、これからはpuppeteerだというようなことを読んだので。puppeteerはChrominiumを腹に抱え込んでてヘッドレス動作させます。テストコードと繋ぐのはChrom Dev ToolsのプロトコルでChrom開発チームが頑張ってるらしい。ドキュメント読んでもモチベーションたかそうな気合の入った文言が並ぶ。

puppeteerはブラウザ利用ですから、読み込ませるHTMLを用意します。HTMLからコンソールに書き出すとテストの方からイベントで取れる。

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>puppeteer</title>
</head>
<body>
<svg width="1600" height="900">
    <text id="hello_world" x="40" y="40" dominant-baseline="text-before-edge"
            font-family="Roboto" font-size="18" fill="black">Hello World!</text>
</svg>
<script>
    const bbox = document.getElementById('hello_world').getBBox();
    console.log(`console.log => ${bbox.x}, ${bbox.y}, ${bbox.width}, ${bbox.height}`);
</script>
</body>
</html>

テストは以下のように。コンソールを繋ぐのと同時にテスト側からコードをぶち込む方法を並存させています。

import puppeteer from 'puppeteer';
import * as path from 'path';

describe('puppeteer', () => {
    test('puppeteer file', async () => {
        const browser = await puppeteer.launch();
        const page = await browser.newPage();
        // コンソールを覗く
        page.on('console', msg => console.log(msg.text()));
        // fileプロトコルで読み出す
        await page.goto(`file://${path.resolve(__dirname, './index.html')}`);
        // テストの方からセレクターでエレメントを持ってくる
        const result = await page.$eval('#hello_world', (selected) => {
            const bbox = (selected as SVGTextElement).getBBox();
            // プリミティブ型じゃ無いとプロトコルに乗らないので文字列に加工する
            return `$eval => ${bbox.x}, ${bbox.y}, ${bbox.width}, ${bbox.height}`;
        });
        console.log(result);
        await browser.close();
    });
});

結果は以下の通り。

  PASS  src/__tests__/puppeteer.spec.ts
  ● Console

    console.log src/__tests__/puppeteer.spec.ts:9
      console.log => 40, 40, 97.03125, 21
    console.log src/__tests__/puppeteer.spec.ts:18
      $eval => 40, 40, 97.03125, 21

yの値がさっきと違って想定通りの40なのが、やっぱり。ブラウザ使う方がそりゃ安心かなと。

速度の問題もない

このpuppeteerを使う実験の方ですが、ローカルのファイルをfileプロトコルで読みだしてると超速いです。これをhttp/httpsを用いてネット越しに取ってくるとすぐタイムアウトしちゃう。localhostを立てても遅く、そもそも面倒です。クロスサイトにならず全部ローカルに置けるならセキュリティ問題も無い。しかし!

そして課題。大問題。

テストの方でimportしても、それはブラウザの方に送られない。。。なんか方法ありそうだけど、コードの依存関係丸ごとオンザフライで送り込む方法が用意できていないので、テスト準備が至極大変になります。これでTDDな単体テストでグルグル回すというのは無理ゲー。なんか方法ありそうだけどなー。

念のためそもそも

そもそも、getBBox()をしないのであれば一番初めのjsdomで十分にテストできます。d3.jsをまっすぐに使うだけならgetBBox()にぶつかることないと思います。私がぶつかったのは、dagre.jsでレイアウトを整えるのにgetBBox()もしくはgetComputedTextLength()が使いたかったから。

結局SVGTextElementのBBoxは単体テストで気軽に取れないのか。。。fontkitのとこまで戻る?TypeScriptの型定義ファイル作るところからか。。。

可搬性の高いGoのVendoring環境整備その後、dep編

mk.hatenablog.com

direnvでGOPATHを通し、特にまずvendorフォルダを優先することでgo getにていい感じにVendoringするようにしてました。が、しかし、しばらく離れているうちに様子が変わってしまいましたね。

HUGO(https://github.com/gohugoio/hugo)のソース見たときに、リポジトリのルートで未知のGopkg.lockファイルを見つけ、何だろうと調べたのがdep気づきのきっかけです。

github.com

そのほか、Serverless.comでGo言語版のテンプレートを実行しようとしたらdepを使った構成が推奨されていたりと、何度かdep体験が連続しました。depのリリース履歴を見るとファーストリリースは2017年5月で、その後9月のリリースぐらいからじわっと来てたのかなと見て取れます。既にbrewでぶっこめるようにもなってて素敵ですが、brew info depで見ると2018年1月の0.4.1からの登録でした。まさに普及はこれからですね。

dep環境にするには、まずGOPATHを決める。デフォルトに環境変数で決め打ってOKなのですが私は既存環境のこともあるので大好きdirenvで設定。

# ~/go/.envrc
export GOPATH="`pwd`"

前に行ってたようなvendorフォルダとプロジェクトルートを両方通さなければならないということはなく、どっかにGOPATHをまず通す。上記では~/goフォルダに通した。

$ tree -a -L 3 ./go
./go
├── .envrc
├── bin
├── pkg
│   └── dep
│       └── sources
└── src
    ├── aws-hugo
    │   ├── .gitignore
    │   ├── .idea
    │   ├── Gopkg.lock
    │   ├── Gopkg.toml
    │   ├── Makefile
    │   ├── codebuild
    │   ├── codecommit
    │   ├── generate.go
    │   ├── s3
    │   ├── serverless.yml
    │   └── vendor
    └── renames
        ├── Gopkg.lock
        ├── Gopkg.toml
        ├── renames.go
        ├── renames_test.go
        └── vendor

GOPATHフォルダの下に、srcフォルダを作り、その中にプロジェクトを作ります。プロジェクトルートでmainパッケージを置いてその下にパッケージ名で階層掘ってく。

あとは、Makefileの冒頭や手打ちで、dep ensure を実行するとソースコード中に書かれたimportを解決すべくVendoringをdepが頑張る。たまにアップデートするには、dep ensure -updateやdep prune。

問題は、バージョン固定で外部を持ってきたいときがあるならdepでワークフロー組めない。勝手に解決方式のdepではなく自らの意識で持ってくるgo getをdirenvと組み合わせて使うのが変わらずいいと思う。私は全部depに移行でいいや。便利だし。

GoLand...っておい

www.jetbrains.com

しばらく前のSPAプロジェクトを久しぶりに開いてみたのです。同じプロジェクトでTypeScriptのクライアントとGoのサーバを両方書けるようにしたWebStormで作った環境が、どうも何かおかしい。GoのIDEサポートが動いていないのです。

結論として、WebStormに導入していたJetBrailns謹製Go言語プラグインが有料製品に昇格したために元のプラグインが無効にされていた。。。30日無料でGoLandを使い始めました。この決断の前にはAtomで一通りやってみたのですが、どうにも欲しい機能がなかったりする。もしかしたら解決方法あるかもしれないけど、それを探すのが苦痛だったので、GoLandをダウンロードしました。

課金するかはわからない。Pythonは継続して書かなかったのでAtomで間に合わせて、PyCharmには手を出さなかった。。。しかしGoは過去資産があるんだよなー。

AWS Lambdaでのネイティブコード実行

なんと、まだ日本にいます。12月下旬からなので既に2ヶ月以上。流石にそろそろ帰米します。

さて、AWS lambdaのこと。

Lambda Execution Environment and Available Libraries - AWS Lambda

If you are using any native binaries in your code, make sure they are compiled in this environment. Note that only 64-bit binaries are supported on AWS Lambda.

公式のドキュメントの冒頭、初め何を言ってるのか分からなかった。ネイティブバイナリって何のことだろうかと。

調べると要はLambdaの設置Zipパッケージの中にネイティブバイナリを置いておいて、Pythonやnode.jsで書いたLambda関数から呼べるということでした。スピンアップ効率にはちょっと疑問あるけど、腹に抱えこんでさえいればコマンド叩けるんだね。

hugo-lambda/RunHugo.js at bb3709a27a0c19fd6bdfb712305a8ecfdc4c3a59 · ryansb/hugo-lambda · GitHub

    function runHugo(next) {
        console.log("Running hugo");
        var child = spawn("./hugo", ["-v", "--source=" + tmpDir, "--destination=" + pubDir], {});
        child.stdout.on('data', function (data) {
            console.log('hugo-stdout: ' + data);
        });
        child.stderr.on('data', function (data) {
            console.log('hugo-stderr: ' + data);
        });
        child.on('error', function(err) {
            console.log("hugo failed with error: " + err);
            next(err);
        });
        child.on('close', function(code) {
            console.log("hugo exited with code: " + code);
            next(null);
        });
    },

この例示で抱え込まれている「hugo」はGoで書かれているOSSなので、LambdaがGoに対応した今ではもっと直接的なアプローチにも道が開きましたが、これは一つのベストプラクティスだと思う。

Python速修

スーパーバイザーで入ったプロジェクトがPythonメインだったため、これまでずっと食わず嫌いだったPythonを速修しています。初めてPythonの名を知ったのは間違いなく10年以上前、もしかしたら15年ぐらいかもと思いますが、オフサイドルールと呼ばれる改行とインデントでブロック認識させる書き方がなんか馴染めなくてHello World以前に避けてました(たとえばYAMLよりJSONの方が好き)。時が過ぎ、GoogleがAppEngineをPython版でリリースした時も遅れてJava版も出たし、クラウドでの機械学習等についてはその機運が高まっている時にそもそも自分が手を動かすところにいなかった。つまりこれまでは避けてもなんの支障もなかった。

しかし、今やAmazonGoogleやその他のクラウドでもPython第一言語なのかなというぐらいにどこでも対応されているのでやって無駄はなかろうし、とにかくプロジェクトの採用言語がすでにPythonだったから避けられないだろうと、重い腰あげて学んでみました。隙間時間での取り組みでカレンダーとしては年明け約二週間、ここまでで正味3日ぐらい。

Python環境を作る

MacにHomeBrewでpyenv入れてPython 3.6.4をインストールしました。その後のことですがリントのためflake8も入れた。

AtomでのPython環境作り

まずはAtomPython環境を作りました。

  • autocomplete-python:識別子の自動補完。やってみたら完璧レベルに補完してくれて快適
  • python-indent:オフサイドルールのためのインデント処理。これ大事。
  • indent-guide-improved:インデントを可視化する。これ重要。
  • linter-flake8:当然のリントツール。私はこれが無いとアプリケーション書けないと思ってる。
  • platformio-ide-terminal:シェルがエディタにくっついていると便利。

Pythonチュートリアル

Pythonチュートリアル 第3版

Pythonチュートリアル 第3版

まずは移動時間や寝る前とか、待ち合わせ前に喫茶店で時間調整しているときに本を一冊。このPythonチュートリアルはどうやら公式的なものっぽい位置付けで、常にWEBに上がってるものの製本版なのですが、私も中高年なのでスクリーン長時間みてると目がしょぼつくから紙で購入。

Pythonの公式姿勢が此の期に及んでまだ控えめなのか、他に書けるプログラミング言語がしっかりある人向けにしか書かれていない。すなわち非オフサイドルールなC系文法に馴染んでいる人にPythonすごいよとアピールする本。結果としてチュートリアルにして全く入門本でなく、Python初心者に向けていて全くプログラミング初心者には向けていないのが私にちょうど良かった。

  • この本でだいたいPythonの基本的なことを知ることができるんじゃないかな?地平線を知らないので断言できないが。
  • 4章 制御構造、5章 データ構造、6章 モジュール、これらでPython書ける気がする。実際書けた。
  • 続いて7章 例外処理、8章 クラス、で他言語との文法の違いだけ追えば、既存コードを読める。
  • 薄い本なので正味1日ぐらいで終えられる。

既にPythonが素敵だなあと思い始めてきた。私はGO言語を書いたときにタプルとスライスを知って便利だと思ってたのだけど、どちらもPythonにしっかりあって素敵。タプルの代入で開くやり方で関数戻り値の書き方もGO言語的(もしくはSwift的とも)にできる。

Fluent Python

Fluent Python ―Pythonicな思考とコーディング手法

Fluent Python ―Pythonicな思考とコーディング手法

amazon.co.jpでポチった時には気がつかなかったが、これは800ページ弱と厚く、昔ながらのコンピューター書籍サイズな大型本でした。普段持ち歩く重さでは無いです。

まだ読み始めですが、これは超良書。当然プログラム入門本で無くPython初心者向けでもないのですが、かといって普段づかいの実践的なことが内容なので興味を逸らさず、5,800円の書籍価格と読むために係る自分の時給を足しても久しぶりにTOCの良い本だった。マストバイ。

  • やっぱりPythonはそのデータ構造のところのテクニックがユニークだし大事。
  • 関数型プログラミングできる。データ構造のところで知ったデリゲートパターン(?、つまりはダンダー)を活用したのも素敵だけど、最近の私はTypeScriptでもクラスベースを避け関数型風に書くのが好きなのでPythonでもそう書けるのは嬉しい。
  • 普通の自然科学書としても読んで面白いレベル。
  • まだまだ読み進める先の先には、コルーチンとか非同期処理についてもちゃんと章があった。今読んでるところから400ページぐらい後ろなので目次しか見てないけど。

PyCharm

PyCharmはJetBrainのシリーズ商品でPythonIDEですがこちらにコミュニティ版として無料の奴があった。IDEとしてJetBrainの製品群は完成度レベルが高いのですがPyCharmはいかに?Atomで環境作ったばかりで痛みがまだ無いために試していない。この後、速修時期を終えてもまだしばらくPythonやることあればダウンロードしたり課金したりするかもしれない。

ちなみにWEBフロントを書く用にはWebStormがお気に入りでずっとサブスクライブ課金しています。WebStormは初期試用無料はあるけどコミュニティ版はないってのがエグいな。それだけ需要があるってことなのでしょう。

現時点での感想

Python面白い。特にデータ構造周辺が素敵。まだPythonで非同期処理を書く局面に到達できていないのですが、クラウドAPIコールで必ず出てきますので。。。Pythonが面白いとはいえ、まだ私はTypeScriptがより好きだな。画面をPythonで書くことはおそらくこれからもないと思うので、サーバ&クライアントを両方書けるTypeScriptがまだまだラブ。

Alexa...アレクサ?

f:id:masataka_k:20171210094532j:plain

Amazon Echo Dot (2nd Gen.)を買いました。日本では招待制購入みたいですが、米国では有り余っているのか値引きして$30になった上、オーダーしたらFree-2day指定のはずが翌日届きました。当然英語版ですが、いきなり日本語化します。

  • 箱から出してmini-USB電源ケーブルを繋ぐ
  • https://alexa.amazon.co.jp をPCブラウザで開き、日本のAmazonアカウントでログイン
  • 画面の手順説明にしたがって、Echo DotにWiFiの設定をする
  • 言語に日本語選択肢が無いので英語のままでセットアップ。その後10分ぐらい放置してたらファームのアップデートが始まった
  • 数分でファームアップデートが完了すると、日本語が選べるようになる。
    • PCブラウザの設定画面で言語を日本語に設定する。
    • 住所はAmazon.co.jpアカウントに残ってた日本の最後住所になってたので、今住んでる米国住所を入力する。
    • 気温は摂氏、長さはメートル法に設定する。
    • ウェイクワードを、AlexaからOK Googleに変更しようとしたら、ダメじゃん。Amazon的に用意されているワードからのみの選択だった

OK。Alexaからアレクサに帰化しました。

ここのところ数ヶ月、小さな業務機能を作るのにサーバレスはじめて、実装はServerless.com + TypeScriptで、クラウド環境はAmazon Lambda + Amazon RDS + Amazon SNS + Google Drive + Google Sheetsというのを一通り習得してました。今回Echo Dotを買ったのはここ1ヶ月ぐらいSNSで話題になってたのもありますが、Lambdaのドキュメント見てるとAlexaのインターフェイスががっちり定義されていたので結構手軽にAlexaスキルを作れそうと思ったからです。

時期的にAdvent Calendarを書けばよかったのだけど、そこは激しく色々嵌ってたので、それどころじゃなく。今週から2ヶ月日本へ出張のため年末年始は日本のカレンダーで休みなので、アレクサのスキル書いて遊びながらサーバレス開発の備忘をしてこうと思います。

続 keyof T

in keyof T - まさたか日記

keyof Tを用いて連想配列でアクセスする際の縛りを前に書きましたが、もっとスマートな書き方を思いついたのでメモ。

test ('power of keyof', () => {
    function checkObject<T>(props: T): { [N in keyof T]?: string } {
        return Object.keys(props).reduce(
            (prior, key) => {
                // propsを型変換するのではなく、連想配列キーを「as keyof」でTへ縛る。
                const value = props[key as keyof T];
                if (typeof value === 'number' && value > 0) {
                    return prior;
                }
                return { ...prior, [key]: `${key} is invalid.` };
            },
            {},
        );
    }
    type Target = {
        first: string,
        second: number,
        third: number,
        fourth: boolean,
    };
    const target: Target = { first: '', second: 10, third: -3, fourth: true };
    const result = checkObject<Target>(target);
    expect(result.first).toBe('first is invalid.');
});

変えたのは一箇所で、props[key as keyof T]のとこ。propsを「as { [key: string]: any }」で型変換するのではなく、連想配列キーを「as keyof」でTへ縛る。この応用で、関数の引数とかで、name: stringがあったときに、name: keyof Tというのも有用かと思います。

function foo<T>(obj: T, name: keyof T): any {
    return T[name];
}

コードとしてはあまり意味がないけど、これでしょうがなくanyで受けてたところが改善されます。