📖

組版システムの品質を保証する自動テスト

2024/02/18に公開

組版システムの開発時に、組版結果をスナップショットテストでチェックする簡単な仕組みを入れてみたため、その過程を紹介します。

背景

最近、JavaScript 上で作動する組版システム[1](図 1)を開発しています。組版とは、文字や図版をページ上に配置して紙面を構成する作業を指し、代表的な組版システムとして LaTeX, SATySFi, Typst 等が挙げられます。

組版システムの中でも特に文字組みは要件が複雑であり、機能を追加する度に、意図しない箇所の組版結果に影響を与えてしまうことが往々にしてあります。例えば、和欧混植(日本語と英語を混ぜた組版)を実現した際に日本語の処理が正しく動作しなくなった、といったケースは枚挙に暇がありません。(イラレやインデザのバージョンを上げたら出力が変わった!といった例は判りやすいと思います)

したがって、これらのソフトウェアを作る際には、文字組みを制御する組版アルゴリズムが正しく作動し、適切な出力が得られるかを継続的に検証可能な環境が望ましいです。幸い Node.js のエコシステムではテストティングフレームワークが成熟していますので、これを利用していきます。

開発中のソフトウェアのスクリーンショット
図 1:開発中のソフトウェア

実装

Jest + jest-image-snapshot を利用して、スナップショットテストを実現します。スナップショットテストは UI 等の開発に多く用いられる自動テストの手法であり、コードの変更前後において、出力結果の UI 等(のスクリーンショット)に差分が生じないかをチェックします。

開発中のソフトウェアに限らず、多くの組版システムは PDF 形式で組版結果を出力します。素の PDF は比較が困難であるため、pdfjs-dist および node-canvas を用いて PDF を画像化します。pdfjs を採用することで、Ghostscript 等の依存関係が不要になります。

yarn add -D jest jest-image-snapshot  pdfjs-dist node-canvas
typesetting.test.js
import fs from "fs";
import { createCanvas } from "canvas";
import * as pdfjs from "pdfjs-dist/legacy/build/pdf.mjs";

import { Block } from "../lib/block.js";
import { minitype } from "../main";
import { parseBody } from "../parser/block-parser.js";
import { shorthand } from "../style/figure.js";
import { cmyk } from "../style/style.js";

const typeset = async (body: Block[], pdfPath: string) => {
  // 組版結果が pdfPath に出力される
  await minitype(
    body,
    {
      size: { width: 4 * 10, height: 4 * (1.5 * 5 + 1) },
      padding: shorthand([0]),
      block: {
        paragraph: {
          size: 4,
          lineHeight: 4 * 1.5,
          font: "SourceHanSans-Normal",
          color: cmyk(0, 0, 0, 100),
        },
      }
    },
    pdfPath,
  );

  // 出力結果を png に変換
  const pdfData = new Uint8Array(fs.readFileSync(pdfPath));
  const pdfDoc = await pdfjs.getDocument({ data: pdfData }).promise;
  // 1 ページ目のみを処理
  const page = await pdfDoc.getPage(1);
  const viewport = page.getViewport({ scale: 2.0 });
  const canvas = createCanvas(viewport.width, viewport.height);
  const context = canvas.getContext("2d");

  await page.render({ canvasContext: context as any, viewport }).promise;
  return canvas.toBuffer("image/png");
};

最後に、変換後の画像を expect(page).toMatchImageSnapshot() します。

typesetting.test.js
describe("組版品質のテスト", () => {
  // テスト前に test.pdf を削除
  beforeEach(() => {
    fs.rmSync("test.pdf", { force: true });
  });

  it.each([
    [
      "ベタ組みができる",
      "桜の樹の下には屍体が埋まっている!これは信じていいことなんだよ。何故って、桜の花があんなにも見事に咲くなんて信じられない",
    ],
    [
      "改行ができる",
      "桜の樹の下には屍体が埋まっている!\n/\nこれは信じていいことなんだよ。何故って、桜の花があんなにも見事に咲くなんて",
    ],
    [
      "行頭・行末禁則(追い出し)ができる",
      "桜の樹の下には屍体が埋まっている!これは、信じていいことなんだよ。何故って、「桜の花があんなにも」見事に咲くなんて",
    ],
    [
      "約物アキが正しく制御される",
      "「桜の樹の下には屍体が埋まっている!これは・『信じて』いいことなんだよ。」何故って、(桜の花)。があんなにも見事に咲く",
    ],
    [
      "分割禁止を処理できる",
      "桜の樹の下には屍体が埋まっている!これは信じていいことなん……だよ。何故って、桜の花があんなにも見事に咲くなんて",
    ],
    [
      "和欧混植を正しく処理できる",
      "桜の樹の下にはcadaverが埋まっている!これは信じていいことなんだよ。何故って、 Cherry blossom があんなにも見事に",
    ],
  ])("%s", async (_, text) => {
    const body = parseBody(`/p\n${text}`);
    const page = await typeset(body, "test.pdf");
    expect(page).toMatchImageSnapshot();
  });
});

実行結果

テストを実行すると、/__image_snapshots__/ 以下に組版結果のスナップショットが生成されます。生成されたスナップショットの一覧を図 2 に示します。


図 2:生成されたスナップショット。原文は梶井基次郎「桜の樹の下には」

活用例

活用例をいくつか紹介します。

まずは判りやすい例から。図 3 では、ある修正によって約物(句読点や括弧といった記号類)の前後のアキが 0 となり、非常に読み難い状態に陥っています。こうした意図しない差分が生じた場合にはテストが落ちるため、迅速にエラーを発見することが出来ます。


図 3:約物のアキに関するスナップショットの差分

もう少しニッチな例を。図 4 では、改善前(左側)は !, っ が誤って分離禁止と判断され、両端揃えをした際にこれらの文字の後へのアキの挿入が制限されていました。改善後(右側)では、そのバグが修正された様子が伺えます。

図 4:分離禁止に関するスナップショットの差分

このように、一見違いが判りにくく見落しが発生しやすい差分も、テストを通すことで安全に検出することができます。組版結果が正しいかのチェックは人の目を通す必要がありますが、一度正しい出力をコミットしてしまえば、以降はアルゴリズムの修正時に従来通りの結果が得られていることを、自動テストを用いて担保することが出来ます。

余談ですが、InDesign の開発時には夏目漱石「草枕」を完全に組むことを目標にテストを設計したそうです[2]

脚注
  1. 組版のトピックは度々 Zenn 上で話題にしている
    例:Web だって組版の夢を見る――新聞のように自在にテキストを流し込むには ↩︎

  2. 笠原一輝: 20周年を迎えたInDesign日本語版が、日本でトップシェアになるまでの歴史, マイナビニュース ↩︎

Discussion