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をとる。で、取れてた、やったー!