node v10.1.0は回復

node v10.1.0がリリースされていたので、nで切り替えて一通りテストやビルドを動かしてみました。

(node:26203) [DEP0005] DeprecationWarning: Buffer() is deprecated due to security and usability issues. Please use the Buffer.alloc(), Buffer.allocUnsafe(), or Buffer.from() methods instead.

ほぼ全てと言っていいぐらいに頻繁にBufferコンストラクタのワーニングが出ますけど、私の手元ではv10.0.0の時が嘘のように全てきちんと動きます。10.1.0でいいですね。

node-canvasの型定義備忘

node-canvasの型定義を使うところだけ書いてみたけど目的に敵わなかったので、定義だけ備忘。いつか使うかも。

declare module 'canvas' {
    import * as Stream from 'stream';

    interface PNGOptions {
        palette: Uint8ClampedArray;
        backgroundIndex?: number;
    }

    interface JPEGOptions {
        bufsize?: number;
        quality?: number;
        progressive?: boolean;
        disableChromaSubsampling?: boolean;
    }

    interface Canvas extends HTMLCanvasElement {
        inspect(): string;
        pngStream(options?: PNGOptions): Stream.Readable;
        jpegStream(options?: JPEGOptions): Stream.Readable;
        // pdfStream(): Stream.Readable;
    }

    interface Image extends HTMLImageElement {
        inspect(): string;
    }

    interface FontFace {
        family: string;
        weight?: string;
        style?: string;
    }

    function registerFont(src: string, fontFace: FontFace): any;
    function createCanvas(width: number, height: number, type?: any): Canvas;
    // function loadImage(src: string | Buffer): Promise<Image>;
}

v2.0.0-alpha12でregisterFontしてcreateCanvasで作ったヘッドレスCanvasに描画してpngStream/jpegStreamで書き出す一連の流れは問題ないが、loadImageで爆死。pdfStreamも不調。

node v10.0.0は地雷

node v10.0.0は地雷バージョンでした。すでに@types/nodeがv10.0.0でTypeScriptのコアLibとURLなどいくつかの型定義が被って爆死という案件がありましたが、今回はts-jestの環境で以下のテストが沈黙してしまうという問題を抱えています。非TypeScriptな素のjest環境なら大丈夫なので辛い。Babel挟むのは試してません。

describe('silence', () => {
    test('error', () => {
        fail('silent');
    });
});

これだけでテストランナーが沈黙のまま吹っ飛ぶ。これを $sudo n 9.11.1 と前のバージョンに切り替えるとts-jestでもしっかり動きます。v10.0.0はTypeScriptクラスタは回避で。APIがPromise多用で近代的に書き換わったらしいが、普通にSPAやWEBサーバ書いてる限りには関係ないし。

偶数バージョンで根源的な不都合が出ると戸惑っちゃう。。。

  • node環境構築
    • brewインストール
    • nインストール: brewによる
    • nodeインストール: nによる
    • yarnインストール: brewにより--without-nodeスイッチ付き

この構成が正義。不用意に$ brew upgrade でnodeのバージョンが変わらないし、nで適宜バージョンを変更して試せる。

開発ガイドラインとGetting started

内向きに開発ガイドラインとGetting Startedを兼ねたようなものを書こうとしたが、目次だけ書き出したけどこれでも環境作るところだけ、この後にコーディング編とテスト編とリポジトリ&CI&デプロイ編があって、そもそもコーディング編にはSPA章とサーバレス章と自前WEBサーバを含むローカルツール章があり、さらにSPA章には画面出すところまでとFluxとコンポーネントルーターAPI通信とイベントハンドリングがあり、フォーム&バリデーションやi18nやレスポンシブがある。。。さらに続く。 いつか暇になって、かつ書いたら効果が高い時にもっと目次も練って書こうと思う。おそらくそんな時は来ないだろうとも思いつつ。

  • node環境構築
    • brewインストール
    • nインストール: brewによる
    • nodeインストール: nによる
    • yarnインストール: brewにより--without-nodeスイッチ付き
  • プロジェクトの作成
    • 空フォルダにpackage.jsonを作る
    • ソースフォルダと出力先フォルダを決める
    • もしくはcliツールの利用: create-react-app、vue-cli、serverlessなど
  • package.jsonの基本的な書き方
    • プロジェクト設定: version、private、license
    • yarn addおよび-Dスイッチ付き
  • トランスパイルとリントの設定
    • yarn add typescript tslint tslint-config-airbnb -D
    • tsconfig.jsonの書き方
      • compilerOptions.target
      • compilerOptions.module
      • compilerOptions.outDir: 出力先フォルダ
      • compilerOptions.strict
      • compilerOptions.libおよびcompilerOptions.typesの意味
      • include: ソースフォルダ
      • exclude
      • compilerOptions.baseUrlとcompilerOptions.pathsの意味
    • tslint.jsonの書き方
      • extends: [tslint-config-airbnb]
      • rulesの意味
      • おまけ:ソースインラインでリント設定を抑制する方法
  • (SPAであれば)バンドラーの設定
    • cliでプロジェクトを作った場合
      • create-react-appの場合
      • vue-cliの場合
    • yarn add webpack webpack-dev-server awesome-typescript-loader tslint-loader -D
    • ルールの書き方
    • DevServerの設定
  • (SPAであれば) WEBブラウザ設定
  • テストツールの設定
    • yarn add jest ts-jest @types/jest @types/node -D
    • package.jsonにjest追加
      • moduleFileExtensions
      • testMatch
      • transform
  • package.jsonでのscriptsの書き方
    • node実行
    • ライブラリのbin
      • tsc
      • jest
      • webpack
    • シェルコマンド
    • rimrafなどの定番ツール
    • 自作JS
  • 環境の維持
    • brewでのupgradeとcleanup
    • nでのアップデートと旧版への切り替えおよび削除
    • yarn upgradeとyarn remove

考察

しかし考えるに、チェック項目として目次だけ書くのも有用かもしれない。その点で目次ツリーの大きさを抑制するためには以下の分岐をまず決める事なんだろう。

  • プラットフォーム:node(python、go)
  • node -> 言語:TypeScript(node、Babel)
  • TypeScript -> 対象:SPA(サーバレス、ローカル)
  • SPA -> フロントエンド:Vue(React、Angular)

でTypeScriptまで決めておけば先の目次みたいな感じになりますな。

gulp v4によるフルTypeScriptなビルド環境

SPAを作るには、webpack等の、色々寄せ集めて一つのJSにバンドルしてくれるツールを用いてビルドしますが、サーバサイドやローカル実行ツールのプロジェクトはそもそもビルドする必要すらありません。しかし、何らかの都合でビルドをしたい場合もあるでしょう。

  • JS以外のファイルをコンパイルしたい
    • TypeScriptで書いてる
    • SCSSとかコンパイルする
    • ドキュメントを生成する
    • テストは除いてリリースビルドを作る
  • ファイルをコピーしたり、ダウンロードしたりして構成を整える
    • フォントファイル!私はこれ
  • 難読化する

自分にニーズが無い時にはピンと来ないのですが、必要になったらどうにかしないといけない。そこでGulp、そしてv4。

https://gulpjs.com/

聞くところによると3年の沈黙を経て、昨年末ギリギリにv4.0.0がリリースされたそうです。gulp使ったことあるのは3年ぐらいは前だったかも。後方互換を保ちつつも、イマドキ風に生まれ変わりました。そしてこれがまたTypeScriptと相性が良いことが判明。

$ yarn add gulp@next gulp-typescript typescript ts-node @types/gulp @types/node@9 del -D

まず関連パッケージをインストールしますが、この時にgulpは@next、@types/nodeは@9とバージョン明示しないと本日時点ではNG。gulpはlatestがまだ3系なのは良いとして、@types/nodeの本日時点のlatestである10.0.0はTypeScriptをコンパイルできない。理由は@types/node@10.0.0とTypeScript本体のlib.d.tsでURLなど一部宣言が被って爆死しちゃうから。いずれ@types/nodeが治すでしょうけど、今日時点では@types/node@9で、治れば @types/gulp -> @types/vinyl-fs: "" -> @types/node: "" とバージョン指定してない連鎖のための10.0.0混入だからインストールする必要もなくなる。

@types/gulpは勇み足なのか本体がまだ3系をメインにしているのに普通に4系のものが入る。

// script/build.ts
import { src, dest, symlink } from 'gulp';
import * as ts from 'gulp-typescript';
const del = require('del');

(function () {
    // クリーンアップ
    del('lib');

    // コンパイル
    src(['src/**/*.ts', '!src/**/__tests__/*.ts'])
        .pipe(ts({
            target: 'es6',
            module: 'commonjs',
            strict: true,
            lib: ['es6', 'dom'],
        }))
        .pipe(dest('lib'));

    // 小サイズファイルコピー
    src('src/**/*.txt').pipe(dest('lib'));

    // 大サイズファイルはリンク
    src('src/**/*.otf').pipe(symlink('lib'));
})();

ビルドファイルは伝統のgulpfile.jsではなく何でも良いので適当な名前の.tsを作ります。ここではbuild.tsにしました。v4でgulp.taskを使わなくてよくなったのでこんな感じにかけます。プラグインもこれまでのものが使えます。ts-node入れてるのに.tsをビルドしててパッと見は奇妙なのですが、これが必要な時もある。

// package.json
{
    "scripts": {
        "build": "ts-node script/build.ts"
    },
    "devDependencies": {
        "@types/gulp": "^4.0.5",
        "@types/node": "9",
        "del": "^3.0.0",
        "gulp": "^4.0.0",
        "gulp-typescript": "^4.0.2",
        "ts-node": "^6.0.1",
        "typescript": "^2.8.3"
    }
}

package.jsonのscriptで、ts-nodeの呼び出しを書きます。以上。プロジェクトルートにscriptディレクトリを作ってそこにコマンドを一つづつファイルで書いて置いておけば良いし、共通のものなんかは関数エキスポートしてどっかでまとめて取り込んで組み立てればいいし。実行は当然以下の通り。

$ yarn build

すげー簡単。超素敵。。。だが冷静にGulpの使い所ってどれだけあるんだろうか。さらにこのぐらいのことを書くのにほとんど似たような素のnode.jsでかけるのに余計なものを詰め込んでまでTypeScriptが要るだろうか?

課題

gulp-typescriptが、オプションでpathsが効かないなど、本家tscと挙動が違う。

結論

gulpじゃなくてもいいや。

// package.json
{
    "scripts": {
        "build": "rimraf lib && tsc -p tsconfig.prod.json && cp src/font/*.otf lib/font",
        "lint": "tslint --project ./ './src/**/*.ts'",
        "test": "jest"
    }
}

元に戻った。例えばこんなこともしてます。

const _ = require('lodash');
const fs = require('fs');

(function () {
    const packageJson = _.omit(
        require('./package.json'),
        ['scripts', 'jest', 'devDependencies'],
    );
    _.set(packageJson, 'bin.helloworld', 'index.js');
    if (fs.existsSync('dist/package.json')) {
        fs.unlinkSync('dist/package.json');
    }
    fs.writeFileSync('dist/package.json', JSON.stringify(packageJson, null, 4));
})();

これは素のnode.jsなスクリプト。ちょっとビルドを整えるぐらいだったらこれでも不自由ないかとも思う。Gulpの良さはgulp.srcで対象ファイルをかき集めてきて、gulp.destでそのまま書き出せることでしょうかね。他に複雑なことをするのであれば、ファイルコピーだけでもgulpの方がわかりやすく便利なので出番が出てきます。複雑なことをするかですね。

fontkitを活用したら、ばっちりテキストのBBoxが取れました

github.com

必要なものだけfontkitの定義ファイルを作る。

// @/renderer/headless/fontkit.d.ts
declare module 'fontkit' {
    function openSync(filename: string): Font;

    class Font {
        ascent: number;
        descent: number;
        unitsPerEm: number;
        layout(text: string): GlyphRun;
    }

    class GlyphRun {
        glyphs: Glyph[];
    }

    class Glyph {
        advanceWidth: number;
    }
}

フォントを用意します。日本語フォントなのに無料で手に入れやすい Google提供のNote Sans SJK JP をダウンロードしてソースフォルダ内に格納します。フォントの定義はCSSファイルを参考に以下の通り。

// @/renderer/headless/noto/index.ts
import * as path from 'path';

export default [
    {
        fontWeight: 100,
        src: path.resolve(__dirname, 'NotoSansCJKjp-Thin.otf'),
    },
    {
        fontWeight: 200,
        src: path.resolve(__dirname, 'NotoSansCJKjp-Light.otf'),
    },
    {
        fontWeight: 300,
        src: path.resolve(__dirname, 'NotoSansCJKjp-DemiLight.otf'),
    },
    {
        fontWeight: 400,
        src: path.resolve(__dirname, 'NotoSansCJKjp-Regular.otf'),
    },
    {
        fontWeight: 500,
        src: path.resolve(__dirname, 'NotoSansCJKjp-Medium.otf'),
    },
    {
        fontWeight: 700,
        src: path.resolve(__dirname, 'NotoSansCJKjp-Bold.otf'),
    },
    {
        fontWeight: 900,
        src: path.resolve(__dirname, 'NotoSansCJKjp-Black.otf'),
    },
];

ここからは呆気ない。計算の意味は、フォントの各定義値をググって見つかる図を見ながら理解ください。

// @/renderer/headless/index.ts
import * as _ from 'lodash';
import { openSync, Font } from 'fontkit';
import noto from '@/renderer/headless/noto'; 

export function getComputedTextWidth(font: Font, fontSize: number, text: string): number {
    const textGlyphs = font.layout(text).glyphs;
    const totalAdvanceWidth = textGlyphs.reduce(
        (previous, current) => previous + current.advanceWidth, 0);
    return totalAdvanceWidth / font.unitsPerEm * fontSize;
}

export function getTextHeight(font: Font, fontSize: number): number {
    return (font.ascent - font.descent) / font.unitsPerEm * fontSize;
}

export type NotoSansCJKJPFontWeight = 100 | 200 | 300 | 400 | 500 | 700 | 900;

export function openNotoSansCJKJPFont(fontWeight: NotoSansCJKJPFontWeight) {
    const fontInfo = _.find(noto, value => _.get(value, 'fontWeight') === fontWeight);
    return openSync(_.get(fontInfo, 'src')!);
}

テストで実行。

import * as fontkit from 'fontkit';

import { openNotoSansCJKJPFont, getComputedTextWidth, getTextHeight } from '@/renderer/headless';

describe('fontkit.spec', () => {
    test('openNotoSansCJKJPFont', () => {
        const font = openNotoSansCJKJPFont(400);
        const width = getComputedTextWidth(font, 16, 'こんにちは世界');
        const height = getTextHeight(font, 16);
        console.log({ width, height });
    });
});

結果。

 PASS  src/renderer/headless/__tests__/headless.test.ts
  ● Console

    console.log src/renderer/headless/__tests__/headless.test.ts:10
      { width: 112, height: 23.68 }

で、これはバッチリ正しい結果でした。これで完全にヘッドレスでSVG組版できる!puppeteerは面白かったけど単なる寄り道だった。

import * as d3 from 'd3';
import { Font } from 'fontkit';
import { getComputedTextWidth, getTextHeight } from '@/renderer/headless';

// 複数行テキストを左寄せで書き出す。返値は書き出したテキストのBBOX。
export function appendText(parent: d3.Selection<any, any, any, any>,
        font: Font, fontSize: number,
        value: string, x: number = 0, y: number = 0): SVGRect {
    const values = value.split('\n');
    if (values.length === 0) {
        return { x, y, width: 0, height: 0 };
    }
    const fontHeight = getTextHeight(font, fontSize);
    const text = parent.append<SVGTextElement>('text')
        .attr('dominant-baseline', 'text-before-edge')
        .attr('font-size', fontSize);
    if (values.length === 1) {
        text.text(value)
            .attr('x', x)
            .attr('y', y);
    } else {
        values.forEach((current, i) => {
            text.append<SVGTSpanElement>('tspan')
                .text(current)
                .attr('x', x)
                .attr((i === 0) ? 'y' : 'dy', (i === 0) ? y : fontHeight);
        });
    }
    const width = d3.max(values, value => getComputedTextWidth(font, fontSize, value))!;
    return { x, y, width, height: fontHeight };
}

これがやりたかったこと。ヘッドレスでSVGTextElementを書き出してそのBBoxをとる。で、取れてた、やったー!