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も不調。
@types/nodeは治った
@types/nodeの10.0.4が出てたので試しにupgradeしてみたらTypeScriptのコアLibとぶつかる問題は解決していました。nにまだ10.0.0より後は来てないので本体入れ替えないから定義だけupgradeの意味はないけど、害もなし。
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サーバ書いてる限りには関係ないし。
偶数バージョンで根源的な不都合が出ると戸惑っちゃう。。。
この構成が正義。不用意に$ brew upgrade
でnodeのバージョンが変わらないし、nで適宜バージョンを変更して試せる。
開発ガイドラインとGetting started
内向きに開発ガイドラインとGetting Startedを兼ねたようなものを書こうとしたが、目次だけ書き出したけどこれでも環境作るところだけ、この後にコーディング編とテスト編とリポジトリ&CI&デプロイ編があって、そもそもコーディング編にはSPA章とサーバレス章と自前WEBサーバを含むローカルツール章があり、さらにSPA章には画面出すところまでとFluxとコンポーネントとルーターとAPI通信とイベントハンドリングがあり、フォーム&バリデーションやi18nやレスポンシブがある。。。さらに続く。 いつか暇になって、かつ書いたら効果が高い時にもっと目次も練って書こうと思う。おそらくそんな時は来ないだろうとも思いつつ。
- node環境構築
- プロジェクトの作成
- 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であれば)バンドラーの設定
- (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。
聞くところによると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が取れました
必要なものだけ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をとる。で、取れてた、やったー!
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ほどのプロジェクトが実装できてないのだから、相当の難度なのでしょう。そこは頑張らなくても怒られないと思う。
諦めてヘッドレスブラウザでやる
ヘッドレスブラウザでやるのには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の型定義ファイル作るところからか。。。