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の型定義ファイル作るところからか。。。